EMV QR codes
EMVCo 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.
Version 1.0.0 · 8 functions · 2 crates
Functions
What use emv_qr.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use emv_qr_crc, emv_qr_tlv from emv_qr (Quillo 0.6).
emv_qr_crc(text: str) → strThe CRC of a payload as four upper-case hex digits (CRC16-CCITT-FALSE), taken over everything up to and including 6304.
emv_qr_tlv(text: str) → listor an errorAny EMV QR text as a tree without judging it: [{ tag, name, value, fields }], fields for templates (26 to 51, 62, 64, 65 to 99).
emv_qr_parse(text: str) → anyor an errorReads a merchant-presented code: { valid_crc, missing, format, static, category, currency, amount, country, merchant_name, merchant_city, postal_code, schemes, additional, language, fields }. schemes lists the merchant accounts, each with its gui and, where known, scheme. missing lists mandatory fields left out, which is common: PromptPay codes have no name or category. A bad CRC, a length that runs off the end, a first field not 00 or no merchant account at all is an error.
emv_qr_build(fields: any) → stror an errorA payload from a map of tags: { "01": "11", "29": { "00": "A000000677010111", "01": "0066812345678" }, "52": "0000", ... }, nested maps being templates. Fields go in tag order, 00 first (written as 01 unless given) and the CRC added last. A field over 99 characters is refused.
emv_qr_promptpay(payment: any) → stror an errorA Thai PromptPay code from { mobile }, { national_id } (13 digits) or { ewallet } (15), with amount if fixed. A mobile like 0812345678 becomes 0066812345678. With an amount the code is single-use (12), as the banks' apps treat it, unless static is given.
emv_qr_paynow(payment: any) → stror an errorA Singapore PayNow code from { mobile } (+65 and 8 digits) or { uen }, with name, amount, reference, editable (may the payer change the amount, default no without an amount given, yes with none) and expiry (YYYYMMDD).
emv_qr_duitnow(payment: any) → stror an errorA Malaysian DuitNow code, as PayNet's QR specification lays it out, from { acquirer_id, qr_id, name, city, category, amount, postcode, descriptor, mobile, dynamic }: the acquirer's id and the QR id come from your acquirer. Payload format 02, currency 458, country MY; static (11) unless dynamic is true, an amount or not.
emv_qr_svg(code: str, size: int) → stror an errorAn SVG of a code. size is the smallest width in pixels.
Get it
$ quillo install emv_qr # emv_qr.qwer into this folder$ quillo install emv_qr --example # and its example, emv_qr.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/emv_qr.qwer$ curl -O https://quillolang.org/plugins/files/emv_qr.qweThen, in your program, all of it or only what you call:
use emv_qr.qweruse emv_qr_crc, emv_qr_tlv from emv_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, features = ["svg"] }Example
// EMV merchant-presented QR codes: a Thai PromptPay code, a Singapore PayNow code and a Malaysian// DuitNow code, a code of your own scheme, and any code read back, a broken one refused.//// quillo run emv_qr.qwe then open http://localhost:8080/promptpay, /paynow, /duitnow, /custom, /read, /code.svg and /refuseduse "emv_qr.qwer"route GET /promptpay = { mobile: emv_qr_promptpay({ mobile: "081-234-5678" })?, amount: emv_qr_promptpay({ national_id: "1234567890123", amount: "420" })?, read: emv_qr_parse(emv_qr_promptpay({ mobile: "0812345678" })?)?.schemes,}route GET /paynow { let code = emv_qr_paynow({ uen: "201403121W", name: "Kopi Corner", amount: "12.5", reference: "INV-77", editable: false, expiry: "20261231" })? let r = emv_qr_parse(code)? { code: code, scheme: r.schemes[0].scheme, proxy: r.schemes[0].fields, amount: r.amount, reference: r.additional.bill_number }}route GET /duitnow = { // PayNet's first example: the code it prints, made here. made: emv_qr_duitnow({ acquirer_id: "501664", qr_id: "123456789", name: "QRCSDNBHD", city: "BANGI", category: "9999", amount: "10" })?, read: emv_qr_parse("00020201021126410014A000000615000101065016640209123456789520499995303458540510.005802MY5909QRCSDNBHD6005BANGI6304343F")?.schemes,}// Any other scheme: the tags given directly, a template as a nested map.route GET /custom { let code = emv_qr_build({ "01": "11", "27": { "00": "com.example.pay", "01": "MERCHANT42" }, "52": "5812", "53": "840", "58": "US", "59": "Blue Bottle", "60": "Oakland", "62": { "03": "STORE7", "07": "TILL2" }, "64": { "00": "ZH", "01": "蓝瓶" }, })? let r = emv_qr_parse(code)? { code: code, additional: r.additional, language: r.language, tree: emv_qr_tlv(code)?[2] }}route GET /code.svg = respond(200, emv_qr_svg(emv_qr_promptpay({ mobile: "0812345678" })?, 300)?, { "content-type": "image/svg+xml" })route GET /refused { let good = emv_qr_promptpay({ mobile: "0812345678", amount: "10" })? { changed: emv_qr_parse(replace(good, "10.00", "90.00")).error, short: emv_qr_parse("000201010211").error, runs_off: emv_qr_tlv("00020129990016A0000006").error, no_id: emv_qr_promptpay({}).error, long: emv_qr_build({ "59": repeat("x", 120) }).error, }}$ quillo run emv_qr.qwe$ curl 'localhost:8080/promptpay'$ curl 'localhost:8080/paynow'$ curl 'localhost:8080/duitnow'$ curl 'localhost:8080/custom'$ curl 'localhost:8080/refused'Source
//! EMVCo merchant-presented QR codes in general: TLV read and written with nested templates and named fields, the CRC checked, and codes made for PromptPay, PayNow and DuitNow, or any scheme's own merchant template.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }//! qrcode = { version = "0.14", default-features = false, features = ["svg"] }use qrcode::render::svg;use qrcode::{EcLevel, QrCode};use serde_json::{json, Map, Value};fn crc16(data: &[u8]) -> u16 { let mut c: u16 = 0xFFFF; for b in data { c ^= (*b as u16) << 8; for _ in 0..8 { c = if c & 0x8000 != 0 { (c << 1) ^ 0x1021 } else { c << 1 }; } } c}/// The CRC of a payload as four upper-case hex digits (CRC16-CCITT-FALSE), taken over everything up to and including `6304`.pub fn emv_qr_crc(text: &str) -> String { format!("{:04X}", crc16(text.as_bytes()))}fn tag_name(tag: &str) -> &'static str { let n: u32 = tag.parse().unwrap_or(100); match n { 26..=51 => return "merchant_account", 65..=79 => return "emvco_template", 80..=99 => return "unreserved_template", _ => {} } match tag { "00" => "payload_format", "01" => "initiation_method", "02" | "03" => "visa", "04" | "05" => "mastercard", "06" | "07" | "08" => "emvco", "09" | "10" => "discover", "11" | "12" => "amex", "13" | "14" => "jcb", "15" | "16" => "unionpay", "17" | "18" | "19" | "20" | "21" | "22" | "23" | "24" | "25" => "emvco", "52" => "merchant_category", "53" => "currency", "54" => "amount", "55" => "tip_indicator", "56" => "convenience_fee_fixed", "57" => "convenience_fee_percent", "58" => "country", "59" => "merchant_name", "60" => "merchant_city", "61" => "postal_code", "62" => "additional_data", "63" => "crc", "64" => "language_template", _ => "", }}fn additional_name(tag: &str) -> &'static str { match tag { "01" => "bill_number", "02" => "mobile_number", "03" => "store_label", "04" => "loyalty_number", "05" => "reference_label", "06" => "customer_label", "07" => "terminal_label", "08" => "purpose", "09" => "consumer_data_request", "10" => "merchant_tax_id", "11" => "merchant_channel", _ => "", }}fn language_name(tag: &str) -> &'static str { match tag { "00" => "language", "01" => "merchant_name", "02" => "merchant_city", _ => "", }}fn nested(tag: &str) -> bool { matches!(tag.parse::<u32>(), Ok(26..=51 | 62 | 64 | 65..=99))}fn split(text: &str, at: &str) -> Result<Vec<(String, String)>, String> { let c: Vec<char> = text.chars().collect(); let (mut i, mut out) = (0, vec![]); while i < c.len() { if i + 4 > c.len() { return Err(format!("{}: {} characters left over at position {}", at, c.len() - i, i)); } let (tag, len): (String, String) = (c[i..i + 2].iter().collect(), c[i + 2..i + 4].iter().collect()); if !tag.chars().all(|x| x.is_ascii_digit()) || !len.chars().all(|x| x.is_ascii_digit()) { return Err(format!("{}: `{}{}` at position {} is not a tag and a length", at, tag, len, i)); } let n: usize = len.parse().unwrap(); if n == 0 { return Err(format!("{}: field {} is empty, which the format does not allow", at, tag)); } if i + 4 + n > c.len() { return Err(format!("{}: field {} says {} characters, and {} are left", at, tag, n, c.len() - i - 4)); } out.push((tag, c[i + 4..i + 4 + n].iter().collect())); i += 4 + n; } Ok(out)}fn node(tag: &str, value: &str, name: &str, depth: usize) -> Value { let mut m = Map::new(); m.insert("tag".into(), json!(tag)); if !name.is_empty() { m.insert("name".into(), json!(name)); } m.insert("value".into(), json!(value)); if depth == 0 && nested(tag) { if let Ok(sub) = split(value, tag) { let kids: Vec<Value> = sub.iter().map(|(t, v)| { let n = match tag { "62" => additional_name(t), "64" => language_name(t), _ if t == "00" => "globally_unique_id", _ => "" }; node(t, v, n, 1) }).collect(); m.insert("fields".into(), Value::Array(kids)); } } Value::Object(m)}/// Any EMV QR text as a tree without judging it: `[{ tag, name, value, fields }]`, `fields` for templates (26 to 51, 62, 64, 65 to 99).pub fn emv_qr_tlv(text: &str) -> Result<Vec<Value>, String> { let f = split(text.trim(), "payload")?; Ok(f.iter().map(|(t, v)| node(t, v, tag_name(t), 0)).collect())}fn scheme_of(gui: &str) -> &'static str { match gui.to_uppercase().as_str() { "A000000677010111" | "A000000677010112" | "A000000677010113" => "promptpay", "SG.PAYNOW" => "paynow", "A0000006150001" => "duitnow", "BR.GOV.BCB.PIX" => "pix", "A000000727" => "vietqr", "ID.CO.QRIS.WWW" => "qris", "SG.COM.NETS" => "nets", "COM.P2PQRPAY" => "qrph", _ => "", }}/// Reads a merchant-presented code: `{ valid_crc, missing, format, static, category, currency, amount, country, merchant_name, merchant_city, postal_code, schemes, additional, language, fields }`. `schemes` lists the merchant accounts, each with its `gui` and, where known, `scheme`. `missing` lists mandatory fields left out, which is common: PromptPay codes have no name or category. A bad CRC, a length that runs off the end, a first field not 00 or no merchant account at all is an error.pub fn emv_qr_parse(text: &str) -> Result<Value, String> { let code = text.trim(); let f = split(code, "payload")?; if f.first().map(|x| x.0.as_str()) != Some("00") { return Err("the first field must be the payload format, 00".into()); } let Some(last) = f.last().filter(|x| x.0 == "63") else { return Err("the last field must be the CRC, 63".into()); }; if last.1.chars().count() != 4 { return Err(format!("the CRC `{}` should be four characters", last.1)); } let body = &code[..code.len() - 4]; let want = emv_qr_crc(body); if !last.1.eq_ignore_ascii_case(&want) { return Err(format!("the CRC is {} and should be {}", last.1, want)); } let get = |t: &str| f.iter().find(|x| x.0 == t).map(|x| x.1.clone()); for (i, x) in f.iter().enumerate() { if f[..i].iter().any(|y| y.0 == x.0) { return Err(format!("field {} appears twice", x.0)); } } let missing: Vec<&str> = [("52", "merchant_category"), ("53", "currency"), ("58", "country"), ("59", "merchant_name"), ("60", "merchant_city")].iter().filter(|x| get(x.0).is_none()).map(|x| x.1).collect(); let mut schemes = vec![]; for (t, v) in &f { if ("02".."52").contains(&t.as_str()) { if ("26".."52").contains(&t.as_str()) { let sub = split(v, t)?; let gui = sub.iter().find(|x| x.0 == "00").map(|x| x.1.clone()).ok_or(format!("field {}: a merchant template needs a unique id, sub-field 00", t))?; let fields: Map<String, Value> = sub.iter().filter(|x| x.0 != "00").map(|(a, b)| (a.clone(), json!(b))).collect(); schemes.push(json!({ "tag": t, "gui": gui, "scheme": scheme_of(&gui), "fields": fields })); } else { schemes.push(json!({ "tag": t, "scheme": tag_name(t), "account": v })); } } } if schemes.is_empty() { return Err("no merchant account: nothing between fields 02 and 51".into()); } let sub_map = |tag: &str, name: fn(&str) -> &'static str| -> Result<Value, String> { let Some(v) = get(tag) else { return Ok(Value::Null) }; let mut m = Map::new(); for (t, x) in split(&v, tag)? { let n = name(&t); m.insert(if n.is_empty() { t } else { n.to_string() }, json!(x)); } Ok(Value::Object(m)) }; Ok(json!({ "valid_crc": true, "missing": missing, "format": get("00"), "static": get("01").as_deref() != Some("12"), "category": get("52"), "currency": get("53"), "amount": get("54"), "country": get("58"), "merchant_name": get("59"), "merchant_city": get("60"), "postal_code": get("61"), "schemes": schemes, "additional": sub_map("62", additional_name)?, "language": sub_map("64", language_name)?, "fields": emv_qr_tlv(code)?, }))}fn tlv(tag: &str, value: &str) -> Result<String, String> { let n = value.chars().count(); if tag.len() != 2 || !tag.bytes().all(|b| b.is_ascii_digit()) { return Err(format!("tag `{}` should be two digits", tag)); } if n == 0 || n > 99 { return Err(format!("field {} is {} characters; it takes 1 to 99", tag, n)); } Ok(format!("{}{:02}{}", tag, n, value))}fn scalar(v: &Value) -> Option<String> { match v { Value::String(s) if !s.is_empty() => Some(s.clone()), Value::Number(n) => Some(n.to_string()), _ => None, }}fn encode(fields: &Value) -> Result<Vec<(String, String)>, String> { let mut out = vec![]; let list: Vec<(String, Value)> = match fields { Value::Object(m) => m.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), Value::Array(a) => a.iter().map(|x| (x["tag"].as_str().unwrap_or("").to_string(), if x.get("fields").is_some() { x["fields"].clone() } else { x["value"].clone() })).collect(), _ => return Err("fields should be a map of tags, or a list of `{ tag, value }`".into()), }; for (tag, v) in list { match v { Value::Null => {} Value::Object(_) | Value::Array(_) => { let inner: String = encode(&v)?.iter().map(|(t, x)| tlv(t, x)).collect::<Result<_, _>>()?; out.push((tag, inner)); } o => { let x = scalar(&o).ok_or(format!("field {} is empty: leave it out", tag))?; out.push((tag, x)); } } } Ok(out)}/// A payload from a map of tags: `{ "01": "11", "29": { "00": "A000000677010111", "01": "0066812345678" }, "52": "0000", ... }`, nested maps being templates. Fields go in tag order, 00 first (written as 01 unless given) and the CRC added last. A field over 99 characters is refused.pub fn emv_qr_build(fields: serde_json::Value) -> Result<String, String> { let mut list = encode(&fields)?; if list.iter().any(|x| x.0 == "63") { return Err("field 63 is the CRC, which this adds".into()); } if !list.iter().any(|x| x.0 == "00") { list.push(("00".into(), "01".into())); } list.sort_by(|a, b| a.0.cmp(&b.0)); let mut out = String::new(); for (t, v) in &list { out += &tlv(t, v)?; } out += "6304"; let crc = emv_qr_crc(&out); Ok(out + &crc)}fn amount_text(v: Option<&Value>, places: usize) -> Result<Option<String>, String> { let Some(a) = v.and_then(scalar) else { return Ok(None) }; 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()) { return Err(format!("amount `{}` is not a number", a)); } if f.len() > places { return Err(format!("amount {} has more than {} decimals", a, places)); } if w.bytes().all(|b| b == b'0') && f.bytes().all(|b| b == b'0') { return Err("an amount of zero: leave it out".into()); } let text = format!("{}.{:0<w$}", w, f, w = places); if text.len() > 13 { return Err(format!("amount {} is over 13 characters", a)); } Ok(Some(text))}fn only_digits(x: &str) -> String { x.chars().filter(char::is_ascii_digit).collect()}/// A Thai PromptPay code from `{ mobile }`, `{ national_id }` (13 digits) or `{ ewallet }` (15), with `amount` if fixed. A mobile like 0812345678 becomes 0066812345678. With an amount the code is single-use (12), as the banks' apps treat it, unless `static` is given.pub fn emv_qr_promptpay(payment: serde_json::Value) -> Result<String, String> { let g = |k: &str| payment.get(k).and_then(scalar).map(|x| only_digits(&x)).filter(|x| !x.is_empty()); let (tag, id) = if let Some(m) = g("mobile") { let m = m.strip_prefix("66").map(str::to_string).unwrap_or_else(|| m.strip_prefix('0').unwrap_or(&m).to_string()); if m.len() != 9 { return Err("a Thai mobile number has 9 digits after the 0".into()); } ("01", format!("0066{}", m)) } else if let Some(n) = g("national_id") { if n.len() != 13 { return Err(format!("a national or tax id has 13 digits, this has {}", n.len())); } ("02", n) } else if let Some(n) = g("ewallet") { if n.len() != 15 { return Err(format!("an e-wallet id has 15 digits, this has {}", n.len())); } ("03", n) } else { return Err("give mobile, national_id or ewallet".into()); }; let amount = amount_text(payment.get("amount"), 2)?; let fixed = amount.is_some() && !payment.get("static").and_then(Value::as_bool).unwrap_or(false); let mut m = Map::new(); m.insert("00".into(), json!("01")); m.insert("01".into(), json!(if fixed { "12" } else { "11" })); let mut account = Map::new(); account.insert("00".into(), json!("A000000677010111")); account.insert(tag.into(), json!(id)); m.insert("29".into(), Value::Object(account)); m.insert("53".into(), json!("764")); if let Some(a) = amount { m.insert("54".into(), json!(a)); } m.insert("58".into(), json!("TH")); let text: String = encode(&Value::Object(m))?.iter().map(|(t, v)| tlv(t, v)).collect::<Result<_, _>>()?; let out = text + "6304"; let crc = emv_qr_crc(&out); Ok(out + &crc)}/// A Singapore PayNow code from `{ mobile }` (+65 and 8 digits) or `{ uen }`, with `name`, `amount`, `reference`, `editable` (may the payer change the amount, default no without an amount given, yes with none) and `expiry` (YYYYMMDD).pub fn emv_qr_paynow(payment: serde_json::Value) -> Result<String, String> { let g = |k: &str| payment.get(k).and_then(scalar).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()); let (kind, proxy) = if let Some(m) = g("mobile") { let d = only_digits(&m); let d = if d.len() == 8 { format!("65{}", d) } else { d }; if d.len() != 10 || !d.starts_with("65") { return Err(format!("`{}` is not a Singapore mobile number", m)); } ("0", format!("+{}", d)) } else if let Some(u) = g("uen") { let u = u.to_uppercase(); if !(9..=10).contains(&u.len()) || !u.bytes().all(|b| b.is_ascii_alphanumeric()) { return Err(format!("`{}` is not a UEN: 9 or 10 letters and digits", u)); } ("2", u) } else { return Err("give mobile or uen".into()); }; let name = g("name").ok_or("the merchant's name is required")?; let amount = amount_text(payment.get("amount"), 2)?; let editable = payment.get("editable").and_then(Value::as_bool).unwrap_or(amount.is_none()); let expiry = g("expiry"); if let Some(e) = &expiry { if e.len() != 8 || !e.bytes().all(|b| b.is_ascii_digit()) { return Err(format!("expiry `{}` should be YYYYMMDD", e)); } } let mut account = Map::new(); account.insert("00".into(), json!("SG.PAYNOW")); account.insert("01".into(), json!(kind)); account.insert("02".into(), json!(proxy)); account.insert("03".into(), json!(if editable { "1" } else { "0" })); if let Some(e) = expiry { account.insert("04".into(), json!(e)); } let mut m = Map::new(); m.insert("01".into(), json!(if amount.is_some() { "12" } else { "11" })); m.insert("26".into(), Value::Object(account)); m.insert("52".into(), json!("0000")); m.insert("53".into(), json!("702")); if let Some(a) = amount { m.insert("54".into(), json!(a)); } m.insert("58".into(), json!("SG")); m.insert("59".into(), json!(name.chars().take(25).collect::<String>())); m.insert("60".into(), json!("Singapore")); if let Some(r) = g("reference") { let mut extra = Map::new(); extra.insert("01".into(), json!(r)); m.insert("62".into(), Value::Object(extra)); } emv_qr_build(Value::Object(m))}/// A Malaysian DuitNow code, as PayNet's QR specification lays it out, from `{ acquirer_id, qr_id, name, city, category, amount, postcode, descriptor, mobile, dynamic }`: the acquirer's id and the QR id come from your acquirer. Payload format 02, currency 458, country MY; static (11) unless `dynamic` is true, an amount or not.pub fn emv_qr_duitnow(payment: serde_json::Value) -> Result<String, String> { let g = |k: &str| payment.get(k).and_then(scalar).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()); let acquirer = g("acquirer_id").ok_or("acquirer_id is required: your acquirer gives it")?; let id = g("qr_id").ok_or("qr_id is required: your acquirer gives it")?; if acquirer.len() > 6 { return Err(format!("acquirer_id `{}` is at most 6 characters", acquirer)); } if id.len() > 28 || !id.bytes().all(|b| b.is_ascii_alphanumeric()) { return Err(format!("qr_id `{}` is at most 28 letters and digits", id)); } let name = g("name").ok_or("the merchant's name is required")?; let city = g("city").ok_or("the merchant's city is required")?; let amount = amount_text(payment.get("amount"), 2)?; let mut account = Map::new(); account.insert("00".into(), json!("A0000006150001")); account.insert("01".into(), json!(acquirer)); account.insert("02".into(), json!(id)); if let Some(d) = g("descriptor") { account.insert("03".into(), json!(d)); } if let Some(d) = g("mobile") { account.insert("04".into(), json!(d)); } let mut m = Map::new(); m.insert("00".into(), json!("02")); m.insert("01".into(), json!(if payment.get("dynamic").and_then(Value::as_bool).unwrap_or(false) { "12" } else { "11" })); m.insert("26".into(), Value::Object(account)); m.insert("52".into(), json!(g("category").unwrap_or_else(|| "0000".into()))); m.insert("53".into(), json!("458")); if let Some(a) = amount { m.insert("54".into(), json!(a)); } m.insert("58".into(), json!("MY")); m.insert("59".into(), json!(name.chars().take(25).collect::<String>())); m.insert("60".into(), json!(city.chars().take(15).collect::<String>())); if let Some(p) = g("postcode") { m.insert("61".into(), json!(p)); } emv_qr_build(Value::Object(m))}/// An SVG of a code. `size` is the smallest width in pixels.pub fn emv_qr_svg(code: &str, size: i64) -> Result<String, String> { let c = QrCode::with_error_correction_level(code.as_bytes(), EcLevel::M).map_err(|e| e.to_string())?; let px = size.clamp(64, 4096) as u32; Ok(c.render::<svg::Color>().min_dimensions(px, px).build())}Related
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 · 8 downloadsUPI payment links upi_qr.qwer · 1.0.0India's UPI deep links and QR payloads: upi://pay links built and read, VPAs checked, amounts held in paise so no float rounds them, the transaction limit applied, and an SVG of the code.4 views · 9 downloadsAdyen adyen.qwer · 1.0.0Adyen's Checkout API without an SDK: sessions for Drop-in, payments with their result codes turned into paid, failed, pending or waiting on the shopper, 3-D Secure details sent on, captures, refunds, cancels and reversals, amounts by Adyen's own table of minor units, and each webhook notification's HMAC checked.2 views · 7 downloadsMollie mollie.qwer · 1.0.0Mollie's Payments API without an SDK: payments and refunds with amounts in the exact decimals Mollie insists on, customers, mandates and recurring charges, lists followed through their next links, and webhooks taken as Mollie means them: only an id comes in, and the payment is fetched back to learn what happened.4 views · 9 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 downloadsPaddle Billing paddle.qwer · 1.0.0Paddle Billing's API without an SDK: prices, transactions and subscriptions, lists followed through their next links, subscription changes with the proration Paddle insists on, pauses, resumes and cancels, money as Paddle's strings of the lowest denomination, and the Paddle-Signature header checked with its timestamp and rotating keys.5 views · 8 downloadsThread 0 posts
On EMV QR codes: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.