_cover_.typ (9081B)
1 // Cover page for AML files. 2 // Renders all account properties, current rules, etc. 3 4 #let form(data) = { 5 set page( 6 paper: "a4", 7 margin: (left: 2cm, right: 2cm, top: 2cm, bottom: 2.5cm), 8 footer: context [ 9 #grid( 10 columns: (1fr, 1fr), 11 align: (left, right), 12 text(size: 8pt)[ 13 ], 14 text(size: 8pt)[ 15 Page #here().page() of #counter(page).final().first() 16 ] 17 ) 18 ] 19 ) 20 21 set text(font: "Liberation Sans", size: 10pt) 22 set par(justify: false, leading: 0.65em) 23 24 // Helper function to get value or empty string 25 let get(key, default: "") = { 26 data.at(key, default: default) 27 } 28 29 // Helper function for checkbox 30 let checkbox(checked) = { 31 box( 32 width: 3mm, 33 height: 3mm, 34 stroke: 0.5pt + black, 35 inset: 0.3mm, 36 if checked == true or checked == "true" { 37 place(center + horizon, text(size: 8pt, sym.checkmark)) 38 } 39 ) 40 } 41 42 // Helper function to get nice labels for standard properties 43 let get_property_label(key) = { 44 if key == "pep" { "Politically exposed person (PEP)" } 45 else if key == "sanctioned" { "Sanctioned account" } 46 else if key == "high_risk" { "High risk account" } 47 else if key == "business_domain" { "Business domain" } 48 else if key == "is_frozen" { "Account frozen" } 49 else if key == "was_reported" { "Reported to authorities" } 50 else { key } 51 } 52 53 // Helper function to format timeframe 54 let format_timeframe(d_us) = { 55 if d_us == "forever" { 56 "forever" 57 } else { 58 let us = int(d_us) 59 let s = calc.quo(us, 1000000) 60 let m = calc.quo(s, 60) 61 let h = calc.quo(m, 60) 62 let d = calc.quo(h, 24) 63 64 if calc.rem(us, 1000000) == 0 { 65 if calc.rem(s, 60) == 0 { 66 if calc.rem(m, 60) == 0 { 67 if calc.rem(h, 24) == 0 { 68 str(d) + " day" + if d != 1 { "s" } else { "" } 69 } else { 70 str(h) + " hour" + if h != 1 { "s" } else { "" } 71 } 72 } else { 73 str(m) + " minute" + if m != 1 { "s" } else { "" } 74 } 75 } else { 76 str(s) + " s" 77 } 78 } else { 79 str(us) + " μs" 80 } 81 } 82 } 83 84 85 // Helper function to format timestamp; ignores leap seconds (too variable) 86 let format_timestamp(ts) = { 87 if type(ts) == dictionary and "t_s" in ts { 88 let t_s = ts.t_s 89 if t_s == "never" { 90 "never" 91 } else { 92 // Convert Unix timestamp to human-readable format 93 let seconds = int(t_s) 94 let days_since_epoch = calc.quo(seconds, 86400) 95 let remaining_seconds = calc.rem(seconds, 86400) 96 let hours = calc.quo(remaining_seconds, 3600) 97 let minutes = calc.quo(calc.rem(remaining_seconds, 3600), 60) 98 let secs = calc.rem(remaining_seconds, 60) 99 100 // Helper to check if year is leap year 101 let is_leap(y) = { 102 calc.rem(y, 4) == 0 and (calc.rem(y, 100) != 0 or calc.rem(y, 400) == 0) 103 } 104 105 // Calculate year, month, day 106 let year = 1970 107 let days_left = days_since_epoch 108 109 // Find the year 110 let done = false 111 while not done { 112 let days_in_year = if is_leap(year) { 366 } else { 365 } 113 if days_left >= days_in_year { 114 days_left = days_left - days_in_year 115 year = year + 1 116 } else { 117 done = true 118 } 119 } 120 121 // Days in each month 122 let days_in_months = if is_leap(year) { 123 (31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) 124 } else { 125 (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) 126 } 127 128 // Find month and day 129 let month = 1 130 for days_in_month in days_in_months { 131 if days_left >= days_in_month { 132 days_left = days_left - days_in_month 133 month = month + 1 134 } else { 135 break 136 } 137 } 138 let day = days_left + 1 139 140 // Format with leading zeros 141 let m_str = if month < 10 { "0" + str(month) } else { str(month) } 142 let d_str = if day < 10 { "0" + str(day) } else { str(day) } 143 let h_str = if hours < 10 { "0" + str(hours) } else { str(hours) } 144 let min_str = if minutes < 10 { "0" + str(minutes) } else { str(minutes) } 145 let s_str = if secs < 10 { "0" + str(secs) } else { str(secs) } 146 147 str(year) + "-" + m_str + "-" + d_str + " " + h_str + ":" + min_str + ":" + s_str + " UTC" 148 } 149 } else { 150 str(ts) 151 } 152 } 153 154 155 156 // Header 157 align(center, text(size: 11pt, weight: "bold")[CONFIDENTIAL]) 158 159 v(0.5em) 160 161 grid( 162 columns: (50%, 50%), 163 gutter: 1em, 164 image("taler-logo.svg", width: 80%), 165 align(right)[ 166 #table( 167 columns: (1fr, 1fr), 168 stroke: 0.5pt + black, 169 inset: 5pt, 170 align: (left, left), 171 [AMLA File No.], [#get("FILE_NUMBER")], 172 [Account open?], [#checkbox(get("is_active"))], 173 [Active investigation?], [#checkbox(get("to_investigate"))], 174 ) 175 ] 176 ) 177 178 v(1em) 179 180 // Section 1: Properties 181 text(size: 11pt, weight: "bold")[Properties:] 182 183 v(0.5em) 184 185 block(breakable: false)[ 186 #v(0.5em) 187 #let props = get("properties", default: (:)) 188 #let standard_props = ("pep", "sanctioned", "high_risk", "business_domain", "is_frozen", "was_reported") 189 #let all_keys = props.keys() 190 191 #table( 192 columns: (35%, 65%), 193 stroke: 0.5pt + black, 194 inset: 5pt, 195 align: (left, left), 196 ..for key in all_keys { 197 let value = props.at(key) 198 let label = get_property_label(key) 199 200 // Render based on value type 201 if type(value) == bool { 202 ([#label], [#checkbox(value)]) 203 } else { 204 ([#label], [#value]) 205 } 206 } 207 ) 208 #v(0.5em) 209 ] 210 211 // Section 2: Rules 212 let rules_data = get("rules", default: none) 213 214 if rules_data != none { 215 text(size: 11pt, weight: "bold")[ 216 Rules 217 #if "expiration_time" in rules_data { 218 [ (expires: #format_timestamp(rules_data.expiration_time))] 219 } 220 : 221 ] 222 223 v(0.5em) 224 225 let rules = rules_data.at("rules", default: ()) 226 227 if rules.len() > 0 { 228 block(breakable: true)[ 229 #table( 230 columns: (17%, 13%, 13%, 20%, 17%, 10%, 10%), 231 stroke: 0.5pt + black, 232 inset: 4pt, 233 align: (left, left, left, left, left, center, center), 234 table.header( 235 [*Operation*], 236 [*Threshold*], 237 [*Timeframe*], 238 [*Rule Name*], 239 [*Measures*], 240 [*Exposed*], 241 [*Verboten*] 242 ), 243 ..for rule in rules { 244 let op_type = rule.at("operation_type", default: "") 245 let threshold = rule.at("threshold", default: "") 246 let timeframe_raw = rule.at("timeframe", default: (:)) 247 let timeframe = if "d_us" in timeframe_raw { 248 format_timeframe(timeframe_raw.d_us) 249 } else { "" } 250 let rule_name = rule.at("rule_name", default: "") 251 let measures = rule.at("measures", default: ()) 252 let exposed = rule.at("exposed", default: false) 253 let is_verboten = if type(measures) == array { "verboten" in measures } else { "verboten" == measures } 254 let measures_text = if type(measures) == array { 255 measures.filter(m => m != "verboten").map(m => str(m)).join(", ") 256 } else if measures != "verboten" { 257 str(measures) 258 } else { 259 "" 260 } 261 262 ( 263 [#op_type], 264 [#threshold], 265 [#timeframe], 266 [#rule_name], 267 [#measures_text], 268 [#checkbox(exposed)], 269 [#checkbox(is_verboten)] 270 ) 271 } 272 ) 273 ] 274 } else { 275 text(style: "italic")[No rules defined.] 276 } 277 } 278 } 279 280 // Example usage: 281 #form(( 282 "FILE_NUMBER": "42", 283 "is_active": true, 284 "to_investigate": false, 285 "properties": ( 286 "pep": false, 287 "sanctioned": false, 288 "high_risk": true, 289 "business_domain": "Financial services", 290 "is_frozen": false, 291 "was_reported": false, 292 "custom_field": "Custom value" 293 ), 294 "rules": ( 295 "expiration_time": ("t_s": 1764967786), // Fri Dec 5 20:49:46 UTC 2025 296 "rules": ( 297 ( 298 "operation_type": "WITHDRAW", 299 "rule_name": "large_withdrawal", 300 "threshold": "EUR:10000", 301 "timeframe": ("d_us": 86400000000), 302 "measures": ("kyc_review"), 303 "display_priority": 10, 304 "exposed": true 305 ), 306 ( 307 "operation_type": "DEPOSIT", 308 "rule_name": "suspicious_deposit", 309 "threshold": "EUR:50000", 310 "timeframe": ("d_us": 604800000000), 311 "measures": ("verboten"), 312 "display_priority": 20, 313 "exposed": false 314 ), 315 ( 316 "operation_type": "BALANCE", 317 "threshold": "EUR:5000", 318 "timeframe": ("d_us": 3600000000), 319 "measures": ("aml_check", "manager_approval"), 320 "display_priority": 5, 321 "exposed": true 322 ) 323 ) 324 ) 325 ))