From 1fa77cff02a1845bd9edf22fb1ad8c85f0ffd42d Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 26 Oct 2026 15:43:08 -0600 Subject: [PATCH] feat(settings): invoicing, bills and contact settings (P4b S4.2) Eleven settings the invoice and bill workflow reads: the number prefix and first number, default terms, payment methods with their bank details and QR links, a footer, the unsent and reminder day counts, the bill reminder and discount-warning day counts, and the company's email and website that the seller snapshot prints. Three new kinds keep them exact: a count is a positive whole number, terms are canonicalized through Terms so "net 30" is stored once as "Net 30" (B-39), and a payment method list admits at most eight methods whose lines are at most 120 characters. A routing number is nine digits, so an ach or wire method's bank-detail lines are the one text exempt from the full-TIN check (A-3); every other text in a method is scanned. The keys need the documents_v2 feature (D-52): an older version doesn't know them, and the settings replay refuses an unknown key as newer. The constant, the known-features entry and the gate test land with this slice (its 8.0.6 work); S4.3 adds the document write call sites and must not redeclare them. A settings file now exports the [invoicing] and [bills] tables, a payment method as an inline table (never a [[payment_methods]] section that would land outside its table), and imports them back to the same values. The unknown-key test uses fax instead of website, which is now a setting. --- crates/ledger-config/src/codec.rs | 135 +++++++++-- crates/ledger-config/tests/codec_props.rs | 19 ++ crates/ledger-core/src/settings.rs | 256 ++++++++++++++++++++- crates/ledger-core/tests/settings_units.rs | 181 +++++++++++++++ crates/ledger-store/src/format.rs | 24 ++ 5 files changed, 591 insertions(+), 24 deletions(-) diff --git a/crates/ledger-config/src/codec.rs b/crates/ledger-config/src/codec.rs index 5824ad3..3150aae 100644 --- a/crates/ledger-config/src/codec.rs +++ b/crates/ledger-config/src/codec.rs @@ -349,23 +349,53 @@ pub fn receipt_policy(s: &Settings) -> ReceiptPolicy { } } +/// A scalar as TOML writes it (strings escaped and quoted the TOML way). +fn toml_scalar(v: toml::Value) -> String { + let mut t = toml::Table::new(); + t.insert("x".to_string(), v); + let out = toml::to_string(&t).unwrap_or_default(); + let out = out.trim_end(); + out.strip_prefix("x = ").unwrap_or(out).to_string() +} + +/// `v` on one line, as TOML's inline form: a table is `{ k = v }`, so a +/// list of payment methods stays inside its table instead of becoming +/// `[[payment_methods]]` sections under a later table. +fn inline_toml(v: &Value) -> String { + match v { + Value::String(s) => toml_scalar(toml::Value::String(s.clone())), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::Array(a) => format!( + "[{}]", + a.iter().map(inline_toml).collect::>().join(", ") + ), + Value::Object(o) => format!( + "{{ {} }}", + o.iter() + .map(|(k, v)| format!("{} = {}", toml_key(k), inline_toml(v))) + .collect::>() + .join(", ") + ), + Value::Null => "\"\"".to_string(), + } +} + +/// A TOML key: bare when it can be, quoted when it can't. +fn toml_key(k: &str) -> String { + if !k.is_empty() + && k.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + k.to_string() + } else { + toml_scalar(toml::Value::String(k.to_string())) + } +} + /// One TOML line `name = value`, the value spelled as TOML. fn toml_line(name: &str, v: &Value) -> String { - let tv = match v { - Value::String(s) => toml::Value::String(s.clone()), - Value::Array(a) => toml::Value::Array( - a.iter() - .map(|x| toml::Value::String(x.as_str().unwrap_or_default().to_string())) - .collect(), - ), - other => toml::Value::String(other.to_string()), - }; - let mut t = toml::Table::new(); - t.insert(name.to_string(), tv); - toml::to_string(&t) - .unwrap_or_default() - .trim_end() - .to_string() + format!("{name} = {}\n", inline_toml(v)) } fn provenance(out: &mut String, r: &settings::Recorded) { @@ -397,7 +427,14 @@ pub fn export(s: &Settings, base_currency: &str) -> String { out.push_str( "# The books keep their own settings: this file is a copy, and editing it\n# changes nothing (books.set_setting changes them).\n", ); - for table in ["company", "books"] { + for table in ["company", "books", "invoicing", "bills"] { + // The invoicing and bills tables are written only once a key is + // set: an empty table says nothing. + if !["company", "books"].contains(&table) + && !s.iter().any(|(k, _)| k.toml.0 == table) + { + continue; + } let _ = write!(out, "\n[{table}]\n"); if table == "company" { let _ = writeln!( @@ -537,9 +574,65 @@ open_at_start = "ask" ); } - /// INVARIANT 4 for policy.require_tax_category (rules.md B-32): it is - /// `[books] require_tax_category` in a file, and round-trips; a file - /// without it clears it back to warn. + /// INVARIANT 4 for the invoicing settings (P4b S4.2): the two new + /// tables export, a payment method with its bank details, its link and + /// its QR flag included, and the file imports as no change at all. + #[test] + fn the_invoicing_settings_round_trip() { + let file = format!( + "{FERNHILL}\n[invoicing]\n\ + number_prefix = \"ACME-\"\n\ + first_number = 1000\n\ + default_terms = \"2/10 net 30\"\n\ + footer = \"Thank you.\"\n\ + unsent_days = 2\n\ + reminder_days = 14\n\ + payment_methods = [{{kind = \"ach\", label = \"Bank transfer\", lines = [\"Routing 123456789\"]}}, {{kind = \"link\", label = \"Card\", lines = [], link = \"https://pay.example/acme\", qr = true}}]\n\ + \n[bills]\n\ + remind_days = 7\n\ + discount_warn_days = 3\n" + ); + let s = imported(&file); + assert_eq!( + s.get("invoicing.first_number").unwrap().value, + json!(1000) + ); + assert_eq!( + s.get("invoicing.default_terms").unwrap().value, + json!("2/10 net 30") + ); + let text = export(&s, "USD"); + assert!(text.contains("[invoicing]"), "{text}"); + assert!(text.contains("[bills]"), "{text}"); + assert!(text.contains("payment_methods = ["), "{text}"); + assert!(text.contains("qr = true"), "{text}"); + let back = import(&text, None, &s, "USD").unwrap(); + assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes); + assert!(back.unknown.is_empty(), "{:?}", back.unknown); + // Into empty books: the same values, in one spelling. + let fresh = import(&text, None, &Settings::default(), "USD").unwrap(); + let mut other = Settings::default(); + other.apply(&fresh.changes, &src(), 1).unwrap(); + for name in [ + "invoicing.number_prefix", + "invoicing.first_number", + "invoicing.default_terms", + "invoicing.footer", + "invoicing.unsent_days", + "invoicing.reminder_days", + "invoicing.payment_methods", + "bills.remind_days", + "bills.discount_warn_days", + ] { + assert_eq!( + other.get(name).map(|r| &r.value), + s.get(name).map(|r| &r.value), + "{name}" + ); + } + } + + #[test] fn the_tax_category_policy_round_trips() { let file = FERNHILL.replace( @@ -582,13 +675,13 @@ open_at_start = "ask" #[test] fn unknown_and_currency() { let i = import( - "[company]\nwebsite = \"x\"\n[extra]\na = 1\n", + "[company]\nfax = \"x\"\n[extra]\na = 1\n", None, &Settings::default(), "USD", ) .unwrap(); - assert_eq!(i.unknown, ["[company] website", "[extra] a"]); + assert_eq!(i.unknown, ["[company] fax", "[extra] a"]); assert!( import( "[company]\nbase_currency = \"EUR\"\n", diff --git a/crates/ledger-config/tests/codec_props.rs b/crates/ledger-config/tests/codec_props.rs index 398fe61..332cdc4 100644 --- a/crates/ledger-config/tests/codec_props.rs +++ b/crates/ledger-config/tests/codec_props.rs @@ -70,6 +70,25 @@ fn candidates() -> Vec<(&'static str, Value)> { ("policy.evidence.exempt_accounts", json!(["6999", "6998"])), ("policy.parties", json!("require")), ("policy.approval_rules", json!("enforce")), + // P4b S4.2: invoicing, bills and contact details. + ("invoicing.number_prefix", json!("ACME-")), + ("invoicing.first_number", json!(1000)), + ("invoicing.default_terms", json!("2/10 net 30")), + ("invoicing.default_terms", json!("Net 15 EOM")), + ( + "invoicing.payment_methods", + json!([{"kind": "ach", "label": "Bank transfer", + "lines": ["Routing 123456789", "Account 000123456789"]}, + {"kind": "link", "label": "Card", "lines": [], + "link": "https://pay.example/acme", "qr": true}]), + ), + ("invoicing.footer", json!("Thank you for your business.")), + ("invoicing.unsent_days", json!(2)), + ("invoicing.reminder_days", json!(14)), + ("bills.remind_days", json!(7)), + ("bills.discount_warn_days", json!(3)), + ("company.email", json!("billing@acme.example")), + ("company.website", json!("https://acme.example")), ] } diff --git a/crates/ledger-core/src/settings.rs b/crates/ledger-core/src/settings.rs index 0293b16..0c8e9c0 100644 --- a/crates/ledger-core/src/settings.rs +++ b/crates/ledger-core/src/settings.rs @@ -31,7 +31,15 @@ //! 5. Unset keys mean the defaults the settings file had: "Unnamed //! company", a calendar year, accrual basis, receipts above 25.00, //! parties suggested, a missing tax category warned of (not -//! refused), approval rules off, the computer's time zone. +//! refused), approval rules off, the computer's time zone; and, for +//! invoicing, the prefix "INV-", the first number 1, "Net 30", and +//! day counts of 2, 14, 7 and 3 (P4b S4.2). +//! 6. Payment terms keep one spelling ([`crate::Terms::describe`]): +//! "net 30" is stored as "Net 30", never as it was typed. +//! 7. A payment method's bank-detail lines (an `ach` or `wire` method's +//! `lines`) are the one text the full-TIN check doesn't scan, because +//! a routing number is nine digits (A-3); every other text in the +//! list is scanned. //! ``` use crate::TaxForm; @@ -129,6 +137,13 @@ pub enum Kind { /// A folder named from the root of its disk: one line, an absolute /// path. Folder, + /// A positive whole number (a day count, the first invoice number). + Count, + /// Payment terms, in their one canonical spelling ([`crate::Terms`], + /// rules.md B-39). + Terms, + /// Payment methods (A-3): a list of `{kind, label, lines, link?, qr?}`. + PaymentMethods, } /// One setting the books know. @@ -398,6 +413,84 @@ pub static KEYS: &[Key] = &[ sensitivity: Sensitivity::Public, doc: "A folder the books back themselves up to as well (D-30): an absolute path, an external disk or a synced folder; the backup is a repository in it named for the book. Unset: none.", }, + // P4b S4.2: invoicing, bills and the company's contact details. + Key { + name: "invoicing.number_prefix", + toml: ("invoicing", "number_prefix"), + kind: Kind::Text, + sensitivity: Sensitivity::Public, + doc: "The prefix of an invoice number (\"INV-\" gives INV-0001): 1 to 12 visible ASCII characters. Unset: \"INV-\".", + }, + Key { + name: "invoicing.first_number", + toml: ("invoicing", "first_number"), + kind: Kind::Count, + sensitivity: Sensitivity::Public, + doc: "The number the first numbered invoice takes (the series starts there): a positive whole number, settable only while no numbered invoice exists. Unset: 1.", + }, + Key { + name: "invoicing.default_terms", + toml: ("invoicing", "default_terms"), + kind: Kind::Terms, + sensitivity: Sensitivity::Public, + doc: "The terms an invoice takes when neither the invoice nor the customer gives any (\"Net 30\", \"Net 15 EOM\", \"2/10 net 30\", \"Due on receipt\"). Unset: \"Net 30\".", + }, + Key { + name: "invoicing.payment_methods", + toml: ("invoicing", "payment_methods"), + kind: Kind::PaymentMethods, + sensitivity: Sensitivity::Public, + doc: "The ways the company takes payment, printed on an invoice (A-3): [{kind: ach|wire|link|check|p2p|crypto|other, label, lines, link?, qr?}], at most 8; a bank-detail line of an ach or wire method may hold a routing number. Unset: none.", + }, + Key { + name: "invoicing.footer", + toml: ("invoicing", "footer"), + kind: Kind::LongText, + sensitivity: Sensitivity::Public, + doc: "Text printed at the foot of each invoice (thank-you, payment note). Unset: none.", + }, + Key { + name: "invoicing.unsent_days", + toml: ("invoicing", "unsent_days"), + kind: Kind::Count, + sensitivity: Sensitivity::Public, + doc: "How many days after issue an invoice nobody marked sent is listed as unsent: a positive whole number. Unset: 2.", + }, + Key { + name: "invoicing.reminder_days", + toml: ("invoicing", "reminder_days"), + kind: Kind::Count, + sensitivity: Sensitivity::Public, + doc: "How many days after a reminder an overdue invoice is reminded again: a positive whole number. Unset: 14.", + }, + Key { + name: "bills.remind_days", + toml: ("bills", "remind_days"), + kind: Kind::Count, + sensitivity: Sensitivity::Public, + doc: "A bill due within this many days is listed as due soon: a positive whole number. Unset: 7.", + }, + Key { + name: "bills.discount_warn_days", + toml: ("bills", "discount_warn_days"), + kind: Kind::Count, + sensitivity: Sensitivity::Public, + doc: "A bill whose early-payment discount ends within this many days is listed: a positive whole number. Unset: 3.", + }, + Key { + name: "company.email", + toml: ("company", "email"), + kind: Kind::Text, + sensitivity: Sensitivity::Public, + doc: "The company's email address, printed on an invoice as the seller's.", + }, + Key { + name: "company.website", + toml: ("company", "website"), + kind: Kind::Text, + sensitivity: Sensitivity::Public, + doc: "The company's website, printed on an invoice as the seller's.", + }, ]; /// The key named `name`. @@ -688,6 +781,15 @@ fn no_tin(what: &str, s: &str) -> Result<(), String> { /// One line of text: trimmed non-empty, at most `max` characters, no line /// breaks or other control characters, no full TIN. pub fn line_text(what: &str, s: &str, max: usize) -> Result { + let t = bare_line_text(what, s, max)?; + no_tin(what, &t)?; + Ok(t) +} + +/// [`line_text`] without the full-TIN check: for the one place a nine-digit +/// number is legitimate (an `ach` or `wire` payment method's bank-detail +/// line, where it is a routing number: A-3). +fn bare_line_text(what: &str, s: &str, max: usize) -> Result { let t = s.trim(); if t.is_empty() { return Err(format!("{what} is blank")); @@ -700,7 +802,6 @@ pub fn line_text(what: &str, s: &str, max: usize) -> Result { "{what} holds a line break or other control character" )); } - no_tin(what, t)?; Ok(t.to_string()) } @@ -821,13 +922,127 @@ fn want_list<'a>(name: &str, v: &'a Value) -> Result<&'a Vec, String> { .ok_or_else(|| format!("{name} takes a list, not {v}")) } +/// The payment-method kinds A-3 allows. +const PAYMENT_KINDS: &[&str] = &["ach", "wire", "link", "check", "p2p", "crypto", "other"]; + +/// A positive whole number: a JSON number, or the digits a settings file +/// writes it as. +fn count_of(name: &str, v: &Value) -> Result { + let n = match v { + Value::Number(n) => n.as_u64().and_then(|n| u32::try_from(n).ok()), + Value::String(s) => { + let s = s.trim(); + (s.bytes().all(|b| b.is_ascii_digit()) && !s.is_empty()) + .then(|| s.parse::().ok()) + .flatten() + } + _ => None, + }; + n.filter(|n| *n >= 1) + .ok_or_else(|| format!("{name} {v}: expected a positive whole number (1 or more)")) +} + +/// A `qr` flag: a boolean, or the word a settings file writes it as. +fn flag_of(name: &str, v: &Value) -> Result { + match v { + Value::Bool(b) => Ok(*b), + Value::String(s) if s == "true" => Ok(true), + Value::String(s) if s == "false" => Ok(false), + _ => Err(format!("{name} {v}: expected true or false")), + } +} + +/// `invoicing.payment_methods` in its canonical form (A-3): at most 8 +/// methods, each `{kind, label, lines, link?, qr?}`. An `ach` or `wire` +/// method's `lines` are its bank details, where a nine-digit routing +/// number is legitimate; every other text in the list is scanned for a +/// full TIN like every other setting. +fn payment_methods(name: &str, list: &[Value]) -> Result { + if list.len() > 8 { + return Err(format!("{name}: at most 8 payment methods")); + } + let mut out = Vec::new(); + for (i, m) in list.iter().enumerate() { + let n = format!("{name} method {}", i + 1); + let o = m + .as_object() + .ok_or_else(|| format!("{n}: expected {{kind, label, lines}}"))?; + for k in o.keys() { + if !["kind", "label", "lines", "link", "qr"].contains(&k.as_str()) { + return Err(format!("{n}: unknown field {k:?}")); + } + } + let kind = o + .get("kind") + .and_then(Value::as_str) + .map(|k| k.trim().to_lowercase()) + .filter(|k| PAYMENT_KINDS.contains(&k.as_str())) + .ok_or_else(|| format!("{n}: kind is one of {}", PAYMENT_KINDS.join(", ")))?; + let label = line_text( + &format!("{n} label"), + o.get("label").and_then(Value::as_str).unwrap_or_default(), + MAX_TEXT, + )?; + let lines = o + .get("lines") + .and_then(Value::as_array) + .ok_or_else(|| format!("{n}: lines is a list of strings"))?; + let bank_details = matches!(kind.as_str(), "ach" | "wire"); + let mut checked_lines = Vec::new(); + for l in lines { + let text = l + .as_str() + .ok_or_else(|| format!("{n}: each line is a string"))?; + // A routing number is nine digits: bank-detail lines are the + // one text the TIN check doesn't scan (A-3). Length and + // control characters are still checked. + let text = if bank_details { + bare_line_text(&format!("{n} line"), text, 120)? + } else { + line_text(&format!("{n} line"), text, 120)? + }; + checked_lines.push(json!(text)); + } + let mut method = serde_json::Map::new(); + method.insert("kind".into(), json!(kind)); + method.insert("label".into(), json!(label)); + method.insert("lines".into(), Value::Array(checked_lines)); + if let Some(link) = o.get("link").filter(|l| !l.is_null()) { + let link = line_text( + &format!("{n} link"), + want_str(&format!("{n} link"), link)?, + MAX_TEXT, + )?; + method.insert("link".into(), json!(link)); + } + if let Some(qr) = o.get("qr").filter(|q| !q.is_null()) { + method.insert("qr".into(), json!(flag_of(&format!("{n} qr"), qr)?)); + } + out.push(Value::Object(method)); + } + Ok(Value::Array(out)) +} + /// Check a value for `key` before it is journaled, and return it in its /// canonical spelling (INVARIANT 1). `null` is never a value: clearing a /// key is its own change. pub fn check(key: &Key, v: &Value) -> Result { let name = key.name; match key.kind { - Kind::Text => Ok(json!(line_text(name, want_str(name, v)?, MAX_TEXT)?)), + Kind::Text => { + let text = line_text(name, want_str(name, v)?, MAX_TEXT)?; + // One key's own bound: an invoice number's prefix is short and + // visible (a space or a control character would break the + // number's reading, and the file's). + if name == "invoicing.number_prefix" + && (text.chars().count() > 12 || !text.chars().all(|c| c.is_ascii_graphic())) + { + return Err(format!( + "{name} {text:?}: 1 to 12 visible ASCII characters (no spaces)" + )); + } + Ok(json!(text)) + } Kind::LongText => Ok(json!(long_text(name, want_str(name, v)?, MAX_LONG_TEXT)?)), Kind::Choice(choices) => { let s = want_str(name, v)?.trim().to_lowercase(); @@ -879,6 +1094,13 @@ pub fn check(key: &Key, v: &Value) -> Result { Ok(json!(out)) } Kind::Members => Ok(members_json(&check_members(want_list(name, v)?)?)), + Kind::Count => Ok(json!(count_of(name, v)?)), + Kind::Terms => { + let s = want_str(name, v)?.trim(); + let terms = crate::Terms::parse(s).map_err(|e| format!("{name}: {e}"))?; + Ok(json!(terms.describe())) + } + Kind::PaymentMethods => payment_methods(name, want_list(name, v)?), Kind::TimeZone => { let s = want_str(name, v)?.trim(); let zone = jiff::tz::TimeZone::get(s).map_err(|_| { @@ -1005,6 +1227,29 @@ pub fn shape(key: &Key, v: &Value) -> Result<(), String> { .map(|_| ()) .ok_or_else(|| format!("{name} {s:?} is not a decimal")) } + Kind::Count => count_of(name, v).map(|_| ()), + Kind::PaymentMethods => { + for (i, m) in want_list(name, v)?.iter().enumerate() { + let n = format!("{name} method {}", i + 1); + let o = m + .as_object() + .ok_or_else(|| format!("{n}: expected an object"))?; + let field = |f: &str| o.get(f).ok_or_else(|| format!("{n}: no {f}")); + want_str(&format!("{n} kind"), field("kind")?)?; + want_str(&format!("{n} label"), field("label")?)?; + let lines = want_list(&format!("{n} lines"), field("lines")?)?; + for l in lines { + want_str(&format!("{n} line"), l)?; + } + if let Some(link) = o.get("link").filter(|l| !l.is_null()) { + want_str(&format!("{n} link"), link)?; + } + if o.get("qr").is_some_and(|q| !q.is_boolean() && !q.is_null()) { + return Err(format!("{n} qr: expected true or false")); + } + } + Ok(()) + } Kind::MonthStart => parse_month_start_lenient(want_str(name, v)?).map(|_| ()), _ => want_str(name, v).map(|_| ()), } @@ -1530,6 +1775,11 @@ pub fn display(key: &Key, v: &Value, full: bool) -> String { }) .collect::>() .join(", "), + Value::Array(a) if key.kind == Kind::PaymentMethods => a + .iter() + .filter_map(|m| m.get("label").and_then(Value::as_str)) + .collect::>() + .join(", "), Value::Array(a) => a .iter() .filter_map(Value::as_str) diff --git a/crates/ledger-core/tests/settings_units.rs b/crates/ledger-core/tests/settings_units.rs index 411d406..5427dcd 100644 --- a/crates/ledger-core/tests/settings_units.rs +++ b/crates/ledger-core/tests/settings_units.rs @@ -417,3 +417,184 @@ fn dates_take_one_spelling() { ); } } + +// ------------------------------------------------ P4b S4.2: the new kinds + +/// `Kind::Count`: a positive whole number, in one canonical JSON number +/// (a settings file writes it as digits; the journal keeps the number). +#[test] +fn counts_are_positive_whole_numbers() { + let days = |v: Value| checked("invoicing.unsent_days", v); + assert_eq!(days(json!(2)), Ok(json!(2))); + assert_eq!(days(json!("14")), Ok(json!(14))); + assert_eq!( + checked("invoicing.first_number", json!(1000)), + Ok(json!(1000)) + ); + for bad in [ + json!(0), + json!(-1), + json!("0"), + json!("-2"), + json!(1.5), + json!("2.0"), + json!("x"), + json!(true), + json!(null), + ] { + assert!(days(bad.clone()).is_err(), "{bad}"); + } + // Every day-count key takes the same kind. + for k in [ + "invoicing.first_number", + "invoicing.unsent_days", + "invoicing.reminder_days", + "bills.remind_days", + "bills.discount_warn_days", + ] { + assert_eq!(checked(k, json!(7)), Ok(json!(7)), "{k}"); + assert!(checked(k, json!(0)).is_err(), "{k}"); + } +} + +/// `Kind::Terms`: canonicalized through [`ledger_core::Terms`], so two +/// spellings of one agreement are one value (rules.md B-39). +#[test] +fn terms_keep_one_canonical_spelling() { + let terms = |s: &str| checked("invoicing.default_terms", json!(s)); + assert_eq!(terms("net 30"), Ok(json!("Net 30"))); + assert_eq!(terms(" NET 15 EOM "), Ok(json!("Net 15 EOM"))); + assert_eq!(terms("2/10 net 30"), Ok(json!("2/10 net 30"))); + assert_eq!(terms("Due On Receipt"), Ok(json!("Due on receipt"))); + assert_eq!(terms("due 2026-11-15"), Ok(json!("Due 2026-11-15"))); + assert_eq!(terms("2.5/10 net 30"), Ok(json!("2.5/10 net 30"))); + for bad in [ + "net", + "Net 4000", + "Net -5", + "2/40 net 30", + "0/10 net 30", + "2/10 net 15 eom", + "2/10 net 30 extra", + "Due 9999-13-01", + "Due 2026-1-5", + "", + ] { + assert!(terms(bad).is_err(), "{bad:?}"); + } +} + +/// `Kind::PaymentMethods` (A-3): a routing number is nine digits, so an +/// `ach` or `wire` method's bank-detail lines are the one text exempt from +/// the full-TIN check; every other text in the list is scanned. At most +/// eight methods, each line at most 120 characters. +#[test] +fn payment_methods_scan_every_text_but_ach_and_wire_lines() { + let methods = |v: Value| checked("invoicing.payment_methods", v); + for kind in ["ach", "wire"] { + let got = methods(json!([{ + "kind": kind, + "label": "Bank transfer", + "lines": ["Routing 123456789", "Account 000123456789"], + }])) + .unwrap(); + assert_eq!(got[0]["lines"][0], json!("Routing 123456789"), "{kind}"); + assert_eq!(got[0]["lines"][1], json!("Account 000123456789"), "{kind}"); + } + for bad in [ + json!([{"kind": "link", "label": "123456789", "lines": []}]), + json!([{"kind": "link", "label": "Pay here", "lines": ["123456789"]}]), + json!([{"kind": "check", "label": "Check", "lines": ["pay to 123456789"]}]), + json!([{"kind": "other", "label": "Cash", "lines": [], "link": "123456789"}]), + ] { + assert!(methods(bad.clone()).is_err(), "{bad}"); + } + // Canonical: the kind's spelling and the texts are trimmed. + assert_eq!( + methods(json!([{"kind": "ACH", "label": " Bank ", "lines": [" 123456789 "], "qr": true}])) + .unwrap(), + json!([{"kind": "ach", "label": "Bank", "lines": ["123456789"], "qr": true}]) + ); + // Unknown kind and unknown field are refused. + assert!(methods(json!([{"kind": "barter", "label": "X", "lines": []}])).is_err()); + assert!( + methods(json!([{"kind": "other", "label": "X", "lines": [], "note": "y"}])).is_err() + ); + // At most eight methods. + let pool = |n: usize| { + Value::Array( + (0..n) + .map(|i| json!({"kind": "other", "label": format!("Method {i}"), "lines": []})) + .collect(), + ) + }; + assert!(methods(pool(8)).is_ok()); + assert!(methods(pool(9)).is_err()); + // Each line at most 120 characters. + let long = "x".repeat(121); + assert!( + methods(json!([{"kind": "other", "label": "M", "lines": [long]}])).is_err(), + "121 characters" + ); +} + +/// `invoicing.number_prefix` is 1 to 12 visible ASCII characters: a number +/// with a space or a control character in it reads wrong, and an empty +/// prefix is no prefix. +#[test] +fn number_prefix_is_short_visible_ascii() { + let prefix = |s: &str| checked("invoicing.number_prefix", json!(s)); + for ok in ["INV-", "A", "2026/", "ABCDEFGHIJKL"] { + assert_eq!(prefix(ok), Ok(json!(ok)), "{ok:?}"); + } + assert_eq!(prefix(" INV- "), Ok(json!("INV-"))); + for bad in ["", " ", "ABCDEFGHIJKLM", "INV 1", "INV\t1", "Ünï"] { + assert!(prefix(bad).is_err(), "{bad:?}"); + } +} + +/// The company's contact details are simple company keys, so the seller +/// snapshot reads them through `Company::profile`. +#[test] +fn contact_details_are_in_the_company_profile() { + let s = settings(&[ + ("company.name", json!("Acme LLC")), + ("company.email", json!("billing@acme.example")), + ("company.website", json!("https://acme.example")), + ]); + let profile: Vec<(String, String)> = s.company("USD").profile; + assert!( + profile.contains(&("email".into(), "billing@acme.example".into())), + "{profile:?}" + ); + assert!( + profile.contains(&("website".into(), "https://acme.example".into())), + "{profile:?}" + ); +} + +/// Replay checks the new kinds by shape, never by a later version's +/// bounds: a count is a positive whole number, terms any string, and a +/// method list of objects with their fields (rules.md B-28). +#[test] +fn replay_shapes_the_new_kinds() { + let count = key("invoicing.unsent_days").unwrap(); + assert!(shape(count, &json!(2)).is_ok()); + assert!(shape(count, &json!("2")).is_ok()); + assert!(shape(count, &json!(0)).is_err()); + let terms = key("invoicing.default_terms").unwrap(); + assert!(shape(terms, &json!("Net 30")).is_ok()); + assert!(shape(terms, &json!("a spelling this version doesn't know")).is_ok()); + assert!(shape(terms, &json!(30)).is_err()); + let methods = key("invoicing.payment_methods").unwrap(); + assert!(shape(methods, &json!([])).is_ok()); + assert!( + shape( + methods, + &json!([{"kind": "ach", "label": "Bank", "lines": ["123456789"]}]) + ) + .is_ok() + ); + assert!(shape(methods, &json!([{"kind": "ach", "label": "Bank"}])).is_err()); +} + diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index acec73f..4f5dbd8 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -62,6 +62,7 @@ pub const KNOWN_FEATURES: &[&str] = &[ PROJECTS, DOCUMENT_LINE_PROJECTS, DOCUMENT_STANDING, + DOCUMENTS_V2, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -88,6 +89,19 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> { match key { "policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY), "books.schedule_convention" => Some(SCHEDULE_CONVENTION), + // P4b S4.2: the invoicing and bills settings, and the company's + // contact details an invoice's seller snapshot reads. + "invoicing.number_prefix" + | "invoicing.first_number" + | "invoicing.default_terms" + | "invoicing.payment_methods" + | "invoicing.footer" + | "invoicing.unsent_days" + | "invoicing.reminder_days" + | "bills.remind_days" + | "bills.discount_warn_days" + | "company.email" + | "company.website" => Some(DOCUMENTS_V2), _ => None, } } @@ -174,6 +188,16 @@ pub const DOCUMENT_LINE_PROJECTS: &str = "document_line_projects"; /// Every other payment is written as before and needs no feature. pub const DOCUMENT_STANDING: &str = "document_standing"; +/// Feature: `documents_v2` (D-52; plan P4b S4 to S7): an `invoice.created` +/// or `bill.created` carrying a number, terms, snapshots, references or +/// line accounts; a `party.updated` with an email or default terms; a +/// `settings.set` of the invoicing or bills keys or the company's contact +/// details. S4.2 writes the settings and party fields; S4.3 to S7 add the +/// document fields, sends, settlements and write-offs. An older version +/// would drop the line accounts (posting to the role accounts), the +/// discount and the fee, or refuse the unknown values, silently. +pub const DOCUMENTS_V2: &str = "documents_v2"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set";