Swiss QR bills
Swiss QR bills: the SPC payload written and read in its fixed order, QR-IBANs told from ordinary ones, QRR references with their mod 10 check digit and SCOR with theirs, each rule refused by name, and an SVG of the code with its Swiss cross.
Version 1.0.0 · 8 functions · 2 crates
Functions
What use swiss_qr.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use swiss_qr_is_qr_iban, swiss_qr_qrr_problem from swiss_qr (Quillo 0.6).
swiss_qr_is_qr_iban(iban: str) → boolWhether an IBAN is a QR-IBAN: its bank number (positions 5 to 9) is between 30000 and 31999. A QR-IBAN takes only a QRR reference, an ordinary IBAN never does.
swiss_qr_qrr_problem(reference: str) → str?Why a QRR reference fails, or null: 27 digits, the last the recursive mod 10 check digit of the others.
swiss_qr_qrr(digits: str) → stror an errorA QRR reference from up to 26 digits of your own (zero-filled on the left), with its check digit.
swiss_qr_scor_problem(reference: str) → str?Why a SCOR (ISO 11649) reference fails, or null: starts RF, 5 to 25 letters and digits, and its check digits agree.
swiss_qr_scor(body: str) → stror an errorA SCOR reference for your own text (letters and digits, up to 21): the check digits worked out. swiss_qr_scor("539007547034") is RF18539007547034.
swiss_qr_build(bill: any) → stror an errorThe payload of a bill: { account, creditor: { name, street, number, postcode, town, country }, amount, currency, debtor, reference, message, billing_info, alternatives }. The type of reference follows from it: 27 digits is QRR, an RF one SCOR, none NON. Fields are in the guidelines' order, separated by CRLF, with 7 empty lines for the reserved ultimate creditor.
swiss_qr_parse(payload: str) → anyor an errorReads a payload back into { version, account, qr_iban, creditor, amount, currency, debtor, reference_type, reference, message, billing_info, alternatives }, with the same rules. A combined address (type K), which older bills have, is read too. Errors name the field.
swiss_qr_svg(payload: str) → stror an errorAn SVG of the code, 46 mm wide inside a 5 mm quiet zone, error level M, with the 7 mm Swiss cross in its middle. Refuses a payload that needs more than QR version 25, as the guidelines do.
Get it
$ quillo install swiss_qr # swiss_qr.qwer into this folder$ quillo install swiss_qr --example # and its example, swiss_qr.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/swiss_qr.qwer$ curl -O https://quillolang.org/plugins/files/swiss_qr.qweThen, in your program, all of it or only what you call:
use swiss_qr.qweruse swiss_qr_is_qr_iban, swiss_qr_qrr_problem from swiss_qr // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }qrcode = { version = "0.14", default-features = false }Example
// A Swiss QR bill: the payload of the payment part, its code as an SVG with the Swiss cross, and// what a bank's scanner refuses. The first bill is the example of SIX's Implementation Guidelines.//// quillo run swiss_qr.qwe then open http://localhost:8080/payload, /code.svg, /read and /refuseduse "swiss_qr.qwer"let BILL = { account: "CH44 3199 9123 0008 8901 2", creditor: { name: "Robert Schneider AG", street: "Rue du Lac", number: "1268", postcode: "2501", town: "Biel", country: "CH" }, amount: "1949.75", currency: "CHF", debtor: { name: "Pia-Maria Rutschmann-Schnyder", street: "Grosse Marktgasse", number: "28", postcode: "9400", town: "Rorschach", country: "CH" }, reference: "21 00000 00003 13947 14300 09017", message: "Order of 15 June 2020", billing_info: "//S1/10/10201409/11/200701/20/140.000-53",}route GET /payload = respond(200, swiss_qr_build(BILL)?, { "content-type": "text/plain; charset=utf-8" })route GET /code.svg = respond(200, swiss_qr_svg(swiss_qr_build(BILL)?)?, { "content-type": "image/svg+xml" })route GET /read { let b = swiss_qr_parse(swiss_qr_build(BILL)?)? { kind: b.reference_type, reference: b.reference, qr_iban: b.qr_iban, amount: b.amount, town: b.creditor.town, debtor: b.debtor.name, info: b.billing_info }}// An ordinary IBAN with a SCOR reference, and with none.let ORDINARY = merge(BILL, { account: "CH58 0079 1123 0008 8901 2", amount: null, debtor: null })route GET /scor { let scor = swiss_qr_parse(swiss_qr_build(merge(ORDINARY, { reference: "RF18 5390 0754 7034" }))?)? let none = swiss_qr_parse(swiss_qr_build(merge(ORDINARY, { reference: null }))?)? { scor: scor.reference_type, none: none.reference_type, amount: none.amount, debtor: none.debtor }}route GET /references = { made: swiss_qr_qrr("2100000000031394714300090")?, rf: swiss_qr_scor("539007547034")?, wrong_digit: swiss_qr_qrr_problem("210000000003139471430009018"), good: swiss_qr_qrr_problem("210000000003139471430009017"), qr_iban: swiss_qr_is_qr_iban("CH44 3199 9123 0008 8901 2"), ordinary: swiss_qr_is_qr_iban("CH58 0079 1123 0008 8901 2"),}route GET /refused = { non_with_qr: swiss_qr_build(merge(BILL, { reference: null })).error, qrr_ordinary: swiss_qr_build(merge(ORDINARY, { reference: "210000000003139471430009017" })).error, scor_qr: swiss_qr_build(merge(BILL, { reference: "RF18539007547034" })).error, digit: swiss_qr_build(merge(BILL, { reference: "210000000003139471430009018" })).error, currency: swiss_qr_build(merge(BILL, { currency: "USD" })).error, country: swiss_qr_build(merge(BILL, { account: "DE89370400440532013000" })).error, moved: swiss_qr_parse(replace(swiss_qr_build(BILL)?, "EPD", "XXX")).error,}$ quillo run swiss_qr.qwe$ curl 'localhost:8080/payload'$ curl 'localhost:8080/code.svg'$ curl 'localhost:8080/read'$ curl 'localhost:8080/scor'$ curl 'localhost:8080/references'$ curl 'localhost:8080/refused'Source
//! Swiss QR bills: the SPC payload written and read field by field in its fixed order, QR-IBAN and reference rules (QRR with its mod 10 check, SCOR, NON), and an SVG of the code with its Swiss cross.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }//! qrcode = { version = "0.14", default-features = false }use qrcode::{Color, EcLevel, QrCode};use serde_json::{json, Value};const MOD10: [u8; 10] = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5];const MAX_CHARS: usize = 997;fn compact(x: &str) -> String { x.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_uppercase()}fn mod97(s: &str) -> Option<u32> { let mut r = 0u32; for c in s.chars() { let d = c.to_digit(36)?; r = if d > 9 { (r * 100 + d) % 97 } else { (r * 10 + d) % 97 }; } Some(r)}fn iban_problem(i: &str) -> Option<String> { if !i.is_ascii() || i.len() < 2 || (&i[..2] != "CH" && &i[..2] != "LI") { return Some(format!("`{}`: a QR bill's account is a Swiss or Liechtenstein IBAN", i)); } if i.len() != 21 { return Some(format!("{} has {} characters; a CH or LI IBAN has 21", i, i.len())); } if !i[2..].bytes().all(|b| b.is_ascii_alphanumeric()) || mod97(&format!("{}{}", &i[4..], &i[..4])) != Some(1) { return Some(format!("the check digits of {} are wrong", i)); } None}/// Whether an IBAN is a QR-IBAN: its bank number (positions 5 to 9) is between 30000 and 31999. A QR-IBAN takes only a QRR reference, an ordinary IBAN never does.pub fn swiss_qr_is_qr_iban(iban: &str) -> bool { let i = compact(iban); i.len() == 21 && iban_problem(&i).is_none() && matches!(i[4..9].parse::<u32>(), Ok(30000..=31999))}fn mod10_digit(digits: &str) -> u8 { let mut carry = 0u8; for b in digits.bytes() { carry = MOD10[((carry + (b - b'0')) % 10) as usize]; } (10 - carry) % 10}/// Why a QRR reference fails, or null: 27 digits, the last the recursive mod 10 check digit of the others.pub fn swiss_qr_qrr_problem(reference: &str) -> Option<String> { let r = compact(reference); if r.len() != 27 || !r.bytes().all(|b| b.is_ascii_digit()) { return Some(format!("a QR reference has 27 digits, `{}` has {}", r, r.len())); } let want = mod10_digit(&r[..26]); let have = r.as_bytes()[26] - b'0'; if want != have { return Some(format!("QR reference {}: the check digit is {} and should be {}", r, have, want)); } None}/// A QRR reference from up to 26 digits of your own (zero-filled on the left), with its check digit.pub fn swiss_qr_qrr(digits: &str) -> Result<String, String> { let d = compact(digits); if d.is_empty() || d.len() > 26 || !d.bytes().all(|b| b.is_ascii_digit()) { return Err(format!("`{}` should be 1 to 26 digits", digits)); } let body = format!("{:0>26}", d); let c = mod10_digit(&body); Ok(format!("{}{}", body, c))}/// Why a SCOR (ISO 11649) reference fails, or null: starts RF, 5 to 25 letters and digits, and its check digits agree.pub fn swiss_qr_scor_problem(reference: &str) -> Option<String> { let r = compact(reference); if r.len() < 5 || r.len() > 25 || !r.is_ascii() || !r.starts_with("RF") || !r[2..].bytes().all(|b| b.is_ascii_alphanumeric()) { return Some(format!("`{}`: a SCOR reference is RF, two check digits and up to 21 letters or digits", r)); } if mod97(&format!("{}{}", &r[4..], &r[..4])) != Some(1) { return Some(format!("SCOR reference {}: the check digits are wrong", r)); } None}/// A SCOR reference for your own text (letters and digits, up to 21): the check digits worked out. `swiss_qr_scor("539007547034")` is RF18539007547034.pub fn swiss_qr_scor(body: &str) -> Result<String, String> { let b = compact(body); if b.is_empty() || b.len() > 21 || !b.bytes().all(|x| x.is_ascii_alphanumeric()) { return Err(format!("`{}` should be 1 to 21 letters and digits", body)); } let r = mod97(&format!("{}RF00", b)).ok_or("not letters and digits")?; Ok(format!("RF{:02}{}", 98 - r, b))}fn text(v: &Value, k: &str) -> String { match v.get(k) { Some(Value::String(s)) => s.trim().to_string(), Some(Value::Number(n)) => n.to_string(), _ => String::new(), }}/// The Swiss Payments character set: Latin letters with the accents of the Implementation Guidelines, digits, and common signs.fn chars_ok(s: &str) -> bool { s.chars().all(|c| matches!(c, ' '..='~') || "£´ÀÁÂÄÇÈÉÊËÌÍÎÏÑÒÓÔÖÙÚÛÜßàáâäçèéêëìíîïñòóôöùúûüý€".contains(c))}fn checked(what: &str, x: &str, max: usize) -> Result<(), String> { if x.chars().count() > max { return Err(format!("{} is {} characters; at most {}", what, x.chars().count(), max)); } if !chars_ok(x) { return Err(format!("{}: `{}` holds a character the QR bill's set does not have", what, x)); } Ok(())}/// Seven lines of an address: S, the name, street, number, postcode, town and country.fn address(a: Option<&Value>, what: &str, required: bool) -> Result<Vec<String>, String> { let Some(a) = a.filter(|a| a.is_object()) else { if required { return Err(format!("{}: an address is required", what)); } return Ok(vec![String::new(); 7]); }; let (name, street, number, post, town, country) = (text(a, "name"), text(a, "street"), text(a, "number"), text(a, "postcode"), text(a, "town"), text(a, "country").to_uppercase()); if name.is_empty() || post.is_empty() || town.is_empty() { return Err(format!("{}: name, postcode and town are required", what)); } if country.len() != 2 || !country.bytes().all(|b| b.is_ascii_uppercase()) { return Err(format!("{}: country `{}` should be two letters", what, country)); } checked(&format!("{} name", what), &name, 70)?; checked(&format!("{} street", what), &street, 70)?; checked(&format!("{} number", what), &number, 16)?; checked(&format!("{} postcode", what), &post, 16)?; checked(&format!("{} town", what), &town, 35)?; Ok(vec!["S".into(), name, street, number, post, town, country])}fn amount(v: Option<&Value>) -> Result<String, String> { let a = match v { None | Some(Value::Null) => return Ok(String::new()), Some(Value::String(s)) if s.trim().is_empty() => return Ok(String::new()), Some(Value::String(s)) => s.trim().to_string(), Some(Value::Number(n)) => n.to_string(), Some(o) => return Err(format!("amount {} is neither text nor a number", o)), }; let (w, f) = a.split_once('.').unwrap_or((&a, "")); if w.is_empty() || !w.bytes().all(|b| b.is_ascii_digit()) || !f.bytes().all(|b| b.is_ascii_digit()) || f.len() > 2 { return Err(format!("amount `{}` should have at most two decimals", a)); } if w.trim_start_matches('0').len() > 9 { return Err(format!("amount {} is over 999999999.99", a)); } let cents: u64 = format!("{}{:0<2}", w, f).parse().map_err(|_| format!("amount `{}` is not a number", a))?; if cents == 0 { return Err("the amount must be at least 0.01 (a bill for nothing leaves it empty)".into()); } Ok(format!("{}.{:02}", cents / 100, cents % 100))}fn check_reference(iban: &str, kind: &str, reference: &str) -> Result<(), String> { let qr = swiss_qr_is_qr_iban(iban); match kind { "QRR" => { if !qr { return Err(format!("reference type QRR needs a QR-IBAN, and {} is an ordinary IBAN", iban)); } match swiss_qr_qrr_problem(reference) { Some(p) => Err(p), None => Ok(()) } } "SCOR" | "NON" if qr => Err(format!("{} is a QR-IBAN, which takes only a QRR reference", iban)), "SCOR" => match swiss_qr_scor_problem(reference) { Some(p) => Err(p), None => Ok(()) }, "NON" => if reference.is_empty() { Ok(()) } else { Err("reference type NON carries no reference".into()) }, o => Err(format!("reference type `{}` should be QRR, SCOR or NON", o)), }}/// The payload of a bill: `{ account, creditor: { name, street, number, postcode, town, country }, amount, currency, debtor, reference, message, billing_info, alternatives }`. The type of reference follows from it: 27 digits is QRR, an RF one SCOR, none NON. Fields are in the guidelines' order, separated by CRLF, with 7 empty lines for the reserved ultimate creditor.pub fn swiss_qr_build(bill: serde_json::Value) -> Result<String, String> { let iban = compact(&text(&bill, "account")); if let Some(p) = iban_problem(&iban) { return Err(p); } let creditor = address(bill.get("creditor"), "creditor", true)?; let currency = { let c = text(&bill, "currency").to_uppercase(); if c.is_empty() { "CHF".to_string() } else { c } }; if currency != "CHF" && currency != "EUR" { return Err(format!("currency `{}` should be CHF or EUR", currency)); } let amt = amount(bill.get("amount"))?; let debtor = address(bill.get("debtor"), "debtor", false)?; let reference = compact(&text(&bill, "reference")); let kind = if reference.is_empty() { "NON" } else if reference.starts_with("RF") { "SCOR" } else { "QRR" }; check_reference(&iban, kind, &reference)?; let (message, billing) = (text(&bill, "message"), text(&bill, "billing_info")); if message.chars().count() + billing.chars().count() > 140 { return Err("message and billing_info together take at most 140 characters".into()); } checked("message", &message, 140)?; checked("billing_info", &billing, 140)?; let alternatives: Vec<String> = bill.get("alternatives").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); if alternatives.len() > 2 { return Err("at most two alternative procedures".into()); } for a in &alternatives { checked("alternative", a, 100)?; } let mut lines: Vec<String> = vec!["SPC".into(), "0200".into(), "1".into(), iban]; lines.extend(creditor); lines.extend(vec![String::new(); 7]); lines.push(amt); lines.push(currency); lines.extend(debtor); lines.push(kind.into()); lines.push(reference); lines.push(message); lines.push("EPD".into()); if !billing.is_empty() || !alternatives.is_empty() { lines.push(billing); } lines.extend(alternatives); let out = lines.join("\r\n"); if out.chars().count() > MAX_CHARS { return Err(format!("the payload is {} characters; at most {}", out.chars().count(), MAX_CHARS)); } Ok(out)}fn read_address(l: &[&str]) -> Result<Value, String> { if l.iter().all(|x| x.is_empty()) { return Ok(Value::Null); } match l[0] { "S" => Ok(json!({ "kind": "S", "name": l[1], "street": l[2], "number": l[3], "postcode": l[4], "town": l[5], "country": l[6] })), "K" => Ok(json!({ "kind": "K", "name": l[1], "line1": l[2], "line2": l[3], "country": l[6] })), o => Err(format!("address type `{}` should be S or K", o)), }}/// Reads a payload back into `{ version, account, qr_iban, creditor, amount, currency, debtor, reference_type, reference, message, billing_info, alternatives }`, with the same rules. A combined address (type K), which older bills have, is read too. Errors name the field.pub fn swiss_qr_parse(payload: &str) -> Result<Value, String> { let lines: Vec<&str> = payload.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect(); if lines.first().copied() != Some("SPC") { return Err("a Swiss QR payload starts with the line SPC".into()); } if lines.len() < 31 { return Err(format!("{} lines; a payload has at least 31, and a field may be empty but never left out", lines.len())); } if lines.len() > 34 { return Err(format!("{} lines; at most 34", lines.len())); } if !lines[1].starts_with("02") { return Err(format!("version `{}`; this reads 02xx", lines[1])); } if lines[2] != "1" { return Err(format!("coding `{}` should be 1 (UTF-8)", lines[2])); } let iban = lines[3]; if let Some(p) = iban_problem(iban) { return Err(format!("account: {}", p)); } let creditor = read_address(&lines[4..11]).map_err(|e| format!("creditor: {}", e))?; if creditor.is_null() { return Err("creditor: an address is required".into()); } if lines[11..18].iter().any(|l| !l.is_empty()) { return Err("the ultimate creditor is reserved: those seven lines must be empty".into()); } let amt = if lines[18].is_empty() { String::new() } else { amount(Some(&json!(lines[18])))? }; let currency = lines[19]; if currency != "CHF" && currency != "EUR" { return Err(format!("currency `{}` should be CHF or EUR", currency)); } let debtor = read_address(&lines[20..27]).map_err(|e| format!("debtor: {}", e))?; let (kind, reference) = (lines[27], lines[28]); check_reference(iban, kind, reference)?; if lines[30] != "EPD" { return Err(format!("the trailer is `{}`, and should be EPD", lines[30])); } Ok(json!({ "version": lines[1], "account": iban, "qr_iban": swiss_qr_is_qr_iban(iban), "creditor": creditor, "amount": if amt.is_empty() { Value::Null } else { json!(amt) }, "currency": currency, "debtor": debtor, "reference_type": kind, "reference": reference, "message": lines[29], "billing_info": lines.get(31).copied().unwrap_or(""), "alternatives": lines.get(32..).unwrap_or(&[]), }))}/// An SVG of the code, 46 mm wide inside a 5 mm quiet zone, error level M, with the 7 mm Swiss cross in its middle. Refuses a payload that needs more than QR version 25, as the guidelines do.pub fn swiss_qr_svg(payload: &str) -> Result<String, String> { let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M).map_err(|e| e.to_string())?; let v = match code.version() { qrcode::Version::Normal(n) => n, qrcode::Version::Micro(n) => n }; if v > 25 { return Err(format!("the code needs QR version {}; the guidelines stop at 25", v)); } let w = code.width(); let m = 46.0 / w as f64; let mut path = String::new(); let cells = code.to_colors(); for y in 0..w { let mut x = 0; while x < w { if cells[y * w + x] == Color::Dark { let start = x; while x < w && cells[y * w + x] == Color::Dark { x += 1; } path.push_str(&format!("M{:.3} {:.3}h{:.3}v{:.3}h-{:.3}z", 5.0 + start as f64 * m, 5.0 + y as f64 * m, (x - start) as f64 * m, m, (x - start) as f64 * m)); } else { x += 1; } } } // The cross: a 7 mm white square, a 6 mm black one, and the white cross of the flag's proportions. let (c, arm, bar) = (28.0, 3.75, 1.125); Ok(format!( concat!( "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"56mm\" height=\"56mm\" viewBox=\"0 0 56 56\">", "<rect width=\"56\" height=\"56\" fill=\"#fff\"/><path fill=\"#000\" d=\"{}\"/>", "<g id=\"swiss-cross\"><rect x=\"24.5\" y=\"24.5\" width=\"7\" height=\"7\" fill=\"#fff\"/><rect x=\"25\" y=\"25\" width=\"6\" height=\"6\" fill=\"#000\"/>", "<rect x=\"{:.3}\" y=\"{:.4}\" width=\"{}\" height=\"{}\" fill=\"#fff\"/><rect x=\"{:.4}\" y=\"{:.3}\" width=\"{}\" height=\"{}\" fill=\"#fff\"/></g></svg>" ), path, c - arm / 2.0, c - bar / 2.0, arm, bar, c - bar / 2.0, c - arm / 2.0, bar, arm ))}Related
epc_qr.qwer · 1.0.0SEPA credit-transfer QR codes (EPC069-12, GiroCode): the payload built and read line by line, with SEPA IBANs, BICs, amounts, RF references and the 331-byte limit checked, and the code drawn as an SVG at error level M.4 views · 7 downloadsBAI2 bank statements bai2.qwer · 1.0.0BAI2 bank statements read into groups, accounts, balances and transactions with their type codes, funds availability and continuation records, every control total and record count checked and a problem named by its line.4 views · 7 downloadsEMV QR codes emv_qr.qwer · 1.0.0EMVCo merchant-presented QR codes: TLV read and written with nested templates and named fields, the CRC checked, and codes made for Thailand's PromptPay, Singapore's PayNow, Malaysia's DuitNow and any other scheme's merchant template.6 views · 10 downloadsNACHA ACH files nacha.qwer · 1.0.0US ACH files: fixed-width batches built with entry hash, control totals, trace numbers and padding to a block of ten, and files read back with routing check digits and totals checked, each problem naming its line, returns and notifications of change included.5 views · 6 downloadsPix pix.qwer · 1.0.0Brazil's Pix: static BR Codes (copia e cola) built and read, EMV fields and CRC16-CCITT checked, each kind of key told apart with CPF and CNPJ check digits, and an SVG of the code.1 view · 7 downloadsQuicken QIF qif.qwer · 1.0.0Quicken QIF files read and written: bank, cash, card and investment accounts, AutoSwitch account lists, categories, classes, securities and memorized items. Split transactions checked to the cent, transfers and classes taken out of the category, dates in Quicken's forms such as 1/ 2'26, day or month first told from the file, and amounts with thousands separators of either kind.2 views · 7 downloadsThread 0 posts
On Swiss QR bills: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.