NACHA ACH files
US 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.
Version 1.0.0 · 10 functions · 1 crate
Functions
What use nacha.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use nacha_check_digit, nacha_routing_ok from nacha (Quillo 0.6).
nacha_check_digit(first8: str) → intor an errorThe check digit that ends a routing number, from its first eight digits (weights 3, 7, 1): nacha_check_digit("09100001") is 9.
nacha_routing_ok(routing: str) → boolWhether nine digits are a routing number with the right check digit.
nacha_entry_hash(routings: list) → intor an errorThe entry hash of a list of routing numbers: the sum of each one's first eight digits, kept to its last ten digits.
nacha_trace(odfi: str, sequence: int) → stror an errorA trace number: the originating bank's eight digits and a sequence number of seven.
nacha_build(file: any) → stror an errorA whole ACH file from a map: immediate_destination and immediate_origin (9 or 10 characters), destination_name, origin_name, file_date (YYMMDD), file_time (HHMM), file_id_modifier, reference, and batches, each with company_name, company_id, sec_code, description, odfi, effective_date and entries, each of those with kind (credit or debit), account_type, routing, account, amount (text) or amount_cents, id, name, addenda. Lines end with a newline; the file is padded with nines to a multiple of ten.
nacha_reason(code: str) → strWhat a return reason (R01...) or a notification of change (C01...) means.
nacha_parse(text: str) → anyor an errorReads an ACH file into its header, batches and entries, addenda, returns (type 99) and changes (type 98) among them. Any problem is an error naming its line; nacha_check lists them all.
nacha_check(text: str) → anyEvery problem in an ACH file: { ok, problems, batch_count, entry_count, entry_hash, total_debit_cents, total_credit_cents }, each problem starting line N:.
nacha_read_file(path: str) → anyor an errorReads an ACH file from disk as nacha_parse does.
nacha_entries(text: str) → listor an errorA flat list of a file's entries, each with its batch's company, SEC code and effective date: { company_name, sec_code, effective_date, kind, name, amount_cents, routing, account, trace, line, addenda }.
Get it
$ quillo install nacha # nacha.qwer into this folder$ quillo install nacha --example # and its example, nacha.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/nacha.qwer$ curl -O https://quillolang.org/plugins/files/nacha.qwe$ curl -O https://quillolang.org/plugins/files/nacha_returns.achThen, in your program, all of it or only what you call:
use nacha.qweruse nacha_check_digit, nacha_routing_ok from nacha // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }Example
// A payroll run as an ACH file for the bank, read back and checked before upload; then what a// changed amount, a wrong routing digit and a bank's return look like.//// quillo run nacha.qwe then open http://localhost:8080/file, /read, /tampered, /refused and /return// nacha_returns.ach is a small synthetic return file, from no bankuse "nacha.qwer"let PAYROLL = { immediate_destination: "091000019", immediate_origin: "1234567890", destination_name: "Wells Fargo", origin_name: "Quillo Tools Inc", file_date: "261101", file_time: "0930", reference: "PAY1101", batches: [ { company_name: "Quillo Tools", company_id: "1234567890", sec_code: "PPD", description: "PAYROLL", descriptive_date: "NOV 01", effective_date: "261103", odfi: "09100001", entries: [ { kind: "credit", routing: "021000021", account: "1234567", amount: "2500.00", id: "EMP001", name: "Ada Lovelace", addenda: "NOV PAY PERIOD 1" }, { kind: "credit", account_type: "savings", routing: "011401533", account: "98-7654", amount_cents: 187550, id: "EMP002", name: "Grace Hopper" }, ], }, { company_name: "Quillo Tools", company_id: "1234567890", sec_code: "CCD", description: "INVOICES", effective_date: "261103", odfi: "09100001", entries: [ { kind: "debit", routing: "091000019", account: "555000", amount: "410.25", id: "INV-77", name: "Bakery Supply LLC" }, ], }, ],}let FILE = nacha_build(PAYROLL)route GET /file = respond(200, FILE?, { "content-type": "text/plain" })route GET /read { let f = nacha_parse(FILE?)? { batches: f.batch_count, entries: f.entry_count, hash: f.entry_hash, debit: f.total_debit_cents, credit: f.total_credit_cents, blocks: f.block_count, padding: f.padding_lines, first: f.batches[0].entries[0], rows: nacha_entries(FILE?)? | map((e) => `${e.trace} ${e.kind} ${e.amount_cents} ${e.name}`), }}// An amount changed by hand: 2500.00 becomes 2600.00 and the batch control no longer agrees.route GET /tampered { let bad = replace(FILE?, "0000250000", "0000260000") { first: nacha_parse(bad).error, all: nacha_check(bad).problems }}route GET /refused = { routing: nacha_build(merge(PAYROLL, { batches: [merge(PAYROLL.batches[0], { entries: [{ kind: "credit", routing: "021000022", account: "1", amount: "1.00", name: "X" }] })] })).error, check_digit: nacha_check_digit("09100001")?, hash: nacha_entry_hash(["021000021", "011401533", "091000019"])?, trace: nacha_trace("09100001", 42)?, padded: nacha_parse(nacha_build(merge(PAYROLL, { batches: [PAYROLL.batches[0]] }))?)?.padding_lines,}// A file of returns from the bank: entry code 26, an addenda of type 99 with the reason, and a// notification of change (code 21, type 98) with the corrected account.route GET /return { let f = nacha_parse(read_file("nacha_returns.ach"))? let entries = f.batches[0].entries { returned: entries[0].addenda[0], change: entries[1].addenda[0], what: entries[0].what, means: nacha_reason("R03"), }}$ quillo run nacha.qwe$ curl 'localhost:8080/file'$ curl 'localhost:8080/read'$ curl 'localhost:8080/tampered'$ curl 'localhost:8080/refused'$ curl 'localhost:8080/return'Source
//! US ACH files in the NACHA format: fixed-width batches built with their entry hash, control totals, trace numbers and block padding, and files read back and checked, each problem naming its line.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};const LINE: usize = 94;const MAX_AMOUNT: i64 = 9_999_999_999;fn s(v: &Value, k: &str) -> String { match v.get(k) { Some(Value::String(x)) => x.clone(), Some(Value::Number(n)) => n.to_string(), _ => String::new(), }}fn digits(x: &str) -> bool { !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit())}/// Text field: left-justified, space-filled, cut at the width. Only printable ASCII fits a fixed-width line.fn alpha(x: &str, width: usize, what: &str) -> Result<String, String> { if let Some(c) = x.chars().find(|c| !(' '..='~').contains(c)) { return Err(format!("{}: `{}` holds {:?}; a NACHA file is printable ASCII", what, x, c)); } let up = x.to_uppercase(); let cut: String = up.chars().take(width).collect(); Ok(format!("{:<w$}", cut, w = width))}fn num(x: i64, width: usize) -> String { format!("{:0>w$}", x, w = width)}fn digit_field(x: &str, width: usize, what: &str) -> Result<String, String> { if !digits(x) || x.len() != width { return Err(format!("{}: `{}` should be {} digits", what, x, width)); } Ok(x.to_string())}fn yymmdd(x: &str, what: &str) -> Result<String, String> { let ok = x.len() == 6 && digits(x) && matches!(x[2..4].parse::<u32>(), Ok(1..=12)) && matches!(x[4..6].parse::<u32>(), Ok(1..=31)); if ok { Ok(x.to_string()) } else { Err(format!("{}: `{}` is not a date as YYMMDD", what, x)) }}fn check_digit(first8: &str) -> u32 { let w = [3, 7, 1, 3, 7, 1, 3, 7]; let sum: u32 = first8.bytes().zip(w).map(|(b, w)| (b - b'0') as u32 * w).sum(); (10 - sum % 10) % 10}/// The check digit that ends a routing number, from its first eight digits (weights 3, 7, 1): `nacha_check_digit("09100001")` is 9.pub fn nacha_check_digit(first8: &str) -> Result<i64, String> { if first8.len() != 8 || !digits(first8) { return Err(format!("`{}` should be the first 8 digits of a routing number", first8)); } Ok(check_digit(first8) as i64)}/// Whether nine digits are a routing number with the right check digit.pub fn nacha_routing_ok(routing: &str) -> bool { routing.len() == 9 && digits(routing) && check_digit(&routing[..8]) == (routing.as_bytes()[8] - b'0') as u32}/// The entry hash of a list of routing numbers: the sum of each one's first eight digits, kept to its last ten digits.pub fn nacha_entry_hash(routings: Vec<String>) -> Result<i64, String> { let mut sum = 0i64; for r in &routings { if r.len() < 8 || !digits(r) { return Err(format!("`{}` is not a routing number", r)); } sum += r[..8].parse::<i64>().unwrap(); } Ok(sum % 10_000_000_000)}/// A trace number: the originating bank's eight digits and a sequence number of seven.pub fn nacha_trace(odfi: &str, sequence: i64) -> Result<String, String> { if odfi.len() != 8 || !digits(odfi) { return Err(format!("`{}` should be the 8 digits of the originating bank", odfi)); } if !(0..=9_999_999).contains(&sequence) { return Err(format!("sequence {} does not fit seven digits", sequence)); } Ok(format!("{}{:07}", odfi, sequence))}fn cents(e: &Value) -> Result<i64, String> { if let Some(n) = e.get("amount_cents").and_then(Value::as_i64) { return Ok(n); } let a = s(e, "amount"); let (w, f) = a.split_once('.').unwrap_or((&a, "")); if !digits(w) || f.len() > 2 || !f.bytes().all(|b| b.is_ascii_digit()) { return Err(format!("amount `{}` should be dollars with at most 2 decimals, or give amount_cents", a)); } let c = w.parse::<i64>().map_err(|_| format!("amount `{}` is too large", a))?; Ok(c * 100 + format!("{:0<2}", f).parse::<i64>().unwrap())}fn tran_code(e: &Value) -> Result<i64, String> { if let Some(c) = e.get("transaction_code").and_then(Value::as_i64) { return if tran_kind(c).is_some() { Ok(c) } else { Err(format!("transaction code {} is not one of 22 to 39", c)) }; } let kind = s(e, "kind"); let base = match s(e, "account_type").as_str() { "" | "checking" => 20, "savings" => 30, "gl" => 40, "loan" => 50, o => return Err(format!("account_type `{}` should be checking, savings, gl or loan", o)), }; let prenote = e.get("prenote").and_then(Value::as_bool).unwrap_or(false); match kind.as_str() { "credit" => Ok(base + if prenote { 3 } else { 2 }), "debit" if base != 50 => Ok(base + if prenote { 8 } else { 7 }), "debit" => Err("a loan account takes credits only".into()), o => Err(format!("kind `{}` should be credit or debit", o)), }}/// (account, credit or debit, what it is) of a transaction code.fn tran_kind(code: i64) -> Option<(&'static str, &'static str, &'static str)> { let account = match code / 10 { 2 => "checking", 3 => "savings", 4 => "gl", 5 => "loan", _ => return None, }; Some(match code % 10 { 1 => (account, "credit", "return"), 2 => (account, "credit", "live"), 3 => (account, "credit", "prenote"), 4 => (account, "credit", "zero dollar"), 6 => (account, "debit", "return"), 7 => (account, "debit", "live"), 8 => (account, "debit", "prenote"), 9 => (account, "debit", "zero dollar"), _ => return None, })}fn is_credit(code: i64) -> bool { matches!(code % 10, 1..=4)}fn pad(mut line: String) -> String { while line.len() < LINE { line.push(' '); } line}struct Totals { count: i64, hash: i64, debit: i64, credit: i64,}fn build_batch(b: &Value, number: i64, out: &mut Vec<String>, sequence: &mut i64) -> Result<Totals, String> { let at = format!("batch {}", number); let company = s(b, "company_name"); if company.is_empty() { return Err(format!("{}: no company_name", at)); } let company_id = s(b, "company_id"); if company_id.is_empty() || company_id.len() > 10 { return Err(format!("{}: company_id `{}` should be up to 10 characters", at, company_id)); } let sec = { let c = s(b, "sec_code"); if c.is_empty() { "PPD".to_string() } else { c.to_uppercase() } }; if !["PPD", "CCD", "CTX", "WEB", "TEL", "ARC", "BOC", "POP", "RCK"].contains(&sec.as_str()) { return Err(format!("{}: SEC code `{}` is not one this builds (IAT has a layout of its own)", at, sec)); } let description = s(b, "description"); if description.is_empty() { return Err(format!("{}: no description (it shows on the receiver's statement)", at)); } let odfi = digit_field(&s(b, "odfi"), 8, &format!("{}: odfi", at))?; let effective = yymmdd(&s(b, "effective_date"), &format!("{}: effective_date", at))?; let entries = b.get("entries").and_then(Value::as_array).filter(|e| !e.is_empty()).ok_or(format!("{}: no entries", at))?; let mut lines = vec![]; let mut t = Totals { count: 0, hash: 0, debit: 0, credit: 0 }; let (mut credits, mut debits) = (0, 0); for (i, e) in entries.iter().enumerate() { let at = format!("batch {} entry {}", number, i + 1); let code = tran_code(e).map_err(|m| format!("{}: {}", at, m))?; let amount = cents(e).map_err(|m| format!("{}: {}", at, m))?; if !(0..=MAX_AMOUNT).contains(&amount) { return Err(format!("{}: {} cents does not fit the ten-digit amount field", at, amount)); } let prenote = matches!(code % 10, 3 | 8); if prenote && amount != 0 { return Err(format!("{}: a prenote carries no amount", at)); } let routing = s(e, "routing"); if !nacha_routing_ok(&routing) { let good = if routing.len() == 9 && digits(&routing) { format!("; its check digit should be {}", check_digit(&routing[..8])) } else { String::new() }; return Err(format!("{}: routing number `{}` is not valid{}", at, routing, good)); } let account = s(e, "account"); if account.is_empty() || account.len() > 17 { return Err(format!("{}: account `{}` should be 1 to 17 characters", at, account)); } let name = s(e, "name"); if name.is_empty() { return Err(format!("{}: no name", at)); } if sec == "TEL" && is_credit(code) { return Err(format!("{}: a TEL batch holds debits only", at)); } let addenda: Vec<String> = match e.get("addenda") { Some(Value::String(x)) if !x.is_empty() => vec![x.clone()], Some(Value::Array(a)) => a.iter().map(|x| x.as_str().unwrap_or("").to_string()).collect(), _ => vec![], }; let most = if sec == "CTX" { 9999 } else if matches!(sec.as_str(), "PPD" | "CCD" | "WEB") { 1 } else { 0 }; if addenda.len() > most { return Err(format!("{}: {} addenda, and a {} entry takes at most {}", at, addenda.len(), sec, most)); } *sequence += 1; let trace = match e.get("trace").and_then(Value::as_str) { Some(x) => digit_field(x, 15, &format!("{}: trace", at))?, None => nacha_trace(&odfi, *sequence)?, }; let id = alpha(&s(e, "id"), 15, &format!("{}: id", at))?; let discretionary = alpha(&s(e, "discretionary"), 2, &format!("{}: discretionary", at))?; lines.push(format!( "6{:02}{}{:<17}{:010}{}{}{}{}{}", code, routing, alpha(&account, 17, &at)?.trim_end(), amount, id, alpha(&name, 22, &format!("{}: name", at))?, discretionary, if addenda.is_empty() { 0 } else { 1 }, trace )); for (n, a) in addenda.iter().enumerate() { lines.push(format!("705{}{:04}{}", alpha(a, 80, &format!("{}: addenda", at))?, n + 1, &trace[8..])); } t.count += 1 + addenda.len() as i64; t.hash += routing[..8].parse::<i64>().unwrap(); if is_credit(code) { credits += 1; t.credit += amount } else { debits += 1; t.debit += amount } } t.hash %= 10_000_000_000; if t.debit >= 1_000_000_000_000 || t.credit >= 1_000_000_000_000 { return Err(format!("{}: the total does not fit twelve digits", at)); } let class = match (credits > 0, debits > 0) { (true, true) => 200, (true, false) => 220, _ => 225 }; let class = b.get("service_class").and_then(Value::as_i64).unwrap_or(class); if ![200, 220, 225].contains(&class) || (class == 220 && debits > 0) || (class == 225 && credits > 0) { return Err(format!("{}: service class {} does not fit {} credits and {} debits", at, class, credits, debits)); } let settle = " "; out.push(format!( "5{}{}{}{:>10}{}{}{}{}{}1{}{:07}", class, alpha(&company, 16, &at)?, alpha(&s(b, "discretionary"), 20, &at)?, company_id, sec, alpha(&description, 10, &at)?, alpha(&s(b, "descriptive_date"), 6, &at)?, effective, settle, odfi, number )); out.append(&mut lines); out.push(pad(format!( "8{}{:06}{:010}{:012}{:012}{:>10}{:<19}{:<6}{}{:07}", class, t.count, t.hash, t.debit, t.credit, company_id, "", "", odfi, number ))); Ok(t)}/// A whole ACH file from a map: `immediate_destination` and `immediate_origin` (9 or 10 characters), `destination_name`,/// `origin_name`, `file_date` (YYMMDD), `file_time` (HHMM), `file_id_modifier`, `reference`, and `batches`, each with/// `company_name`, `company_id`, `sec_code`, `description`, `odfi`, `effective_date` and `entries`, each of those with `kind` (credit or/// debit), `account_type`, `routing`, `account`, `amount` (text) or `amount_cents`, `id`, `name`, `addenda`. Lines end with a newline; the file is padded with nines to a multiple of ten.pub fn nacha_build(file: serde_json::Value) -> Result<String, String> { let batches = file.get("batches").and_then(Value::as_array).filter(|b| !b.is_empty()).ok_or("no batches")?; let routed = |k: &str| -> Result<String, String> { let x = s(&file, k); if x.len() == 9 && digits(&x) { if !nacha_routing_ok(&x) { return Err(format!("{}: `{}` has a wrong check digit", k, x)); } return Ok(format!(" {}", x)); } if x.len() == 10 { Ok(x) } else { Err(format!("{}: `{}` should be 9 digits, or 10 characters", k, x)) } }; let (dest, orig) = (routed("immediate_destination")?, { let x = s(&file, "immediate_origin"); if x.len() == 9 || x.len() == 10 { format!("{:>10}", x) } else { return Err(format!("immediate_origin: `{}` should be 9 or 10 characters", x)); } }); let date = yymmdd(&s(&file, "file_date"), "file_date")?; let time = { let t = s(&file, "file_time"); if t.is_empty() { "0000".to_string() } else { digit_field(&t, 4, "file_time")? } }; let modifier = { let m = s(&file, "file_id_modifier"); if m.is_empty() { "A".to_string() } else { m.to_uppercase() } }; if modifier.len() != 1 || !modifier.as_bytes()[0].is_ascii_alphanumeric() { return Err(format!("file_id_modifier: `{}` should be one letter or digit", modifier)); } let mut lines = vec![format!( "101{}{}{}{}{}094101{}{}{}", dest, orig, date, time, modifier, alpha(&s(&file, "destination_name"), 23, "destination_name")?, alpha(&s(&file, "origin_name"), 23, "origin_name")?, alpha(&s(&file, "reference"), 8, "reference")? )]; let mut total = Totals { count: 0, hash: 0, debit: 0, credit: 0 }; let mut sequence = 0; for (i, b) in batches.iter().enumerate() { let t = build_batch(b, i as i64 + 1, &mut lines, &mut sequence)?; total.count += t.count; total.hash += t.hash; total.debit += t.debit; total.credit += t.credit; } let blocks = (lines.len() as i64 + 10) / 10; lines.push(pad(format!("9{:06}{:06}{:08}{:010}{:012}{:012}", batches.len(), blocks, total.count, total.hash % 10_000_000_000, total.debit, total.credit))); while lines.len() % 10 != 0 { lines.push("9".repeat(LINE)); } for l in &lines { debug_assert_eq!(l.len(), LINE); } Ok(lines.iter().map(|l| format!("{}\n", l)).collect())}fn field(l: &str, from: usize, to: usize) -> &str { &l[from - 1..to]}fn int(x: &str) -> Option<i64> { let t = x.trim(); if digits(t) { t.parse().ok() } else { None }}fn return_reason(code: &str) -> &'static str { match code { "R01" => "insufficient funds", "R02" => "account closed", "R03" => "no account, or unable to locate", "R04" => "invalid account number", "R05" => "unauthorized debit to a consumer account, corporate SEC code", "R06" => "returned at the originating bank's request", "R07" => "authorization revoked by the customer", "R08" => "payment stopped", "R09" => "uncollected funds", "R10" => "customer says the debit was not authorized", "R11" => "customer says the entry does not follow the authorization", "R12" => "account sold to another bank", "R13" => "invalid routing number", "R14" => "payee or account holder has died", "R15" => "beneficiary has died", "R16" => "account frozen", "R17" => "file edit failed, or suspicious entry", "R20" => "account cannot take this entry", "R23" => "credit refused by the receiver", "R24" => "duplicate entry", "R29" => "corporate customer says the debit was not authorized", "R31" => "permissible return, agreed by the originating bank", "R33" => "return of an XCK entry", "C01" => "incorrect account number", "C02" => "incorrect routing number", "C03" => "incorrect routing and account numbers", "C05" => "incorrect transaction code", "C06" => "incorrect account number and transaction code", "C07" => "incorrect routing, account and transaction code", "C09" => "incorrect individual id", _ => "unknown code", }}/// What a return reason (R01...) or a notification of change (C01...) means.pub fn nacha_reason(code: &str) -> String { return_reason(&code.to_uppercase()).to_string()}fn read(text: &str) -> (Value, Vec<String>) { let mut p: Vec<String> = vec![]; let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect(); let lines: Vec<&str> = if lines.last() == Some(&"") { lines[..lines.len() - 1].to_vec() } else { lines }; let mut out = Map::new(); let mut batches: Vec<Value> = vec![]; let mut cur: Option<Map<String, Value>> = None; // the open batch let mut entries: Vec<Value> = vec![]; let (mut count, mut hash, mut debit, mut credit) = (0i64, 0i64, 0i64, 0i64); let (mut f_count, mut f_hash, mut f_debit, mut f_credit) = (0i64, 0i64, 0i64, 0i64); let mut last_trace = 0i64; let mut pending_addenda = 0; // entries whose indicator says an addenda should follow let mut seen_header = false; let mut control: Option<(usize, String)> = None; let mut padding = 0; if lines.is_empty() { p.push("line 1: the file is empty".into()); } if lines.len() % 10 != 0 { p.push(format!("line {}: {} lines is not a multiple of 10; the file should be padded with lines of nines", lines.len(), lines.len())); } for (i, l) in lines.iter().enumerate() { let n = i + 1; if !l.is_ascii() { p.push(format!("line {}: not ASCII", n)); continue; } if l.len() != LINE { p.push(format!("line {}: {} characters; a line has {}", n, l.len(), LINE)); continue; } if control.is_some() || *l == "9".repeat(LINE) { if *l == "9".repeat(LINE) { padding += 1; continue; } if control.is_some() { p.push(format!("line {}: after the file control only lines of nines may follow", n)); continue; } } if pending_addenda > 0 && !l.starts_with('7') { p.push(format!("line {}: the entry above says it has an addenda, and record type {} follows", n, &l[..1])); pending_addenda = 0; } match &l[..1] { "1" => { if i != 0 { p.push(format!("line {}: a file header belongs on the first line", n)); } seen_header = true; if field(l, 2, 3) != "01" { p.push(format!("line {}: priority code `{}` should be 01", n, field(l, 2, 3))); } if field(l, 35, 37) != "094" { p.push(format!("line {}: record size `{}` should be 094", n, field(l, 35, 37))); } if field(l, 38, 39) != "10" { p.push(format!("line {}: blocking factor `{}` should be 10", n, field(l, 38, 39))); } if field(l, 40, 40) != "1" { p.push(format!("line {}: format code `{}` should be 1", n, field(l, 40, 40))); } let dest = field(l, 4, 13).trim(); if dest.len() == 9 && !nacha_routing_ok(dest) { p.push(format!("line {}: immediate destination `{}` has a wrong check digit", n, dest)); } out.insert("immediate_destination".into(), json!(dest)); out.insert("immediate_origin".into(), json!(field(l, 14, 23).trim())); out.insert("file_date".into(), json!(field(l, 24, 29))); out.insert("file_time".into(), json!(field(l, 30, 33))); out.insert("file_id_modifier".into(), json!(field(l, 34, 34))); out.insert("destination_name".into(), json!(field(l, 41, 63).trim())); out.insert("origin_name".into(), json!(field(l, 64, 86).trim())); out.insert("reference".into(), json!(field(l, 87, 94).trim())); } "5" => { if !seen_header { p.push(format!("line {}: a batch before the file header", n)); } if cur.is_some() { p.push(format!("line {}: a batch header inside a batch: the one above has no control record", n)); } let mut b = Map::new(); let class = int(field(l, 2, 4)).unwrap_or(0); if ![200, 220, 225].contains(&class) { p.push(format!("line {}: service class `{}` should be 200, 220 or 225", n, field(l, 2, 4))); } b.insert("service_class".into(), json!(class)); b.insert("company_name".into(), json!(field(l, 5, 20).trim())); b.insert("discretionary".into(), json!(field(l, 21, 40).trim())); b.insert("company_id".into(), json!(field(l, 41, 50).trim())); b.insert("sec_code".into(), json!(field(l, 51, 53))); b.insert("description".into(), json!(field(l, 54, 63).trim())); b.insert("descriptive_date".into(), json!(field(l, 64, 69).trim())); b.insert("effective_date".into(), json!(field(l, 70, 75))); b.insert("odfi".into(), json!(field(l, 80, 87))); b.insert("number".into(), json!(int(field(l, 88, 94)).unwrap_or(0))); b.insert("line".into(), json!(n)); let expected = batches.len() as i64 + 1; if int(field(l, 88, 94)) != Some(expected) { p.push(format!("line {}: batch number `{}` should be {}", n, field(l, 88, 94), expected)); } cur = Some(b); entries = vec![]; count = 0; hash = 0; debit = 0; credit = 0; last_trace = 0; } "6" => { let Some(b) = cur.as_ref() else { p.push(format!("line {}: an entry outside a batch", n)); continue; }; let code = int(field(l, 2, 3)).unwrap_or(0); let routing = format!("{}{}", field(l, 4, 11), field(l, 12, 12)); if !digits(&routing) { p.push(format!("line {}: routing number `{}` is not digits", n, routing)); } else { if !nacha_routing_ok(&routing) { p.push(format!("line {}: routing number {} has check digit {}, and should have {}", n, routing, &routing[8..], check_digit(&routing[..8]))); } hash += routing[..8].parse::<i64>().unwrap_or(0); } let amount = match int(field(l, 30, 39)) { Some(a) => a, None => { p.push(format!("line {}: amount `{}` is not digits", n, field(l, 30, 39))); 0 } }; let Some((account, kind, what)) = tran_kind(code) else { p.push(format!("line {}: transaction code `{}` is not one there is", n, field(l, 2, 3))); continue; }; if kind == "credit" { credit += amount } else { debit += amount } let class = b["service_class"].as_i64().unwrap_or(0); if class == 220 && kind == "debit" { p.push(format!("line {}: a debit in a credits-only batch (service class 220)", n)); } if class == 225 && kind == "credit" { p.push(format!("line {}: a credit in a debits-only batch (service class 225)", n)); } let trace = field(l, 80, 94); let seq = int(&trace[8..]).unwrap_or(0); if field(l, 80, 87) != b["odfi"].as_str().unwrap_or("") && !matches!(code % 10, 1 | 6) { p.push(format!("line {}: trace {} does not start with the batch's bank {}", n, trace, b["odfi"].as_str().unwrap_or(""))); } if seq <= last_trace { p.push(format!("line {}: trace {} is not above the one before", n, trace)); } last_trace = seq; let indicator = field(l, 79, 79); if indicator != "0" && indicator != "1" { p.push(format!("line {}: addenda indicator `{}` should be 0 or 1", n, indicator)); } count += 1; if indicator == "1" { pending_addenda = 1; } entries.push(json!({ "line": n, "transaction_code": code, "kind": kind, "account_type": account, "what": what, "routing": routing, "account": field(l, 13, 29).trim(), "amount_cents": amount, "amount": format!("{}.{:02}", amount / 100, amount % 100), "id": field(l, 40, 54).trim(), "name": field(l, 55, 76).trim(), "discretionary": field(l, 77, 78).trim(), "trace": trace, "addenda": [], })); } "7" => { pending_addenda = 0; let Some(e) = entries.last_mut() else { p.push(format!("line {}: an addenda with no entry above it", n)); continue; }; if e["addenda"].as_array().map_or(0, |a| a.len()) == 0 && !e["trace"].is_null() && field(lines[e["line"].as_u64().unwrap_or(1) as usize - 1], 79, 79) == "0" { p.push(format!("line {}: an addenda, and the entry above says it has none", n)); } count += 1; let kind = field(l, 2, 3); let addenda = match kind { "05" => { let sequence = int(field(l, 84, 87)).unwrap_or(0); let have = e["addenda"].as_array().map_or(0, |a| a.len()) as i64; if sequence != have + 1 { p.push(format!("line {}: addenda sequence {} should be {}", n, sequence, have + 1)); } let trace = e["trace"].as_str().unwrap_or(""); if field(l, 88, 94) != &trace[8..] { p.push(format!("line {}: addenda entry number {} does not match the trace {}", n, field(l, 88, 94), trace)); } json!({ "type": "05", "text": field(l, 4, 83).trim() }) } "99" => json!({ "type": "99", "reason": field(l, 4, 6), "means": return_reason(field(l, 4, 6)), "original_trace": field(l, 7, 21), "date_of_death": field(l, 22, 27).trim(), "original_rdfi": field(l, 28, 35), "text": field(l, 36, 79).trim(), "trace": field(l, 80, 94) }), "98" => json!({ "type": "98", "reason": field(l, 4, 6), "means": return_reason(field(l, 4, 6)), "original_trace": field(l, 7, 21), "original_rdfi": field(l, 28, 35), "corrected": field(l, 36, 64).trim(), "trace": field(l, 80, 94) }), o => { p.push(format!("line {}: addenda type `{}` is not 05, 98 or 99", n, o)); Value::Null } }; if let Some(a) = e["addenda"].as_array_mut() { a.push(addenda); } } "8" => { let Some(mut b) = cur.take() else { p.push(format!("line {}: a batch control with no batch open", n)); continue; }; let hashed = hash % 10_000_000_000; let checks = [ ("entry and addenda count", field(l, 5, 10), count, 6), ("entry hash", field(l, 11, 20), hashed, 10), ("total debits", field(l, 21, 32), debit, 12), ("total credits", field(l, 33, 44), credit, 12), ]; for (name, got, want, width) in checks { if int(got) != Some(want) { p.push(format!("line {}: batch {}, {} `{}` should be {}", n, b["number"], name, got, num(want, width))); } } if field(l, 2, 4) != num(b["service_class"].as_i64().unwrap_or(0), 3) { p.push(format!("line {}: service class differs from the batch header", n)); } if field(l, 45, 54).trim() != b["company_id"].as_str().unwrap_or("") { p.push(format!("line {}: company id differs from the batch header", n)); } if field(l, 80, 87) != b["odfi"].as_str().unwrap_or("") { p.push(format!("line {}: bank differs from the batch header", n)); } if int(field(l, 88, 94)) != b["number"].as_i64() { p.push(format!("line {}: batch number differs from the batch header", n)); } b.insert("entry_count".into(), json!(count)); b.insert("entry_hash".into(), json!(hashed)); b.insert("total_debit_cents".into(), json!(debit)); b.insert("total_credit_cents".into(), json!(credit)); b.insert("entries".into(), Value::Array(std::mem::take(&mut entries))); batches.push(Value::Object(b)); f_count += count; f_hash += hashed; f_debit += debit; f_credit += credit; } "9" => { if cur.is_some() { p.push(format!("line {}: the file ends inside a batch", n)); cur = None; } control = Some((n, l.to_string())); } o => p.push(format!("line {}: record type `{}` is not 1, 5, 6, 7, 8 or 9", n, o)), } } let blocks = (lines.len() as i64 + 9) / 10; match &control { None => { if !lines.is_empty() { p.push(format!("line {}: the file has no file control record", lines.len())); } } Some((n, l)) => { let checks = [ ("batch count", field(l, 2, 7), batches.len() as i64, 6), ("block count", field(l, 8, 13), blocks, 6), ("entry and addenda count", field(l, 14, 21), f_count, 8), ("entry hash", field(l, 22, 31), f_hash % 10_000_000_000, 10), ("total debits", field(l, 32, 43), f_debit, 12), ("total credits", field(l, 44, 55), f_credit, 12), ]; for (name, got, want, width) in checks { if int(got) != Some(want) { p.push(format!("line {}: file control, {} `{}` should be {}", n, name, got, num(want, width))); } } } } out.insert("batch_count".into(), json!(batches.len())); out.insert("entry_count".into(), json!(f_count)); out.insert("entry_hash".into(), json!(f_hash % 10_000_000_000)); out.insert("total_debit_cents".into(), json!(f_debit)); out.insert("total_credit_cents".into(), json!(f_credit)); out.insert("block_count".into(), json!(blocks)); out.insert("padding_lines".into(), json!(padding)); out.insert("batches".into(), Value::Array(batches)); (Value::Object(out), p)}/// Reads an ACH file into its header, batches and entries, addenda, returns (type 99) and changes (type 98) among them. Any problem is an error naming its line; `nacha_check` lists them all.pub fn nacha_parse(text: &str) -> Result<Value, String> { let (v, p) = read(text); match p.into_iter().next() { Some(first) => Err(first), None => Ok(v), }}/// Every problem in an ACH file: `{ ok, problems, batch_count, entry_count, entry_hash, total_debit_cents, total_credit_cents }`, each problem starting `line N:`.pub fn nacha_check(text: &str) -> Value { let (v, p) = read(text); json!({ "ok": p.is_empty(), "problems": p, "batch_count": v["batch_count"], "entry_count": v["entry_count"], "entry_hash": v["entry_hash"], "total_debit_cents": v["total_debit_cents"], "total_credit_cents": v["total_credit_cents"], })}/// Reads an ACH file from disk as `nacha_parse` does.pub fn nacha_read_file(path: &str) -> Result<Value, String> { nacha_parse(&std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path, e))?)}/// A flat list of a file's entries, each with its batch's company, SEC code and effective date: `{ company_name, sec_code, effective_date, kind, name, amount_cents, routing, account, trace, line, addenda }`.pub fn nacha_entries(text: &str) -> Result<Vec<Value>, String> { let v = nacha_parse(text)?; let mut out = vec![]; for b in v["batches"].as_array().into_iter().flatten() { for e in b["entries"].as_array().into_iter().flatten() { out.push(json!({ "company_name": b["company_name"], "sec_code": b["sec_code"], "effective_date": b["effective_date"], "kind": e["kind"], "name": e["name"], "amount_cents": e["amount_cents"], "routing": e["routing"], "account": e["account"], "trace": e["trace"], "line": e["line"], "addenda": e["addenda"], })); } } Ok(out)}Related
iso20022.qwer · 1.0.0ISO 20022 bank files: camt.053 statements (and camt.052 and 054) read into balances, entries and each payment's counterparty and reference, and pain.001 SEPA credit transfers built with checked IBANs and exact totals.7 views · 6 downloadsMT940 mt940.qwer · 1.0.0MT940 bank statements: accounts, opening and closing balances, and each transaction with its date, amount and counterparty.8 views · 6 downloadsIBAN and BIC iban.qwer · 1.0.0IBANs checked (country, length, check digits) and printed as banks print them, and BICs checked.9 views · 8 downloadsUS routing numbers aba_routing.qwer · 1.0.0US bank routing numbers checked by their check digit and read for the Federal Reserve district and kind of institution, a check's MICR line read into routing, account and check number, and Canadian transit numbers.7 views · 6 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 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 downloadsThread 0 posts
On NACHA ACH files: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.