diff --git a/crates/ledger-config/src/codec.rs b/crates/ledger-config/src/codec.rs index 3150aae..30014fe 100644 --- a/crates/ledger-config/src/codec.rs +++ b/crates/ledger-config/src/codec.rs @@ -430,9 +430,7 @@ pub fn export(s: &Settings, base_currency: &str) -> String { 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) - { + if !["company", "books"].contains(&table) && !s.iter().any(|(k, _)| k.toml.0 == table) { continue; } let _ = write!(out, "\n[{table}]\n"); @@ -593,10 +591,7 @@ open_at_start = "ask" 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.first_number").unwrap().value, json!(1000)); assert_eq!( s.get("invoicing.default_terms").unwrap().value, json!("2/10 net 30") @@ -632,7 +627,6 @@ open_at_start = "ask" } } - #[test] fn the_tax_category_policy_round_trips() { let file = FERNHILL.replace( diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 894ed05..2b3b019 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -4450,6 +4450,9 @@ impl Ledger { display_name: None, roles: std::collections::BTreeSet::new(), website: None, + email: None, + default_terms_as_customer: None, + default_terms_as_vendor: None, address: None, notes: None, archived: false, @@ -4576,6 +4579,9 @@ impl Ledger { display_name: None, roles: roles.iter().copied().collect(), website: None, + email: None, + default_terms_as_customer: None, + default_terms_as_vendor: None, address: None, notes: None, archived: false, @@ -4647,6 +4653,9 @@ impl Ledger { Address(v) => p.address = v, Notes(v) => p.notes = v, Archived(b) => p.archived = b, + Email(v) => p.email = v, + DefaultTermsAsCustomer(v) => p.default_terms_as_customer = v, + DefaultTermsAsVendor(v) => p.default_terms_as_vendor = v, } Ok(()) } @@ -4693,6 +4702,15 @@ impl Ledger { } } Archived(true) => {} + Email(v) => match v { + Some(e) => { + crate::parties::check_email("email", e).map_err(LedgerError::InvalidParty)? + } + None => {} + }, + // Terms are typed and already canonical (`crate::Terms`), so + // there is nothing here a string could get wrong. + DefaultTermsAsCustomer(_) | DefaultTermsAsVendor(_) => {} } Ok(()) } diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index ad8678e..9f26757 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -230,6 +230,18 @@ pub struct Party { /// Website. #[serde(default)] pub website: Option, + /// Email address, where the company writes to them (A-13): copied onto + /// an invoice's bill-to snapshot at issue. + #[serde(default)] + pub email: Option, + /// The terms this party grants the company as a customer: an invoice's + /// due date falls back to these (A-13). + #[serde(default)] + pub default_terms_as_customer: Option, + /// The terms this party, as a vendor, gives the company: a bill's due + /// date falls back to these (A-13). + #[serde(default)] + pub default_terms_as_vendor: Option, /// Business address. #[serde(default)] pub address: Option, @@ -687,6 +699,19 @@ pub fn check_text(field: &str, text: &str) -> Result<(), String> { Ok(()) } +/// Check an email address: one line of party text ([`check_text`], which +/// refuses a full TIN) in a plain address shape, one `@` and no spaces. Not +/// an RFC grammar test: the books only print it, they never send (A-4). +pub fn check_email(field: &str, text: &str) -> Result<(), String> { + check_text(field, text)?; + if text.chars().filter(|c| *c == '@').count() != 1 || text.chars().any(char::is_whitespace) { + return Err(format!( + "{field} {text:?}: expected one address, like billing@example.com" + )); + } + Ok(()) +} + impl W9Facts { /// Check the facts' form. Returns why not. pub fn check(&self) -> Result<(), String> { @@ -747,6 +772,44 @@ pub enum PartyField { Notes(Option), /// Archived (no longer dealt with) or not. Archived(bool), + /// The email address (A-13; `None` clears it). + Email(Option), + /// The terms it grants the company as a customer (A-13; `None` clears). + DefaultTermsAsCustomer(Option), + /// The terms it gives the company as a vendor (A-13; `None` clears). + DefaultTermsAsVendor(Option), +} + +impl PartyField { + /// Every field's wire name, as `party.updated` journals it. + pub const FIELDS: [&'static str; 9] = [ + "name", + "display_name", + "website", + "address", + "notes", + "archived", + "email", + "default_terms_as_customer", + "default_terms_as_vendor", + ]; + + /// Whether `field` is a field this version knows. A `party.updated` + /// naming another was written by a newer version (rules.md B-27). + pub fn known_field(field: &str) -> bool { + Self::FIELDS.contains(&field) + } + + /// Whether the field needs the `documents_v2` feature: the three A-13 + /// facts an older version would drop. + pub fn needs_documents_v2(&self) -> bool { + matches!( + self, + PartyField::Email(_) + | PartyField::DefaultTermsAsCustomer(_) + | PartyField::DefaultTermsAsVendor(_) + ) + } } /// Which of a party's totals to report (rules.md B-35 item 4). Not the diff --git a/crates/ledger-core/tests/settings_units.rs b/crates/ledger-core/tests/settings_units.rs index 5427dcd..8ade403 100644 --- a/crates/ledger-core/tests/settings_units.rs +++ b/crates/ledger-core/tests/settings_units.rs @@ -517,9 +517,7 @@ fn payment_methods_scan_every_text_but_ach_and_wire_lines() { ); // 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() - ); + assert!(methods(json!([{"kind": "other", "label": "X", "lines": [], "note": "y"}])).is_err()); // At most eight methods. let pool = |n: usize| { Value::Array( @@ -597,4 +595,3 @@ fn replay_shapes_the_new_kinds() { ); assert!(shape(methods, &json!([{"kind": "ach", "label": "Bank"}])).is_err()); } - diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index b75c26e..025d5af 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -124,6 +124,9 @@ fn bare_party(name: &str) -> Party { display_name: None, roles: BTreeSet::new(), website: None, + email: None, + default_terms_as_customer: None, + default_terms_as_vendor: None, address: None, notes: None, archived: false, diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 986e5ad..9c6753c 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1603,6 +1603,9 @@ impl Store { field: ledger_core::PartyField, ) -> Result<(), StoreError> { self.ledger.check_update_party(id, &field)?; + if field.needs_documents_v2() { + self.ensure_feature(crate::format::DOCUMENTS_V2)?; + } let line = self.append_journal("party.updated", json!({"id": id, "change": field}))?; self.ledger.update_party(id, field)?; apply_line_to_sqlite(&self.conn, &line)?; @@ -6203,6 +6206,19 @@ fn replay_line( } } "party.updated" => { + // A field this version doesn't know: a newer one wrote it + // (rules.md B-27). A known field with a bad value is damage. + if let Some(field) = p + .get("change") + .and_then(|c| c.get("field")) + .and_then(Value::as_str) + && !ledger_core::PartyField::known_field(field) + { + return Err(crate::format::newer_content( + line, + format!("the party field {field}"), + )); + } let field: ledger_core::PartyField = serde_json::from_value(p["change"].clone()) .map_err(|_| StoreError::JournalCorrupt("party.updated change".into()))?; ledger.update_party(get_u64(p, "id")?, field)?; diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index 815b47f..ecc8cf6 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -1200,6 +1200,55 @@ fn an_unknown_schedule_convention_is_newer_not_corrupt() { assert_corrupt(Store::open(&dir)); } +/// P4b S4.2 (rules.md B-27): a `party.updated` naming a field this version +/// doesn't know is newer, never corrupt; a known field with a malformed +/// value is still damage. +#[test] +fn an_unknown_party_field_is_newer_not_corrupt() { + let dir = test_dir("unknown-party-field"); + books(&dir); + let seq = append( + &dir, + &[ + ( + "party.updated", + json!({"id": 0, "change": {"field": "ssn", "value": "x"}}), + ), + ( + "fx.rate_set", + json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}), + ), + ], + )[0]; + std::fs::remove_file(dir.join(db_file())).unwrap(); + assert_newer( + Store::open(&dir), + seq, + "party.updated", + "the party field ssn", + false, + ); + assert_newer( + Snapshot::load(&dir), + seq, + "party.updated", + "the party field ssn", + false, + ); + + let dir = test_dir("bad-party-field-value"); + books(&dir); + append( + &dir, + &[( + "party.updated", + json!({"id": 0, "change": {"field": "email", "value": 5}}), + )], + ); + std::fs::remove_file(dir.join(db_file())).unwrap(); + assert_corrupt(Store::open(&dir)); +} + /// P3 (R3; rules.md B-27): a schedule month reversed within its month is /// posted again only in books that declare journal feature month_reposting /// (an older version would refuse the second schedule.posted as posted diff --git a/crates/ledger-store/tests/parties.rs b/crates/ledger-store/tests/parties.rs index 73df9ed..b37ea72 100644 --- a/crates/ledger-store/tests/parties.rs +++ b/crates/ledger-store/tests/parties.rs @@ -318,6 +318,111 @@ fn update_party_journals_party_updated() { assert_eq!(got.notes.as_deref(), Some("net 30")); } +/// Oracle: P4b S4.2 (A-13, rules.md B-39, R-A step 3): a party's email and +/// default terms are `party.updated` facts the `documents_v2` feature +/// guards. In books that haven't declared it they are refused, journaling +/// nothing; after the upgrade they journal, replay gives them back, and an +/// email in no plain address shape or holding a full TIN is refused. +#[test] +fn a_party_takes_an_email_and_terms_under_documents_v2() { + let dir = test_dir("email-terms"); + // Books as an older version wrote them: no features declared, as the + // owner's live books are until the go-ahead (D-39). + let mut s = + Store::create_in_format(&dir, Currency::USD, &ledger_store::JournalFormat::default()) + .unwrap(); + let id = s.add_party("Acme", &[]).unwrap(); + let net = ledger_core::Terms::parse("Net 30").unwrap(); + let early = ledger_core::Terms::parse("2/10 net 30").unwrap(); + let fields = [ + PartyField::Email(Some("billing@acme.example".into())), + PartyField::DefaultTermsAsCustomer(Some(net.clone())), + PartyField::DefaultTermsAsVendor(Some(early.clone())), + ]; + for field in &fields { + refused_nothing_journaled(&mut s, &dir, &format!("{field:?} before consent"), |s| { + s.update_party(id, field.clone()) + }); + } + assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead"); + + let ((), p) = one_event(&mut s, &dir, "party.updated", |s| { + s.update_party(id, fields[0].clone()).unwrap() + }); + assert_eq!( + p["change"], + json!({"field": "email", "value": "billing@acme.example"}) + ); + let ((), p) = one_event(&mut s, &dir, "party.updated", |s| { + s.update_party(id, fields[1].clone()).unwrap() + }); + assert_eq!(p["change"]["field"], json!("default_terms_as_customer")); + assert_eq!( + serde_json::from_value::(p["change"]["value"].clone()).unwrap(), + net, + "the journal carries the terms whole" + ); + let ((), p) = one_event(&mut s, &dir, "party.updated", |s| { + s.update_party(id, fields[2].clone()).unwrap() + }); + assert_eq!(p["change"]["field"], json!("default_terms_as_vendor")); + assert_eq!( + serde_json::from_value::(p["change"]["value"].clone()).unwrap(), + early, + ); + + let got = s.ledger().get_party(id).unwrap(); + assert_eq!(got.email.as_deref(), Some("billing@acme.example")); + assert_eq!( + got.default_terms_as_customer.as_ref().map(|t| t.describe()), + Some("Net 30".to_string()) + ); + assert_eq!( + got.default_terms_as_vendor.as_ref().map(|t| t.describe()), + Some("2/10 net 30".to_string()) + ); + + // Refusals, journaling nothing: no plain address shape, or a full TIN. + for bad in [ + "billing", + "a b@example.com", + "a@@example.com", + "123-45-6789", + ] { + refused_nothing_journaled(&mut s, &dir, &format!("email {bad:?}"), |s| { + s.update_party(id, PartyField::Email(Some(bad.into()))) + }); + } + + // `None` clears them, and the books replay to the same directory. + s.update_party(id, PartyField::Email(None)).unwrap(); + s.update_party(id, PartyField::DefaultTermsAsVendor(None)) + .unwrap(); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + for without_db in [false, true] { + if without_db { + delete_db(&dir); + } + let s = Store::open(&dir).unwrap(); + assert!( + *s.ledger() == live, + "replay (db {}): {:?}", + !without_db, + s.ledger().differs_from(&live) + ); + let got = s.ledger().get_party(id).unwrap(); + assert_eq!(got.email, None); + assert_eq!(got.default_terms_as_vendor, None); + assert_eq!( + got.default_terms_as_customer.as_ref().map(|t| t.describe()), + Some("Net 30".to_string()) + ); + s.verify().unwrap(); + } +} + /// Oracle: Store::set_party_role doc ("journaled as party.role_set") with /// {id, role, on}. #[test]