commit 3214c7ce39492541244ac35b6756fc2decfc0864
parent cd8f204566fd75adaf8e2ab0d783c76b02a4dc13
Author: Christian Grothoff <christian@grothoff.org>
Date: Fri, 5 Dec 2025 21:53:11 +0100
add Typst for cover page
Diffstat:
3 files changed, 257 insertions(+), 12 deletions(-)
diff --git a/contrib/typst/Makefile.am b/contrib/typst/Makefile.am
@@ -5,6 +5,7 @@ dist_formdata_DATA = \
_cover_.typ \
challenger_postal.typ \
challenger_sms.typ \
+ taler-logo.svg \
pointing_finger.svg \
vqf_902_1_customer.typ \
vqf_902_4.typ \
diff --git a/contrib/typst/_cover_.typ b/contrib/typst/_cover_.typ
@@ -39,6 +39,120 @@
)
}
+ // Helper function to get nice labels for standard properties
+ let get_property_label(key) = {
+ if key == "pep" { "Politically exposed person (PEP)" }
+ else if key == "sanctioned" { "Sanctioned account" }
+ else if key == "high_risk" { "High risk account" }
+ else if key == "business_domain" { "Business domain" }
+ else if key == "is_frozen" { "Account frozen" }
+ else if key == "was_reported" { "Reported to authorities" }
+ else { key }
+ }
+
+ // Helper function to format timeframe
+ let format_timeframe(d_us) = {
+ if d_us == "forever" {
+ "forever"
+ } else {
+ let us = int(d_us)
+ let s = calc.quo(us, 1000000)
+ let m = calc.quo(s, 60)
+ let h = calc.quo(m, 60)
+ let d = calc.quo(h, 24)
+
+ if calc.rem(us, 1000000) == 0 {
+ if calc.rem(s, 60) == 0 {
+ if calc.rem(m, 60) == 0 {
+ if calc.rem(h, 24) == 0 {
+ str(d) + " day" + if d != 1 { "s" } else { "" }
+ } else {
+ str(h) + " hour" + if h != 1 { "s" } else { "" }
+ }
+ } else {
+ str(m) + " minute" + if m != 1 { "s" } else { "" }
+ }
+ } else {
+ str(s) + " s"
+ }
+ } else {
+ str(us) + " μs"
+ }
+ }
+ }
+
+
+ // Helper function to format timestamp; ignores leap seconds (too variable)
+ let format_timestamp(ts) = {
+ if type(ts) == dictionary and "t_s" in ts {
+ let t_s = ts.t_s
+ if t_s == "never" {
+ "never"
+ } else {
+ // Convert Unix timestamp to human-readable format
+ let seconds = int(t_s)
+ let days_since_epoch = calc.quo(seconds, 86400)
+ let remaining_seconds = calc.rem(seconds, 86400)
+ let hours = calc.quo(remaining_seconds, 3600)
+ let minutes = calc.quo(calc.rem(remaining_seconds, 3600), 60)
+ let secs = calc.rem(remaining_seconds, 60)
+
+ // Helper to check if year is leap year
+ let is_leap(y) = {
+ calc.rem(y, 4) == 0 and (calc.rem(y, 100) != 0 or calc.rem(y, 400) == 0)
+ }
+
+ // Calculate year, month, day
+ let year = 1970
+ let days_left = days_since_epoch
+
+ // Find the year
+ let done = false
+ while not done {
+ let days_in_year = if is_leap(year) { 366 } else { 365 }
+ if days_left >= days_in_year {
+ days_left = days_left - days_in_year
+ year = year + 1
+ } else {
+ done = true
+ }
+ }
+
+ // Days in each month
+ let days_in_months = if is_leap(year) {
+ (31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ } else {
+ (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
+ }
+
+ // Find month and day
+ let month = 1
+ for days_in_month in days_in_months {
+ if days_left >= days_in_month {
+ days_left = days_left - days_in_month
+ month = month + 1
+ } else {
+ break
+ }
+ }
+ let day = days_left + 1
+
+ // Format with leading zeros
+ let m_str = if month < 10 { "0" + str(month) } else { str(month) }
+ let d_str = if day < 10 { "0" + str(day) } else { str(day) }
+ let h_str = if hours < 10 { "0" + str(hours) } else { str(hours) }
+ let min_str = if minutes < 10 { "0" + str(minutes) } else { str(minutes) }
+ let s_str = if secs < 10 { "0" + str(secs) } else { str(secs) }
+
+ str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":" + min_str + ":" + s_str + " UTC"
+ }
+ } else {
+ str(ts)
+ }
+ }
+
+
+
// Header
align(center, text(size: 11pt, weight: "bold")[CONFIDENTIAL])
@@ -47,45 +161,165 @@
grid(
columns: (50%, 50%),
gutter: 1em,
- image("vss_vqf_verein.png", width: 80%),
+ image("taler-logo.svg", width: 80%),
align(right)[
#table(
columns: (1fr, 1fr),
stroke: 0.5pt + black,
inset: 5pt,
align: (left, left),
- [AMLA File No.],
- [#get("FILE_NUMBER")]
+ [AMLA File No.], [#get("FILE_NUMBER")],
+ [Account open?], [#checkbox(get("is_active"))],
+ [Active investigation?], [#checkbox(get("to_investigate"))],
)
]
)
v(1em)
- // Section 1: FIXME -- Validated address
- text(size: 11pt, weight: "bold")[Validated address:]
+ // Section 1: Properties
+ text(size: 11pt, weight: "bold")[Properties:]
v(0.5em)
block(breakable: false)[
#v(0.5em)
+ #let props = get("properties", default: (:))
+ #let standard_props = ("pep", "sanctioned", "high_risk", "business_domain", "is_frozen", "was_reported")
+ #let all_keys = props.keys()
+
#table(
columns: (35%, 65%),
stroke: 0.5pt + black,
inset: 5pt,
- [Contact name:], [#get("CONTACT_NAME")],
- [Address:], [#get("ADDRESS_LINES").split("\n").join(linebreak())],
- [Country:], [#get("ADDRESS_COUNTRY")],
+ align: (left, left),
+ ..for key in all_keys {
+ let value = props.at(key)
+ let label = get_property_label(key)
+
+ // Render based on value type
+ if type(value) == bool {
+ ([#label], [#checkbox(value)])
+ } else {
+ ([#label], [#value])
+ }
+ }
)
#v(0.5em)
]
+
+ // Section 2: Rules
+ let rules_data = get("rules", default: none)
+
+ if rules_data != none {
+ text(size: 11pt, weight: "bold")[
+ Rules
+ #if "expiration_time" in rules_data {
+ [ (expires: #format_timestamp(rules_data.expiration_time))]
+ }
+ :
+ ]
+
+ v(0.5em)
+
+ let rules = rules_data.at("rules", default: ())
+
+ if rules.len() > 0 {
+ block(breakable: true)[
+ #table(
+ columns: (14%, 13%, 13%, 20%, 20%, 10%, 10%),
+ stroke: 0.5pt + black,
+ inset: 4pt,
+ align: (left, left, left, left, left, center, center),
+ table.header(
+ [*Operation*],
+ [*Threshold*],
+ [*Timeframe*],
+ [*Rule Name*],
+ [*Measures*],
+ [*Exposed*],
+ [*Verboten*]
+ ),
+ ..for rule in rules {
+ let op_type = rule.at("operation_type", default: "")
+ let threshold = rule.at("threshold", default: "")
+ let timeframe_raw = rule.at("timeframe", default: (:))
+ let timeframe = if "d_us" in timeframe_raw {
+ format_timeframe(timeframe_raw.d_us)
+ } else { "" }
+ let rule_name = rule.at("rule_name", default: "")
+ let measures = rule.at("measures", default: ())
+ let exposed = rule.at("exposed", default: false)
+ let is_verboten = if type(measures) == array { "verboten" in measures } else { "verboten" == measures }
+ let measures_text = if type(measures) == array {
+ measures.filter(m => m != "verboten").map(m => str(m)).join(", ")
+ } else if measures != "verboten" {
+ str(measures)
+ } else {
+ ""
+ }
+
+ (
+ [#op_type],
+ [#threshold],
+ [#timeframe],
+ [#rule_name],
+ [#measures_text],
+ [#checkbox(exposed)],
+ [#checkbox(is_verboten)]
+ )
+ }
+ )
+ ]
+ } else {
+ text(style: "italic")[No rules defined.]
+ }
+ }
}
// Example usage:
#form((
"FILE_NUMBER": "42",
- "properties": { "PROPA" : "VALUEA", "PROPB":"VALUEB" }, // FIXME: better example...
- "rules": { }, // FIXME: object?
- "is_active" : false,
- "to_investigate" : false,
+ "is_active": true,
+ "to_investigate": false,
+ "properties": (
+ "pep": false,
+ "sanctioned": false,
+ "high_risk": true,
+ "business_domain": "Financial Services",
+ "is_frozen": false,
+ "was_reported": false,
+ "custom_field": "Custom Value"
+ ),
+ "rules": (
+ "expiration_time": ("t_s": 1764967786), // Fri Dec 5 20:49:46 UTC 2025
+ "rules": (
+ (
+ "operation_type": "WITHDRAW",
+ "rule_name": "large_withdrawal",
+ "threshold": "EUR:10000",
+ "timeframe": ("d_us": 86400000000),
+ "measures": ("kyc_review"),
+ "display_priority": 10,
+ "exposed": true
+ ),
+ (
+ "operation_type": "DEPOSIT",
+ "rule_name": "suspicious_deposit",
+ "threshold": "EUR:50000",
+ "timeframe": ("d_us": 604800000000),
+ "measures": ("verboten"),
+ "display_priority": 20,
+ "exposed": false
+ ),
+ (
+ "operation_type": "BALANCE",
+ "threshold": "EUR:5000",
+ "timeframe": ("d_us": 3600000000),
+ "measures": ("aml_check", "manager_approval"),
+ "display_priority": 5,
+ "exposed": true
+ )
+ )
+ )
))
\ No newline at end of file
diff --git a/contrib/typst/taler-logo.svg b/contrib/typst/taler-logo.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg>
+\ No newline at end of file