Payments

Pix

Brazil'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.

Version 1.0.0 · 6 functions · 2 crates

#brazil#qr#payments#emv

Functions

What use pix.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use pix_crc, pix_key_type from pix (Quillo 0.6).

pix_crc(text: str) → str

The CRC16-CCITT (polynomial 1021, start FFFF) of a text, as four upper-case hex digits, as tag 63 holds it.

pix_key_type(key: str) → stror an error

The type of a Pix key: "cpf", "cnpj", "phone", "email" or "random"; an error says why it is none of them. Formatting in a CPF or CNPJ is forgiven by pix_key_clean, not here: a key in a code is written bare.

pix_key_clean(key: str) → stror an error

A key as it goes in a code: a CPF or CNPJ without its dots and dashes, a phone with +55 added when it has 10 digits or its area code in brackets (11 bare digits are a CPF), an e-mail and a random key in lower case.

pix_build(payment: any) → stror an error

The text of a static BR Code from { key, name, city, amount, description, txid, postcode }. key, name (cut to 25 letters) and city (to 15) are needed; accents are taken off. amount is reais with up to two decimals, or left out for the payer to type. txid is up to 25 letters and digits, *** when there is none.

pix_parse(code: str) → anyor an error

Reads a BR Code into { key, key_type, url, name, city, amount, cents, description, txid, postcode, currency, country, static, crc }. A wrong CRC, a missing field or a merchant field of no Pix is an error. A dynamic code has a url to fetch instead of a key.

pix_svg(code: str, size: int) → stror an error

An SVG of the code. size is the smallest width in pixels.

Get it

$ quillo install pix               # pix.qwer into this folder$ quillo install pix --example     # and its example, pix.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/pix.qwer$ curl -O https://quillolang.org/plugins/files/pix.qwe

Then, in your program, all of it or only what you call:

main.qwe
use pix.qweruse pix_crc, pix_key_type from pix      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }qrcode = { version = "0.14", default-features = false, features = ["svg"] }

Example

pix.qwe
// A shop's Pix checkout: a static code ("copia e cola") for an order, read back as a banking app// reads it, each kind of key told apart, and what a changed code or a wrong key gives.////   quillo run pix.qwe      then open http://localhost:8080/code, /code.svg, /read, /manual, /keys and /refuseduse "pix.qwer"let ORDER = { key: "ada@example.com.br", name: "Padaria São João", city: "São Paulo", amount: "42.5", description: "Pedido 1001", txid: "PEDIDO1001", postcode: "01310-100" }route GET /code = respond(200, pix_build(ORDER)?, { "content-type": "text/plain" })route GET /code.svg = respond(200, pix_svg(pix_build(ORDER)?, 300)?, { "content-type": "image/svg+xml" })route GET /read = pix_parse(pix_build(ORDER)?)?// The example of the Banco Central's BR Code manual, a random key and no amount.route GET /manual = pix_parse("00020126580014br.gov.bcb.pix0136123e4567-e12b-12d1-a456-4266554400005204000053039865802BR5913Fulano de Tal6008BRASILIA62070503***63041D3D")?route GET /keys = {    cpf: pix_key_type("11144477735")?,    cnpj: pix_key_type("11222333000181")?,    phone: pix_key_type("+5511987654321")?,    email: pix_key_type("ada@example.com.br")?,    random: pix_key_type("123e4567-e12b-12d1-a456-426655440000")?,    cleaned: [pix_key_clean("111.444.777-35")?, pix_key_clean("11.222.333/0001-81")?, pix_key_clean("(11) 98765-4321")?],}route GET /refused {    let good = pix_build(ORDER)?    {        changed: pix_parse(replace(good, "42.50", "12.50")).error,        bad_cpf: pix_key_type("11144477736").error,        all_same: pix_key_type("11111111111").error,        bad_cnpj: pix_key_type("11222333000182").error,        phone: pix_key_type("+15551234567").error,        decimals: pix_build(merge(ORDER, { amount: "1.999" })).error,        txid: pix_build(merge(ORDER, { txid: "not allowed!" })).error,        no_city: pix_build(merge(ORDER, { city: "" })).error,    }}
$ quillo run pix.qwe$ curl 'localhost:8080/code'$ curl 'localhost:8080/code.svg'$ curl 'localhost:8080/read'$ curl 'localhost:8080/manual'$ curl 'localhost:8080/keys'$ curl 'localhost:8080/refused'

Source

pix.qwer
//! Brazil's Pix: static BR Codes (copia e cola) built and read, EMV fields and CRC16-CCITT checked, keys told apart (CPF, CNPJ, phone, e-mail, random), and an SVG of the code.//! 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, Value};const GUI: &str = "br.gov.bcb.pix";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 CRC16-CCITT (polynomial 1021, start FFFF) of a text, as four upper-case hex digits, as tag 63 holds it.pub fn pix_crc(text: &str) -> String {    format!("{:04X}", crc16(text.as_bytes()))}fn cpf_ok(d: &[u32]) -> bool {    if d.len() != 11 || d.iter().all(|x| *x == d[0]) {        return false;    }    let digit = |n: usize| { let s: u32 = (0..n).map(|i| d[i] * (n as u32 + 1 - i as u32)).sum(); let r = s * 10 % 11; if r == 10 { 0 } else { r } };    digit(9) == d[9] && digit(10) == d[10]}fn cnpj_ok(c: &[u32]) -> bool {    if c.len() != 14 || c.iter().all(|x| *x == c[0]) {        return false;    }    let digit = |n: usize| {        let w: &[u32] = if n == 12 { &[5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] } else { &[6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] };        let s: u32 = (0..n).map(|i| c[i] * w[i]).sum();        let r = s % 11;        if r < 2 { 0 } else { 11 - r }    };    digit(12) == c[12] && digit(13) == c[13]}/// The type of a Pix key: "cpf", "cnpj", "phone", "email" or "random"; an error says why it is none of them. Formatting in a CPF or CNPJ is forgiven by `pix_key_clean`, not here: a key in a code is written bare.pub fn pix_key_type(key: &str) -> Result<String, String> {    let k = key.trim();    if k.is_empty() {        return Err("the Pix key is empty".into());    }    let digits: Vec<u32> = k.chars().filter_map(|c| c.to_digit(10)).collect();    if k.starts_with('+') {        let body = &k[1..];        if !body.starts_with("55") || !body.bytes().all(|b| b.is_ascii_digit()) || !(12..=13).contains(&body.len()) {            return Err(format!("`{}` is not a Brazilian phone key: +55, the area code and the number", k));        }        return Ok("phone".into());    }    if k.contains('@') {        let (user, host) = k.rsplit_once('@').unwrap();        if user.is_empty() || !host.contains('.') || k.len() > 77 || k.contains(char::is_whitespace) || host.starts_with('.') || host.ends_with('.') {            return Err(format!("`{}` is not an e-mail key", k));        }        return Ok("email".into());    }    let b = k.as_bytes();    if k.len() == 36 && [8, 13, 18, 23].iter().all(|i| b[*i] == b'-') && k.bytes().enumerate().all(|(i, c)| [8, 13, 18, 23].contains(&i) || c.is_ascii_hexdigit()) {        return Ok("random".into());    }    if k.bytes().all(|c| c.is_ascii_digit()) {        return match k.len() {            11 if cpf_ok(&digits) => Ok("cpf".into()),            11 => Err(format!("`{}` is not a valid CPF: its check digits are wrong", k)),            14 if cnpj_ok(&digits) => Ok("cnpj".into()),            14 => Err(format!("`{}` is not a valid CNPJ: its check digits are wrong", k)),            n => Err(format!("`{}` has {} digits: a CPF has 11, a CNPJ 14, and a phone starts with +55", k, n)),        };    }    Err(format!("`{}` is no kind of Pix key", k))}/// A key as it goes in a code: a CPF or CNPJ without its dots and dashes, a phone with +55 added when it has 10 digits or its area code in brackets (11 bare digits are a CPF), an e-mail and a random key in lower case.pub fn pix_key_clean(key: &str) -> Result<String, String> {    let k = key.trim();    let out = if k.contains('@') || (k.len() == 36 && k.matches('-').count() == 4) {        k.to_lowercase()    } else {        let plus = k.starts_with('+');        let d: String = k.chars().filter(char::is_ascii_digit).collect();        if k.chars().any(|c| c.is_alphabetic()) {            k.to_string()        } else if plus {            format!("+{}", d)        } else if k.contains('(') || (d.len() == 10) {            format!("+55{}", d)        } else {            d        }    };    pix_key_type(&out)?;    Ok(out)}fn tlv(tag: &str, value: &str) -> Result<String, String> {    let n = value.chars().count();    if n > 99 {        return Err(format!("field {} is {} characters; at most 99", tag, n));    }    Ok(format!("{}{:02}{}", tag, n, value))}/// Takes the accents off Portuguese letters: names and cities in a code are plain ASCII.fn plain(x: &str) -> String {    x.chars().filter_map(|c| {        let r = match c {            'á' | 'à' | 'â' | 'ã' | 'ä' => 'a', 'Á' | 'À' | 'Â' | 'Ã' | 'Ä' => 'A',            'é' | 'ê' | 'è' | 'ë' => 'e', 'É' | 'Ê' | 'È' | 'Ë' => 'E',            'í' | 'î' | 'ì' | 'ï' => 'i', 'Í' | 'Î' | 'Ì' | 'Ï' => 'I',            'ó' | 'ô' | 'õ' | 'ò' | 'ö' => 'o', 'Ó' | 'Ô' | 'Õ' | 'Ò' | 'Ö' => 'O',            'ú' | 'û' | 'ù' | 'ü' => 'u', 'Ú' | 'Û' | 'Ù' | 'Ü' => 'U',            'ç' => 'c', 'Ç' => 'C', 'ñ' => 'n', 'Ñ' => 'N',            o if (' '..='~').contains(&o) => o,            _ => return None,        };        Some(r)    }).collect()}fn s(v: &Value, k: &str) -> String {    match v.get(k) {        Some(Value::String(x)) => x.trim().to_string(),        Some(Value::Number(n)) => n.to_string(),        _ => String::new(),    }}/// The text of a static BR Code from `{ key, name, city, amount, description, txid, postcode }`. `key`, `name` (cut to 25 letters) and `city` (to 15) are needed; accents are taken off. `amount` is reais with up to two decimals, or left out for the payer to type. `txid` is up to 25 letters and digits, `***` when there is none.pub fn pix_build(payment: serde_json::Value) -> Result<String, String> {    let key = pix_key_clean(&s(&payment, "key"))?;    let (name, city) = (plain(&s(&payment, "name")), plain(&s(&payment, "city")));    if name.is_empty() {        return Err("the receiver's name is required".into());    }    if city.is_empty() {        return Err("the receiver's city is required".into());    }    let mut account = tlv("00", GUI)? + &tlv("01", &key)?;    let description = plain(&s(&payment, "description"));    if !description.is_empty() {        account += &tlv("02", &description)?;    }    if account.len() > 99 {        return Err(format!("the key and description together take {} of the account field's 99 characters; shorten the description", account.len()));    }    let mut out = tlv("00", "01")? + &tlv("26", &account)? + &tlv("52", "0000")? + &tlv("53", "986")?;    let amount = s(&payment, "amount");    if !amount.is_empty() {        let (w, f) = amount.split_once('.').unwrap_or((&amount, ""));        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 be reais with at most two decimals, with a point", amount));        }        if w.trim_start_matches('0').len() + 3 > 13 {            return Err(format!("amount {} is too long", amount));        }        let cents: u64 = format!("{}{:0<2}", w, f).parse().map_err(|_| "amount too large")?;        if cents == 0 {            return Err("an amount of zero: leave it out for the payer to type one".into());        }        out += &tlv("54", &format!("{}.{:02}", cents / 100, cents % 100))?;    }    out += &tlv("58", "BR")?;    out += &tlv("59", &name.chars().take(25).collect::<String>())?;    out += &tlv("60", &city.chars().take(15).collect::<String>())?;    let post: String = s(&payment, "postcode").chars().filter(char::is_ascii_digit).collect();    if !post.is_empty() {        if post.len() != 8 {            return Err(format!("postcode `{}` should be the 8 digits of a CEP", post));        }        out += &tlv("61", &post)?;    }    let txid = { let t = s(&payment, "txid"); if t.is_empty() { "***".to_string() } else { t } };    if txid != "***" && (txid.len() > 25 || !txid.bytes().all(|b| b.is_ascii_alphanumeric())) {        return Err(format!("txid `{}` should be up to 25 letters and digits", txid));    }    out += &tlv("62", &tlv("05", &txid)?)?;    out += "6304";    let crc = pix_crc(&out);    Ok(out + &crc)}fn fields(text: &str, at: &str) -> Result<Vec<(String, String)>, String> {    let c: Vec<char> = text.chars().collect();    let mut i = 0;    let mut out = 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: String = c[i..i + 2].iter().collect();        let len: String = 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 field's tag and length", at, tag, len, i));        }        let n: usize = len.parse().unwrap();        if i + 4 + n > c.len() {            return Err(format!("{}: field {} says {} characters, and fewer are left", at, tag, n));        }        out.push((tag, c[i + 4..i + 4 + n].iter().collect()));        i += 4 + n;    }    Ok(out)}/// Reads a BR Code into `{ key, key_type, url, name, city, amount, cents, description, txid, postcode, currency, country, static, crc }`. A wrong CRC, a missing field or a merchant field of no Pix is an error. A dynamic code has a `url` to fetch instead of a key.pub fn pix_parse(code: &str) -> Result<Value, String> {    let code = code.trim();    if code.len() < 8 {        return Err("too short to be a BR Code".into());    }    let (body, have) = code.split_at(code.len() - 4);    if !body.ends_with("6304") {        return Err("a BR Code ends with its CRC, field 63, and this does not".into());    }    let want = pix_crc(body);    if !have.eq_ignore_ascii_case(&want) {        return Err(format!("the CRC is {} and should be {}", have, want));    }    let f = fields(code, "BR Code")?;    let get = |t: &str| f.iter().find(|x| x.0 == t).map(|x| x.1.as_str());    if f.first().map(|x| x.0.as_str()) != Some("00") || get("00") != Some("01") {        return Err("field 00 should come first and be 01".into());    }    let account = f.iter().filter(|x| ("26".."52").contains(&x.0.as_str())).find_map(|x| {        let sub = fields(&x.1, &format!("field {}", x.0)).ok()?;        sub.iter().find(|y| y.0 == "00" && y.1.eq_ignore_ascii_case(GUI)).map(|_| sub.clone())    }).ok_or("no Pix merchant field: nothing with br.gov.bcb.pix")?;    let sub = |t: &str| account.iter().find(|x| x.0 == t).map(|x| x.1.clone());    let (key, url) = (sub("01"), sub("25"));    if key.is_none() && url.is_none() {        return Err("the Pix field has neither a key (01) nor a URL (25)".into());    }    let key_type = match &key {        Some(k) => Some(pix_key_type(k).map_err(|e| format!("field 26: {}", e))?),        None => None,    };    for (tag, name) in [("52", "the merchant category"), ("53", "the currency"), ("58", "the country"), ("59", "the name"), ("60", "the city")] {        if get(tag).is_none() {            return Err(format!("field {}, {}, is missing", tag, name));        }    }    if get("53") != Some("986") {        return Err(format!("currency `{}` should be 986, the real", get("53").unwrap_or("")));    }    if get("58") != Some("BR") {        return Err(format!("country `{}` should be BR", get("58").unwrap_or("")));    }    let extra = match get("62") {        Some(x) => fields(x, "field 62")?,        None => vec![],    };    let cents = get("54").and_then(|a| {        let (w, f) = a.split_once('.').unwrap_or((a, ""));        Some(w.parse::<i64>().ok()? * 100 + format!("{:0<2}", f).parse::<i64>().ok()?)    });    Ok(json!({        "key": key, "key_type": key_type, "url": url, "name": get("59"), "city": get("60"), "amount": get("54"), "cents": cents,        "description": sub("02"), "txid": extra.iter().find(|x| x.0 == "05").map(|x| x.1.clone()), "postcode": get("61"),        "currency": "BRL", "country": "BR", "static": get("01") != Some("12"), "crc": have.to_uppercase(),    }))}/// An SVG of the code. `size` is the smallest width in pixels.pub fn pix_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())}
EMV 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.qrpaymentsemvasia6 views · 10 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.indiaqrpaymentsupi4 views · 8 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.paymentscheckoutwebhookshmac2 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.paymentsmandateswebhooksrecurring4 views · 8 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.bankingachpaymentsfixed-width5 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.paymentssubscriptionsmerchant-of-recordwebhooks5 views · 7 downloads
Thread
17Useful? Like it, or add to its thread below.

Thread 0 posts

On Pix: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.