feat(parties): an email and default terms per side (P4b S4.2)

A customer's email is copied onto an invoice's bill-to snapshot at issue,
and a party's default terms (one as our customer, one as our vendor) are
the fallback a document's due date takes when the document itself names
none (A-13, rules.md B-39). Both are journaled as party.updated facts and
guarded by documents_v2: in books that have not declared it the write is
refused with the upgrade remedy and journaling nothing, and replay gives
the same directory back.

An email must be one plain address: one @, no spaces, no full TIN (the
party text rule already refuses nine digits). Terms are typed and already
canonical, so there is no second spelling to check. A party.updated naming
a field this version does not know is now NewerJournal, never corrupt
(B-27): an older version must say update, not damage; a known field with a
malformed value is still corrupt.
This commit is contained in:
swaits committed 2026-10-27 03:44:24 -06:00
1 parent 1fa77cff02
commit 02cb7f0254
8 files changed
+257 -12

No files matched your search

+2 -8
View File
@@ -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(
+18
View File
@@ -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(())
}
+63
View File
@@ -230,6 +230,18 @@ pub struct Party {
/// Website.
#[serde(default)]
pub website: Option<String>,
/// 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<String>,
/// 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<crate::Terms>,
/// 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<crate::Terms>,
/// Business address.
#[serde(default)]
pub address: Option<String>,
@@ -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<String>),
/// Archived (no longer dealt with) or not.
Archived(bool),
/// The email address (A-13; `None` clears it).
Email(Option<String>),
/// The terms it grants the company as a customer (A-13; `None` clears).
DefaultTermsAsCustomer(Option<crate::Terms>),
/// The terms it gives the company as a vendor (A-13; `None` clears).
DefaultTermsAsVendor(Option<crate::Terms>),
}
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
+1 -4
View File
@@ -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());
}
+3
View File
@@ -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,
+16
View File
@@ -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)?;
+49
View File
@@ -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
+105
View File
@@ -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::<ledger_core::Terms>(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::<ledger_core::Terms>(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]