check / check (push) Waiting to run
Parties: one directory for vendors, contractors, customers, employees, members, platforms, agencies, banks, landlords (ledger-core parties.rs). W-9/W-4 facts without the number (last four only; any nine-digit run in a party field is refused, Treas. Reg. 301.6109-4). Vendor and customer records link to parties on replay; core tags every bill, invoice, payment, and reversal with its counterparty. Journal events party.*, entry.party_set; payroll lines name the employee party. MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4, parties, party, set_entry_party; draft_entry party; run_payroll employee; show party. Viewer: Parties directory and party pages, search, journal text, W-9 attention, demo data. One definition of a party's money (ledger_core::parties::activity) for MCP and viewer. 1099 amounts count payments from cash and financing accounts (a member paying for the company counts; a card doesn't; a prepayment counts when made): vendor_1099_report_with, B-19. Store: attachment links to any subject, statements, reconciliations (schema v11), with replay and projection. ledger-vault: encrypted TIN and W-9 records (ChaCha20-Poly1305, key file outside the books). Spec-blind tests for parties (core, store, MCP, viewer) and attachments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
1138 lines
39 KiB
Rust
1138 lines
39 KiB
Rust
//! Spec-derived tests for the party directory.
|
||
//!
|
||
//! Oracles: the spec block of `ledger_core::parties` (INVARIANTS 1–4), the
|
||
//! doc comments of `has_full_tin`, `check_text`, `W9Facts`/`W4Facts` and
|
||
//! their `check`, `Role::gets_1099`, `Party::label`/`all_roles`,
|
||
//! `name_key`, and the `Ledger` party methods (`add_party`,
|
||
//! `update_party`, `set_party_role`, `set_party_w9`/`w4`,
|
||
//! `set_entry_party`, `set_entry_vendor`, the vendor/customer auto-link,
|
||
//! `validate_payroll`), plus `docs/parties-and-attachments.md` "Parties".
|
||
//! Every expected value is worked by hand from those texts; nothing is read
|
||
//! back from the code under test and then asserted.
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::str::FromStr;
|
||
|
||
use chrono::NaiveDate;
|
||
use ledger_core::parties::{check_text, has_full_tin, name_key};
|
||
use ledger_core::{
|
||
AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId,
|
||
PayrollLine, Role, Side, W4Facts, W9Facts,
|
||
};
|
||
use proptest::prelude::*;
|
||
use rust_decimal::Decimal;
|
||
|
||
// ---------------------------------------------------------------- helpers
|
||
|
||
fn d(s: &str) -> Decimal {
|
||
Decimal::from_str(s).expect("decimal literal")
|
||
}
|
||
|
||
fn usd(s: &str) -> Money {
|
||
Money::new(d(s), Currency::USD)
|
||
}
|
||
|
||
fn date(y: i32, m: u32, day: u32) -> NaiveDate {
|
||
NaiveDate::from_ymd_opt(y, m, day).expect("valid date")
|
||
}
|
||
|
||
struct Fx {
|
||
cash: AccountId,
|
||
posted: EntryId,
|
||
draft: EntryId,
|
||
}
|
||
|
||
/// A USD ledger with cash, capital, an expense, one posted entry
|
||
/// (Dr 1000 / Cr 3000 $100.00) and one draft entry.
|
||
fn fixture() -> (Ledger, Fx) {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let cash = l
|
||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let capital = l
|
||
.add_account("3000", "Owner Capital", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
let expense = l
|
||
.add_account(
|
||
"6050",
|
||
"Domain & Hosting",
|
||
AccountType::Expense,
|
||
None,
|
||
false,
|
||
)
|
||
.unwrap();
|
||
let posted = l.try_create_entry(date(2026, 1, 1), "Opening").unwrap();
|
||
l.add_line(posted, cash, Side::Debit, usd("100.00"))
|
||
.unwrap();
|
||
l.add_line(posted, capital, Side::Credit, usd("100.00"))
|
||
.unwrap();
|
||
l.post_entry(posted).unwrap();
|
||
let draft = l.try_create_entry(date(2026, 1, 2), "Hosting").unwrap();
|
||
l.add_line(draft, expense, Side::Debit, usd("12.00"))
|
||
.unwrap();
|
||
l.add_line(draft, cash, Side::Credit, usd("12.00")).unwrap();
|
||
(
|
||
l,
|
||
Fx {
|
||
cash,
|
||
posted,
|
||
draft,
|
||
},
|
||
)
|
||
}
|
||
|
||
fn w9(last4: &str) -> W9Facts {
|
||
W9Facts {
|
||
received: date(2026, 3, 1),
|
||
entity_type: "individual/sole proprietor".to_string(),
|
||
exempt_payee_code: None,
|
||
fatca_code: None,
|
||
tin_last4: last4.to_string(),
|
||
kept_at: "wanderbooks vault".to_string(),
|
||
vault_record: None,
|
||
}
|
||
}
|
||
|
||
fn w4(state: &str) -> W4Facts {
|
||
W4Facts {
|
||
received: date(2026, 3, 1),
|
||
state: state.to_string(),
|
||
kept_at: "CPA portal".to_string(),
|
||
}
|
||
}
|
||
|
||
fn party(l: &Ledger, id: PartyId) -> &Party {
|
||
l.get_party(id).expect("party exists")
|
||
}
|
||
|
||
/// Run `f` on `l`; it must fail and leave `l` equal to before.
|
||
fn refused<T: std::fmt::Debug, E>(
|
||
l: &mut Ledger,
|
||
what: &str,
|
||
f: impl FnOnce(&mut Ledger) -> Result<T, E>,
|
||
) {
|
||
let before = l.clone();
|
||
let r = f(l);
|
||
assert!(r.is_err(), "{what}: expected a refusal, got {:?}", r.ok());
|
||
assert!(*l == before, "{what}: a refusal changed the ledger");
|
||
}
|
||
|
||
fn bare_party(name: &str) -> Party {
|
||
Party {
|
||
id: 1,
|
||
name: name.to_string(),
|
||
display_name: None,
|
||
roles: BTreeSet::new(),
|
||
website: None,
|
||
address: None,
|
||
notes: None,
|
||
archived: false,
|
||
w9: None,
|
||
w4: None,
|
||
vendor: None,
|
||
customer: None,
|
||
}
|
||
}
|
||
|
||
fn pay_line(name: &str, employee: Option<PartyId>) -> PayrollLine {
|
||
PayrollLine {
|
||
employee_name: name.to_string(),
|
||
employee,
|
||
gross_pay: d("1000.00"),
|
||
federal_withholding: d("100.00"),
|
||
state_withholding: d("0"),
|
||
ss_employee: d("62.00"),
|
||
medicare_employee: d("14.50"),
|
||
ss_employer: d("62.00"),
|
||
medicare_employer: d("14.50"),
|
||
other_deductions: d("0"),
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------- has_full_tin truth table
|
||
|
||
/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most one
|
||
/// space or hyphen between neighbors") and INVARIANT 1.
|
||
#[test]
|
||
fn has_full_tin_truth_table() {
|
||
let cases: &[(&str, bool)] = &[
|
||
("123456789", true),
|
||
("123-45-6789", true), // SSN
|
||
("12-3456789", true), // EIN
|
||
("1234 5678 9", true), // single spaces
|
||
("123 45-6789", true), // mixed single separators
|
||
("1-2-3-4-5-6-7-8-9", true),
|
||
("TIN 123456789 on file", true), // inside longer text
|
||
("abc123456789def", true),
|
||
("12 3456789", false), // two spaces break the run: 2 + 7 digits
|
||
("12--3456789", false), // two hyphens
|
||
("12 -3456789", false), // space and hyphen between neighbors
|
||
("12\t3456789", false), // a tab is not a space
|
||
("12345678", false), // eight digits
|
||
("123-45-678", false),
|
||
("1234", false),
|
||
("", false),
|
||
("Acme LLC", false),
|
||
("Suite 100, 84101", false), // 3 + 5 digits, separated by ", "
|
||
("-123456789-", true),
|
||
];
|
||
for (text, want) in cases {
|
||
assert_eq!(has_full_tin(text), *want, "has_full_tin({text:?})");
|
||
}
|
||
}
|
||
|
||
/// Oracle: INVARIANT 1 says "any run of nine digits"; a run of ten digits
|
||
/// holds a run of nine.
|
||
#[test]
|
||
fn has_full_tin_ten_digits_hold_nine() {
|
||
assert!(has_full_tin("1234567890"));
|
||
assert!(has_full_tin("801-555-12345"));
|
||
}
|
||
|
||
/// Oracle: INVARIANT 1 ("No party field holds a full taxpayer
|
||
/// identification number"). Full-width digits are Unicode decimal digits;
|
||
/// a TIN typed with them is still a full TIN.
|
||
#[test]
|
||
fn has_full_tin_unicode_digits() {
|
||
assert!(has_full_tin("123456789"));
|
||
assert!(has_full_tin("١٢٣٤٥٦٧٨٩")); // Arabic-Indic digits
|
||
}
|
||
|
||
// ---------------------------------------------------------------- check_text
|
||
|
||
/// Oracle: `check_text` doc: not a full TIN, at most 500 characters, no
|
||
/// control characters.
|
||
#[test]
|
||
fn check_text_rules() {
|
||
assert!(check_text("notes", "Pays by ACH, net 30").is_ok());
|
||
assert!(check_text("notes", "").is_ok());
|
||
assert!(check_text("notes", &"a".repeat(500)).is_ok());
|
||
assert!(check_text("notes", &"a".repeat(501)).is_err());
|
||
// Characters, not bytes: 500 two-byte characters are 500 characters.
|
||
assert!(check_text("notes", &"é".repeat(500)).is_ok());
|
||
assert!(check_text("notes", &"é".repeat(501)).is_err());
|
||
assert!(check_text("notes", "nul\0").is_err());
|
||
assert!(check_text("notes", "EIN 12-3456789").is_err());
|
||
assert!(check_text("notes", "last four 6789").is_ok());
|
||
let why = check_text("notes", "123456789").unwrap_err();
|
||
assert!(!why.is_empty(), "a refusal says why");
|
||
}
|
||
|
||
// ---------------------------------------------------------------- facts
|
||
|
||
/// Oracle: `W9Facts.tin_last4` doc ("exactly four digits") and
|
||
/// INVARIANT 1.
|
||
#[test]
|
||
fn w9_check_tin_last4_is_exactly_four_digits() {
|
||
assert!(w9("6789").check().is_ok());
|
||
assert!(w9("0000").check().is_ok());
|
||
for bad in ["", "678", "56789", "67a9", "67 9", "123456789", "6789 "] {
|
||
assert!(w9(bad).check().is_err(), "tin_last4 {bad:?}");
|
||
}
|
||
}
|
||
|
||
/// Oracle: INVARIANT 1: no party field (the W-9 facts' text included)
|
||
/// holds a full TIN.
|
||
#[test]
|
||
fn w9_check_refuses_full_tin_in_any_text() {
|
||
let mut f = w9("6789");
|
||
f.entity_type = "LLC 12-3456789".to_string();
|
||
assert!(f.check().is_err());
|
||
let mut f = w9("6789");
|
||
f.kept_at = "drawer 123456789".to_string();
|
||
assert!(f.check().is_err());
|
||
let mut f = w9("6789");
|
||
f.exempt_payee_code = Some("123-45-6789".to_string());
|
||
assert!(f.check().is_err());
|
||
let mut f = w9("6789");
|
||
f.fatca_code = Some("123456789".to_string());
|
||
assert!(f.check().is_err());
|
||
let mut f = w9("6789");
|
||
f.exempt_payee_code = Some("5".to_string());
|
||
f.fatca_code = Some("A".to_string());
|
||
assert!(f.check().is_ok());
|
||
}
|
||
|
||
/// Oracle: `W4Facts.state` doc ("two letters") and INVARIANT 1.
|
||
#[test]
|
||
fn w4_check_state_two_letters() {
|
||
assert!(w4("UT").check().is_ok());
|
||
assert!(w4("CA").check().is_ok());
|
||
for bad in ["", "U", "UTA", "U1", "12", "U "] {
|
||
assert!(w4(bad).check().is_err(), "state {bad:?}");
|
||
}
|
||
let mut f = w4("UT");
|
||
f.kept_at = "SSN 123-45-6789".to_string();
|
||
assert!(f.check().is_err());
|
||
}
|
||
|
||
// ---------------------------------------------------------------- roles
|
||
|
||
/// Oracle: `Role::gets_1099` doc and INVARIANT 2 ("a 1099 role
|
||
/// (contractor or landlord)").
|
||
#[test]
|
||
fn gets_1099_is_contractor_and_landlord_only() {
|
||
for r in Role::ALL {
|
||
let want = matches!(r, Role::Contractor | Role::Landlord);
|
||
assert_eq!(r.gets_1099(), want, "{r:?}");
|
||
}
|
||
}
|
||
|
||
/// Oracle: `Role::ALL` ("every role"), `Role::key` (wire name, e.g.
|
||
/// `payout_platform`), `Role::parse` ("the role named key").
|
||
#[test]
|
||
fn role_keys_round_trip() {
|
||
let all: BTreeSet<Role> = Role::ALL.into_iter().collect();
|
||
assert_eq!(all.len(), 9, "nine distinct roles");
|
||
for r in [
|
||
Role::Vendor,
|
||
Role::Contractor,
|
||
Role::Customer,
|
||
Role::Employee,
|
||
Role::Member,
|
||
Role::PayoutPlatform,
|
||
Role::TaxAgency,
|
||
Role::FinancialInstitution,
|
||
Role::Landlord,
|
||
] {
|
||
assert!(all.contains(&r));
|
||
assert_eq!(Role::parse(r.key()), Some(r));
|
||
}
|
||
assert_eq!(Role::PayoutPlatform.key(), "payout_platform");
|
||
assert_eq!(Role::TaxAgency.key(), "tax_agency");
|
||
assert_eq!(Role::FinancialInstitution.key(), "financial_institution");
|
||
assert_eq!(Role::Contractor.key(), "contractor");
|
||
assert_eq!(Role::parse("boss"), None);
|
||
assert_eq!(Role::parse(""), None);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Party
|
||
|
||
/// Oracle: `Party::label` doc: the display name, else the name.
|
||
#[test]
|
||
fn label_prefers_display_name() {
|
||
let mut p = bare_party("Anthropic, PBC");
|
||
assert_eq!(p.label(), "Anthropic, PBC");
|
||
p.display_name = Some("Anthropic".to_string());
|
||
assert_eq!(p.label(), "Anthropic");
|
||
}
|
||
|
||
/// Oracle: `Party::all_roles` doc: hand roles plus vendor and customer
|
||
/// when it has those records.
|
||
#[test]
|
||
fn all_roles_adds_record_roles() {
|
||
let mut p = bare_party("Acme");
|
||
p.roles.insert(Role::Contractor);
|
||
assert_eq!(p.all_roles(), BTreeSet::from([Role::Contractor]));
|
||
p.vendor = Some(7);
|
||
assert_eq!(
|
||
p.all_roles(),
|
||
BTreeSet::from([Role::Contractor, Role::Vendor])
|
||
);
|
||
p.customer = Some(3);
|
||
assert_eq!(
|
||
p.all_roles(),
|
||
BTreeSet::from([Role::Contractor, Role::Vendor, Role::Customer])
|
||
);
|
||
p.vendor = None;
|
||
assert_eq!(
|
||
p.all_roles(),
|
||
BTreeSet::from([Role::Contractor, Role::Customer])
|
||
);
|
||
}
|
||
|
||
/// Oracle: `name_key` doc: case and surrounding space ignored (inner
|
||
/// space is not surrounding space).
|
||
#[test]
|
||
fn name_key_ignores_case_and_surrounding_space_only() {
|
||
assert_eq!(name_key("Acme"), name_key(" ACME "));
|
||
assert_eq!(name_key("acme llc"), name_key("Acme LLC\t"));
|
||
assert_ne!(name_key("Acme"), name_key("Ac me"));
|
||
assert_ne!(name_key("Acme"), name_key("Acme Inc"));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- add_party
|
||
|
||
/// Oracle: add_party GOAL; parties() "every party, in id order";
|
||
/// party_by_name ignoring case and surrounding space.
|
||
#[test]
|
||
fn add_party_creates_a_record() {
|
||
let (mut l, _) = fixture();
|
||
let p = l
|
||
.add_party("Internal Revenue Service", &[Role::TaxAgency])
|
||
.unwrap();
|
||
let got = party(&l, p);
|
||
assert_eq!(got.id, p);
|
||
assert_eq!(got.name, "Internal Revenue Service");
|
||
assert_eq!(got.roles, BTreeSet::from([Role::TaxAgency]));
|
||
assert!(!got.archived);
|
||
assert_eq!(got.w9, None);
|
||
assert_eq!(got.w4, None);
|
||
assert_eq!(got.vendor, None);
|
||
assert_eq!(got.customer, None);
|
||
assert_eq!(
|
||
l.party_by_name(" internal revenue SERVICE ").map(|x| x.id),
|
||
Some(p)
|
||
);
|
||
let q = l
|
||
.add_party("Utah State Tax Commission", &[Role::TaxAgency])
|
||
.unwrap();
|
||
assert_ne!(p, q);
|
||
let ids: Vec<PartyId> = l.parties().map(|x| x.id).collect();
|
||
let mut sorted = ids.clone();
|
||
sorted.sort();
|
||
assert_eq!(ids, sorted, "parties() is in id order");
|
||
assert!(ids.contains(&p) && ids.contains(&q));
|
||
}
|
||
|
||
/// Oracle: add_party SCENARIO 1.
|
||
#[test]
|
||
fn add_party_refuses_blank_tin_or_taken_names() {
|
||
let (mut l, _) = fixture();
|
||
l.add_party("Apple", &[Role::PayoutPlatform]).unwrap();
|
||
refused(&mut l, "blank", |l| l.add_party("", &[]));
|
||
refused(&mut l, "spaces", |l| l.add_party(" ", &[]));
|
||
refused(&mut l, "TIN", |l| l.add_party("Bob 123-45-6789", &[]));
|
||
refused(&mut l, "same name", |l| l.add_party("Apple", &[]));
|
||
refused(&mut l, "case", |l| l.add_party("APPLE", &[]));
|
||
refused(&mut l, "space", |l| l.add_party(" apple ", &[]));
|
||
assert!(l.check_add_party("Apple", &[]).is_err());
|
||
assert!(l.check_add_party("Google", &[]).is_ok());
|
||
}
|
||
|
||
/// Oracle: add_party SCENARIO 2.
|
||
#[test]
|
||
fn add_party_refuses_vendor_and_customer_roles() {
|
||
let (mut l, _) = fixture();
|
||
refused(&mut l, "vendor", |l| l.add_party("Acme", &[Role::Vendor]));
|
||
refused(&mut l, "customer", |l| {
|
||
l.add_party("Acme", &[Role::Member, Role::Customer])
|
||
});
|
||
assert!(l.check_add_party("Acme", &[Role::Vendor]).is_err());
|
||
}
|
||
|
||
/// Oracle: INVARIANT 3 ("among parties not archived"): an archived
|
||
/// party's name is free again.
|
||
#[test]
|
||
fn add_party_may_reuse_an_archived_name() {
|
||
let (mut l, _) = fixture();
|
||
let old = l.add_party("Landlord Co", &[Role::Landlord]).unwrap();
|
||
l.update_party(old, PartyField::Archived(true)).unwrap();
|
||
let new = l.add_party("landlord co", &[Role::Landlord]).unwrap();
|
||
assert_ne!(old, new);
|
||
assert_eq!(l.party_by_name("Landlord Co").map(|p| p.id), Some(new));
|
||
assert!(party(&l, old).archived);
|
||
}
|
||
|
||
/// Oracle: check_add_party is "the validation behind add_party": it
|
||
/// agrees with add_party and changes nothing.
|
||
#[test]
|
||
fn check_add_party_agrees_with_add_party() {
|
||
let (mut l, _) = fixture();
|
||
l.add_party("Taken", &[]).unwrap();
|
||
for (name, roles) in [
|
||
("Taken", vec![]),
|
||
("Fresh", vec![Role::Member]),
|
||
("", vec![]),
|
||
("Fresh2", vec![Role::Customer]),
|
||
("x 123456789", vec![]),
|
||
] {
|
||
let before = l.clone();
|
||
let check = l.check_add_party(name, &roles);
|
||
assert!(l == before);
|
||
let mut trial = l.clone();
|
||
let real = trial.add_party(name, &roles);
|
||
assert_eq!(check.is_ok(), real.is_ok(), "{name:?}");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- update_party
|
||
|
||
/// Oracle: PartyField docs; update_party "change one of a party's facts".
|
||
#[test]
|
||
fn update_party_changes_one_fact() {
|
||
let (mut l, _) = fixture();
|
||
let p = l.add_party("Anthropic, PBC", &[Role::Contractor]).unwrap();
|
||
l.update_party(p, PartyField::DisplayName(Some("Anthropic".into())))
|
||
.unwrap();
|
||
assert_eq!(party(&l, p).display_name.as_deref(), Some("Anthropic"));
|
||
assert_eq!(party(&l, p).label(), "Anthropic");
|
||
l.update_party(p, PartyField::Website(Some("https://anthropic.com".into())))
|
||
.unwrap();
|
||
l.update_party(
|
||
p,
|
||
PartyField::Address(Some("548 Market St, San Francisco".into())),
|
||
)
|
||
.unwrap();
|
||
l.update_party(p, PartyField::Notes(Some("API billing".into())))
|
||
.unwrap();
|
||
let got = party(&l, p);
|
||
assert_eq!(got.website.as_deref(), Some("https://anthropic.com"));
|
||
assert_eq!(got.address.as_deref(), Some("548 Market St, San Francisco"));
|
||
assert_eq!(got.notes.as_deref(), Some("API billing"));
|
||
assert_eq!(got.name, "Anthropic, PBC");
|
||
l.update_party(p, PartyField::DisplayName(None)).unwrap();
|
||
assert_eq!(party(&l, p).display_name, None);
|
||
assert_eq!(party(&l, p).label(), "Anthropic, PBC");
|
||
l.update_party(p, PartyField::Website(None)).unwrap();
|
||
assert_eq!(party(&l, p).website, None);
|
||
l.update_party(p, PartyField::Name("Anthropic PBC".into()))
|
||
.unwrap();
|
||
assert_eq!(party(&l, p).name, "Anthropic PBC");
|
||
assert_eq!(l.party_by_name("anthropic pbc").map(|x| x.id), Some(p));
|
||
assert!(l.party_by_name("Anthropic, PBC").is_none());
|
||
l.update_party(p, PartyField::Archived(true)).unwrap();
|
||
assert!(party(&l, p).archived);
|
||
assert!(
|
||
l.party_by_name("Anthropic PBC").is_none(),
|
||
"party_by_name skips archived"
|
||
);
|
||
l.update_party(p, PartyField::Archived(false)).unwrap();
|
||
assert!(!party(&l, p).archived);
|
||
}
|
||
|
||
/// Oracle: update_party SCENARIO 1.
|
||
#[test]
|
||
fn update_party_refusals_change_nothing() {
|
||
let (mut l, _) = fixture();
|
||
let a = l.add_party("Alpha", &[]).unwrap();
|
||
l.add_party("Beta", &[]).unwrap();
|
||
let unknown = l.next_party_id() + 10;
|
||
refused(&mut l, "unknown", |l| {
|
||
l.update_party(unknown, PartyField::Notes(None))
|
||
});
|
||
refused(&mut l, "TIN in notes", |l| {
|
||
l.update_party(a, PartyField::Notes(Some("SSN 123-45-6789".into())))
|
||
});
|
||
refused(&mut l, "TIN in website", |l| {
|
||
l.update_party(a, PartyField::Website(Some("x.com/123456789".into())))
|
||
});
|
||
refused(&mut l, "TIN in address", |l| {
|
||
l.update_party(a, PartyField::Address(Some("12-3456789 Main".into())))
|
||
});
|
||
refused(&mut l, "TIN in display name", |l| {
|
||
l.update_party(a, PartyField::DisplayName(Some("123456789".into())))
|
||
});
|
||
refused(&mut l, "TIN in name", |l| {
|
||
l.update_party(a, PartyField::Name("Alpha 123456789".into()))
|
||
});
|
||
refused(&mut l, "too long", |l| {
|
||
l.update_party(a, PartyField::Notes(Some("n".repeat(501))))
|
||
});
|
||
refused(&mut l, "control char", |l| {
|
||
l.update_party(a, PartyField::Notes(Some("a\u{7}b".into())))
|
||
});
|
||
refused(&mut l, "blank name", |l| {
|
||
l.update_party(a, PartyField::Name(" ".into()))
|
||
});
|
||
refused(&mut l, "taken name", |l| {
|
||
l.update_party(a, PartyField::Name("Beta".into()))
|
||
});
|
||
refused(&mut l, "taken name, case/space", |l| {
|
||
l.update_party(a, PartyField::Name(" BETA ".into()))
|
||
});
|
||
assert!(
|
||
l.check_update_party(a, &PartyField::Name("beta".into()))
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
l.check_update_party(a, &PartyField::Name("Gamma".into()))
|
||
.is_ok()
|
||
);
|
||
}
|
||
|
||
/// Oracle: INVARIANT 3: un-archiving a party whose name another live party
|
||
/// now holds would make two live parties share a name.
|
||
#[test]
|
||
fn unarchiving_into_a_taken_name_is_refused() {
|
||
let (mut l, _) = fixture();
|
||
let old = l.add_party("Acme", &[]).unwrap();
|
||
l.update_party(old, PartyField::Archived(true)).unwrap();
|
||
l.add_party("ACME", &[]).unwrap();
|
||
refused(&mut l, "unarchive", |l| {
|
||
l.update_party(old, PartyField::Archived(false))
|
||
});
|
||
}
|
||
|
||
/// Oracle: INVARIANT 3 counts only parties not archived: renaming onto an
|
||
/// archived party's name is fine.
|
||
#[test]
|
||
fn rename_onto_archived_name_is_allowed() {
|
||
let (mut l, _) = fixture();
|
||
let old = l.add_party("Acme", &[]).unwrap();
|
||
l.update_party(old, PartyField::Archived(true)).unwrap();
|
||
let b = l.add_party("Beta", &[]).unwrap();
|
||
l.update_party(b, PartyField::Name("acme".into())).unwrap();
|
||
assert_eq!(l.party_by_name("Acme").map(|p| p.id), Some(b));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- roles + facts
|
||
|
||
/// Oracle: set_party_role SCENARIO 1.
|
||
#[test]
|
||
fn set_party_role_refuses_record_roles() {
|
||
let (mut l, _) = fixture();
|
||
let p = l.add_party("Acme", &[]).unwrap();
|
||
for on in [true, false] {
|
||
refused(&mut l, "vendor", |l| l.set_party_role(p, Role::Vendor, on));
|
||
refused(&mut l, "customer", |l| {
|
||
l.set_party_role(p, Role::Customer, on)
|
||
});
|
||
}
|
||
let unknown = l.next_party_id() + 5;
|
||
refused(&mut l, "unknown party", |l| {
|
||
l.set_party_role(unknown, Role::Member, true)
|
||
});
|
||
}
|
||
|
||
/// Oracle: "give a party a role, or take one away".
|
||
#[test]
|
||
fn set_party_role_gives_and_takes() {
|
||
let (mut l, _) = fixture();
|
||
let p = l.add_party("Kat", &[]).unwrap();
|
||
l.set_party_role(p, Role::Member, true).unwrap();
|
||
l.set_party_role(p, Role::Employee, true).unwrap();
|
||
assert_eq!(
|
||
party(&l, p).roles,
|
||
BTreeSet::from([Role::Member, Role::Employee])
|
||
);
|
||
l.set_party_role(p, Role::Member, false).unwrap();
|
||
assert_eq!(party(&l, p).roles, BTreeSet::from([Role::Employee]));
|
||
}
|
||
|
||
/// Oracle: INVARIANT 2 and set_party_w9 SCENARIO 1: W-9 facts need a 1099
|
||
/// role.
|
||
#[test]
|
||
fn w9_needs_a_1099_role() {
|
||
let (mut l, _) = fixture();
|
||
let p = l.add_party("Dev Contractor", &[Role::Member]).unwrap();
|
||
refused(&mut l, "no 1099 role", |l| {
|
||
l.set_party_w9(p, Some(w9("6789")))
|
||
});
|
||
assert!(l.check_set_party_w9(p, Some(&w9("6789"))).is_err());
|
||
l.set_party_role(p, Role::Contractor, true).unwrap();
|
||
refused(&mut l, "bad facts", |l| l.set_party_w9(p, Some(w9("67"))));
|
||
l.set_party_w9(p, Some(w9("6789"))).unwrap();
|
||
assert_eq!(party(&l, p).w9, Some(w9("6789")));
|
||
let unknown = l.next_party_id() + 3;
|
||
refused(&mut l, "unknown", |l| {
|
||
l.set_party_w9(unknown, Some(w9("6789")))
|
||
});
|
||
l.set_party_w9(p, None).unwrap();
|
||
assert_eq!(party(&l, p).w9, None);
|
||
|
||
let landlord = l.add_party("Office Landlord", &[Role::Landlord]).unwrap();
|
||
l.set_party_w9(landlord, Some(w9("1111"))).unwrap();
|
||
assert_eq!(party(&l, landlord).w9, Some(w9("1111")));
|
||
}
|
||
|
||
/// Oracle: set_party_role SCENARIO 2 (and INVARIANT 2).
|
||
#[test]
|
||
fn removing_the_last_1099_role_with_w9_is_refused() {
|
||
let (mut l, _) = fixture();
|
||
let p = l
|
||
.add_party("Both", &[Role::Contractor, Role::Landlord])
|
||
.unwrap();
|
||
l.set_party_w9(p, Some(w9("6789"))).unwrap();
|
||
// Another 1099 role remains: allowed.
|
||
l.set_party_role(p, Role::Contractor, false).unwrap();
|
||
// The last one: refused.
|
||
refused(&mut l, "last 1099 role", |l| {
|
||
l.set_party_role(p, Role::Landlord, false)
|
||
});
|
||
assert!(l.check_set_party_role(p, Role::Landlord, false).is_err());
|
||
// Clear the facts first, then it goes.
|
||
l.set_party_w9(p, None).unwrap();
|
||
l.set_party_role(p, Role::Landlord, false).unwrap();
|
||
assert!(party(&l, p).roles.is_empty());
|
||
}
|
||
|
||
/// Oracle: INVARIANT 2 (W-4 facts need employee) and set_party_role
|
||
/// SCENARIO 2.
|
||
#[test]
|
||
fn w4_needs_employee() {
|
||
let (mut l, _) = fixture();
|
||
let p = l.add_party("Alice", &[Role::Contractor]).unwrap();
|
||
refused(&mut l, "no employee role", |l| {
|
||
l.set_party_w4(p, Some(w4("UT")))
|
||
});
|
||
assert!(l.check_set_party_w4(p, Some(&w4("UT"))).is_err());
|
||
l.set_party_role(p, Role::Employee, true).unwrap();
|
||
refused(&mut l, "bad state", |l| l.set_party_w4(p, Some(w4("Utah"))));
|
||
l.set_party_w4(p, Some(w4("UT"))).unwrap();
|
||
assert_eq!(party(&l, p).w4, Some(w4("UT")));
|
||
refused(&mut l, "drop employee with W-4", |l| {
|
||
l.set_party_role(p, Role::Employee, false)
|
||
});
|
||
l.set_party_w4(p, None).unwrap();
|
||
l.set_party_role(p, Role::Employee, false).unwrap();
|
||
assert_eq!(party(&l, p).roles, BTreeSet::from([Role::Contractor]));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- auto-link
|
||
|
||
/// Oracle: the auto-link doc ("the party not archived with that name ...
|
||
/// that has no such record yet, else a new party"); party_of_vendor /
|
||
/// party_of_customer; all_roles.
|
||
#[test]
|
||
fn vendor_and_customer_with_the_same_name_share_one_party() {
|
||
let (mut l, _) = fixture();
|
||
let v = l.add_vendor("Acme").unwrap();
|
||
let pv = l.party_of_vendor(v).expect("vendor has a party");
|
||
assert_eq!(party(&l, pv).vendor, Some(v));
|
||
assert!(party(&l, pv).all_roles().contains(&Role::Vendor));
|
||
let c = l.add_customer(" acme ").unwrap();
|
||
let pc = l.party_of_customer(c).expect("customer has a party");
|
||
assert_eq!(pc, pv, "same name: one party");
|
||
let got = party(&l, pv);
|
||
assert_eq!(got.vendor, Some(v));
|
||
assert_eq!(got.customer, Some(c));
|
||
assert!(got.all_roles().contains(&Role::Customer));
|
||
assert!(got.all_roles().contains(&Role::Vendor));
|
||
assert_eq!(l.parties().count(), 1);
|
||
}
|
||
|
||
/// Oracle: the auto-link doc: a hand-added party with the name and no
|
||
/// vendor record absorbs the new vendor.
|
||
#[test]
|
||
fn hand_added_party_absorbs_vendor_ignoring_case_and_space() {
|
||
let (mut l, _) = fixture();
|
||
let p = l.add_party("Gamma Design", &[Role::Contractor]).unwrap();
|
||
let v = l.add_vendor(" GAMMA design ").unwrap();
|
||
assert_eq!(l.party_of_vendor(v), Some(p));
|
||
assert_eq!(party(&l, p).vendor, Some(v));
|
||
assert_eq!(
|
||
party(&l, p).all_roles(),
|
||
BTreeSet::from([Role::Contractor, Role::Vendor])
|
||
);
|
||
}
|
||
|
||
/// Oracle: the auto-link doc ("not archived").
|
||
#[test]
|
||
fn archived_parties_do_not_absorb() {
|
||
let (mut l, _) = fixture();
|
||
let p = l.add_party("Beta", &[]).unwrap();
|
||
l.update_party(p, PartyField::Archived(true)).unwrap();
|
||
let v = l.add_vendor("Beta").unwrap();
|
||
let pv = l.party_of_vendor(v).unwrap();
|
||
assert_ne!(pv, p);
|
||
assert_eq!(party(&l, p).vendor, None);
|
||
assert!(!party(&l, pv).archived);
|
||
}
|
||
|
||
/// Oracle: the auto-link doc ("that has no such record yet, else a new
|
||
/// party").
|
||
#[test]
|
||
fn a_second_vendor_with_the_same_name_gets_a_new_party() {
|
||
let (mut l, _) = fixture();
|
||
let v1 = l.add_vendor("Delta").unwrap();
|
||
let v2 = l.add_vendor("Delta").unwrap();
|
||
assert_ne!(v1, v2);
|
||
let p1 = l.party_of_vendor(v1).unwrap();
|
||
let p2 = l.party_of_vendor(v2).unwrap();
|
||
assert_ne!(p1, p2);
|
||
assert_eq!(party(&l, p1).vendor, Some(v1));
|
||
assert_eq!(party(&l, p2).vendor, Some(v2));
|
||
assert_eq!(
|
||
party(&l, p2).name,
|
||
"Delta (2)",
|
||
"INVARIANT 3: names stay unique"
|
||
);
|
||
}
|
||
|
||
/// Oracle: INVARIANT 3 ("Names are unique ... among parties not
|
||
/// archived") applied after the auto-link's "else a new party".
|
||
#[test]
|
||
fn invariant_3_holds_after_a_second_same_name_vendor() {
|
||
let (mut l, _) = fixture();
|
||
l.add_vendor("Delta").unwrap();
|
||
let r = l.add_vendor("Delta");
|
||
if r.is_ok() {
|
||
let live: Vec<String> = l
|
||
.parties()
|
||
.filter(|p| !p.archived)
|
||
.map(|p| name_key(&p.name))
|
||
.collect();
|
||
let set: BTreeSet<&String> = live.iter().collect();
|
||
assert_eq!(set.len(), live.len(), "live names unique: {live:?}");
|
||
}
|
||
}
|
||
|
||
/// Oracle: INVARIANT 4 after mixed auto-linking.
|
||
#[test]
|
||
fn every_record_links_to_exactly_one_party() {
|
||
let (mut l, _) = fixture();
|
||
l.add_party("Echo", &[]).unwrap();
|
||
for n in ["Echo", "Foxtrot", "echo", "Golf"] {
|
||
l.add_vendor(n).unwrap();
|
||
}
|
||
for n in ["Golf", "Hotel", "ECHO"] {
|
||
l.add_customer(n).unwrap();
|
||
}
|
||
check_invariants(&l);
|
||
}
|
||
|
||
/// Oracle: check_text doc ("no control characters except newline"):
|
||
/// addresses run several lines; a tab is refused.
|
||
#[test]
|
||
fn check_text_newline_yes_tab_no() {
|
||
assert!(check_text("address", "line one\nline two").is_ok());
|
||
assert!(check_text("notes", "tab\there").is_err());
|
||
}
|
||
|
||
/// Oracle: INVARIANT 1 ("No party field holds a full taxpayer
|
||
/// identification number: any run of nine digits ... is refused"): the
|
||
/// party the auto-link makes for a vendor is a party; its name is a party
|
||
/// field.
|
||
#[test]
|
||
fn add_vendor_name_with_full_tin_is_refused() {
|
||
let (mut l, _) = fixture();
|
||
let before = l.clone();
|
||
if l.add_vendor("Bob 123-45-6789").is_ok() {
|
||
assert!(
|
||
l.parties().all(|p| !has_full_tin(&p.name)),
|
||
"a party holds a full TIN"
|
||
);
|
||
} else {
|
||
assert!(l == before);
|
||
}
|
||
let mut l = before;
|
||
if l.add_customer("Carol 123456789").is_ok() {
|
||
assert!(l.parties().all(|p| !has_full_tin(&p.name)));
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- entry tags
|
||
|
||
/// Oracle: set_entry_party doc: a party with a vendor record also tags the
|
||
/// entry's vendor; metadata, so posted entries may be tagged and no amount
|
||
/// changes.
|
||
#[test]
|
||
fn set_entry_party_tags_the_vendor() {
|
||
let (mut l, fx) = fixture();
|
||
let v = l.add_vendor("Hosting Co").unwrap();
|
||
let p = l.party_of_vendor(v).unwrap();
|
||
let cash_before = l.account_balance(fx.cash).unwrap();
|
||
l.set_entry_party(fx.posted, Some(p)).unwrap();
|
||
assert_eq!(l.entry_party(fx.posted), Some(p));
|
||
assert_eq!(l.entry_vendor(fx.posted), Some(v));
|
||
assert_eq!(l.account_balance(fx.cash).unwrap(), cash_before);
|
||
l.set_entry_party(fx.draft, Some(p)).unwrap();
|
||
assert_eq!(l.entry_vendor(fx.draft), Some(v));
|
||
l.set_entry_party(fx.posted, None).unwrap();
|
||
assert_eq!(l.entry_party(fx.posted), None);
|
||
}
|
||
|
||
/// Oracle: set_entry_party doc ("as set_entry_vendor does the other way
|
||
/// round") and the task's "set_entry_vendor now also tags the party".
|
||
#[test]
|
||
fn set_entry_vendor_tags_the_party() {
|
||
let (mut l, fx) = fixture();
|
||
let v = l.add_vendor("Registrar").unwrap();
|
||
let p = l.party_of_vendor(v).unwrap();
|
||
l.set_entry_vendor(fx.posted, Some(v)).unwrap();
|
||
assert_eq!(l.entry_vendor(fx.posted), Some(v));
|
||
assert_eq!(l.entry_party(fx.posted), Some(p));
|
||
}
|
||
|
||
/// Oracle: docs "an entry has one counterparty": retagging an entry with a
|
||
/// party that has no vendor record leaves no other party's vendor tag.
|
||
#[test]
|
||
fn retagging_to_a_party_without_vendor_drops_the_old_vendor() {
|
||
let (mut l, fx) = fixture();
|
||
let v = l.add_vendor("Old Vendor").unwrap();
|
||
let irs = l.add_party("IRS", &[Role::TaxAgency]).unwrap();
|
||
l.set_entry_vendor(fx.posted, Some(v)).unwrap();
|
||
l.set_entry_party(fx.posted, Some(irs)).unwrap();
|
||
assert_eq!(l.entry_party(fx.posted), Some(irs));
|
||
assert_eq!(l.entry_vendor(fx.posted), None);
|
||
}
|
||
|
||
/// Oracle: set_entry_vendor SCENARIO 1 and "refusals change nothing".
|
||
#[test]
|
||
fn entry_tag_refusals() {
|
||
let (mut l, fx) = fixture();
|
||
let p = l.add_party("Someone", &[]).unwrap();
|
||
let missing_entry = l.next_entry_id() + 9;
|
||
let missing_party = l.next_party_id() + 9;
|
||
refused(&mut l, "unknown entry", |l| {
|
||
l.set_entry_party(missing_entry, Some(p))
|
||
});
|
||
refused(&mut l, "unknown party", |l| {
|
||
l.set_entry_party(fx.posted, Some(missing_party))
|
||
});
|
||
refused(&mut l, "unknown vendor", |l| {
|
||
l.set_entry_vendor(fx.posted, Some(9999))
|
||
});
|
||
refused(&mut l, "unknown entry (vendor)", |l| {
|
||
l.set_entry_vendor(missing_entry, None)
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- payroll
|
||
|
||
/// Oracle: validate_payroll: "a line naming an employee needs a party with
|
||
/// the employee role"; payroll.rs: runs from before carry the name only.
|
||
#[test]
|
||
fn payroll_employee_must_have_the_employee_role() {
|
||
let (mut l, fx) = fixture();
|
||
let alice = l.add_party("Alice", &[Role::Employee]).unwrap();
|
||
let bob = l.add_party("Bob", &[Role::Contractor]).unwrap();
|
||
let unknown = l.next_party_id() + 4;
|
||
let day = date(2026, 2, 27);
|
||
assert!(
|
||
l.validate_payroll(day, &[pay_line("Alice", Some(alice))], fx.cash)
|
||
.is_ok()
|
||
);
|
||
assert!(
|
||
l.validate_payroll(day, &[pay_line("Old", None)], fx.cash)
|
||
.is_ok()
|
||
);
|
||
assert!(
|
||
l.validate_payroll(day, &[pay_line("Bob", Some(bob))], fx.cash)
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
l.validate_payroll(day, &[pay_line("Ghost", Some(unknown))], fx.cash)
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
l.validate_payroll(
|
||
day,
|
||
&[pay_line("Alice", Some(alice)), pay_line("Bob", Some(bob))],
|
||
fx.cash
|
||
)
|
||
.is_err()
|
||
);
|
||
refused(&mut l, "run with non-employee", |l| {
|
||
l.run_payroll(day, vec![pay_line("Bob", Some(bob))], fx.cash)
|
||
});
|
||
let run = l
|
||
.run_payroll(day, vec![pay_line("Alice", Some(alice))], fx.cash)
|
||
.unwrap();
|
||
let rec = l.get_payroll_run(run).unwrap();
|
||
assert_eq!(rec.lines[0].employee, Some(alice));
|
||
}
|
||
|
||
// ---------------------------------------------------------------- invariants
|
||
|
||
/// INVARIANTS 1–4 plus the vendor/party tag sync, over the whole ledger.
|
||
fn check_invariants(l: &Ledger) {
|
||
let parties: Vec<&Party> = l.parties().collect();
|
||
// 1. No full TIN in any party field; tin_last4 four digits.
|
||
for p in &parties {
|
||
let mut texts = vec![p.name.clone()];
|
||
texts.extend(p.display_name.clone());
|
||
texts.extend(p.website.clone());
|
||
texts.extend(p.address.clone());
|
||
texts.extend(p.notes.clone());
|
||
if let Some(f) = &p.w9 {
|
||
texts.push(f.entity_type.clone());
|
||
texts.push(f.kept_at.clone());
|
||
texts.extend(f.exempt_payee_code.clone());
|
||
texts.extend(f.fatca_code.clone());
|
||
assert!(
|
||
f.tin_last4.len() == 4 && f.tin_last4.chars().all(|c| c.is_ascii_digit()),
|
||
"INV1 tin_last4 {:?}",
|
||
f.tin_last4
|
||
);
|
||
}
|
||
if let Some(f) = &p.w4 {
|
||
texts.push(f.state.clone());
|
||
texts.push(f.kept_at.clone());
|
||
}
|
||
for t in texts {
|
||
assert!(!has_full_tin(&t), "INV1: party {} holds {t:?}", p.id);
|
||
}
|
||
// 2.
|
||
if p.w9.is_some() {
|
||
assert!(p.roles.iter().any(|r| r.gets_1099()), "INV2 w9 on {}", p.id);
|
||
}
|
||
if p.w4.is_some() {
|
||
assert!(p.roles.contains(&Role::Employee), "INV2 w4 on {}", p.id);
|
||
}
|
||
}
|
||
// 3.
|
||
let mut live: BTreeMap<String, Vec<&Party>> = BTreeMap::new();
|
||
for p in parties.iter().filter(|p| !p.archived) {
|
||
live.entry(name_key(&p.name)).or_default().push(p);
|
||
}
|
||
for (k, group) in &live {
|
||
assert!(
|
||
group.len() == 1,
|
||
"INV3: live name {k:?} shared by {:?}",
|
||
group.iter().map(|p| p.id).collect::<Vec<_>>()
|
||
);
|
||
}
|
||
// 4.
|
||
for v in l.list_vendors() {
|
||
let owners: Vec<PartyId> = parties
|
||
.iter()
|
||
.filter(|p| p.vendor == Some(v.id))
|
||
.map(|p| p.id)
|
||
.collect();
|
||
assert_eq!(owners.len(), 1, "INV4 vendor {}", v.id);
|
||
assert_eq!(l.party_of_vendor(v.id), Some(owners[0]));
|
||
}
|
||
for c in l.list_customers() {
|
||
let owners: Vec<PartyId> = parties
|
||
.iter()
|
||
.filter(|p| p.customer == Some(c.id))
|
||
.map(|p| p.id)
|
||
.collect();
|
||
assert_eq!(owners.len(), 1, "INV4 customer {}", c.id);
|
||
assert_eq!(l.party_of_customer(c.id), Some(owners[0]));
|
||
}
|
||
// Tag sync: an entry's vendor tag belongs to its party.
|
||
for e in 0..l.next_entry_id() {
|
||
if let Some(v) = l.entry_vendor(e) {
|
||
assert_eq!(
|
||
l.entry_party(e),
|
||
l.party_of_vendor(v),
|
||
"entry {e}: vendor tag and party tag disagree"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- proptest
|
||
|
||
const NAMES: &[&str] = &[
|
||
"Acme",
|
||
" acme ",
|
||
"ACME",
|
||
"Beta",
|
||
"beta",
|
||
"Gamma",
|
||
"",
|
||
" ",
|
||
"Tin 123456789",
|
||
"Delta",
|
||
];
|
||
|
||
/// Indices into NAMES used for vendor/customer records (all of them).
|
||
const RECORD_NAMES: &[usize] = &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||
|
||
#[derive(Clone, Debug)]
|
||
enum Cmd {
|
||
AddParty(usize, Vec<Role>),
|
||
Update(usize, PartyField),
|
||
Role(usize, Role, bool),
|
||
W9(usize, Option<W9Facts>),
|
||
W4(usize, Option<W4Facts>),
|
||
EntryParty(usize, Option<usize>),
|
||
EntryVendor(usize, Option<usize>),
|
||
Vendor(usize),
|
||
Customer(usize),
|
||
}
|
||
|
||
fn role_strategy() -> impl Strategy<Value = Role> {
|
||
prop::sample::select(Role::ALL.to_vec())
|
||
}
|
||
|
||
fn text_strategy() -> impl Strategy<Value = Option<String>> {
|
||
prop::option::of(prop::sample::select(vec![
|
||
"ok".to_string(),
|
||
"SSN 123-45-6789".to_string(),
|
||
"x".repeat(501),
|
||
"bell\u{7}".to_string(),
|
||
"Suite 100".to_string(),
|
||
]))
|
||
}
|
||
|
||
fn field_strategy() -> impl Strategy<Value = PartyField> {
|
||
prop_oneof![
|
||
(0..NAMES.len()).prop_map(|i| PartyField::Name(NAMES[i].to_string())),
|
||
text_strategy().prop_map(PartyField::DisplayName),
|
||
text_strategy().prop_map(PartyField::Website),
|
||
text_strategy().prop_map(PartyField::Address),
|
||
text_strategy().prop_map(PartyField::Notes),
|
||
any::<bool>().prop_map(PartyField::Archived),
|
||
]
|
||
}
|
||
|
||
fn cmd_strategy() -> impl Strategy<Value = Cmd> {
|
||
prop_oneof![
|
||
(0..NAMES.len(), prop::collection::vec(role_strategy(), 0..3))
|
||
.prop_map(|(n, r)| Cmd::AddParty(n, r)),
|
||
(0..8usize, field_strategy()).prop_map(|(p, f)| Cmd::Update(p, f)),
|
||
(0..8usize, role_strategy(), any::<bool>()).prop_map(|(p, r, on)| Cmd::Role(p, r, on)),
|
||
(
|
||
0..8usize,
|
||
prop::option::of(prop::sample::select(vec!["6789", "67", "123456789"]))
|
||
)
|
||
.prop_map(|(p, f)| Cmd::W9(p, f.map(w9))),
|
||
(
|
||
0..8usize,
|
||
prop::option::of(prop::sample::select(vec!["UT", "Utah"]))
|
||
)
|
||
.prop_map(|(p, f)| Cmd::W4(p, f.map(w4))),
|
||
(0..3usize, prop::option::of(0..8usize)).prop_map(|(e, p)| Cmd::EntryParty(e, p)),
|
||
(0..3usize, prop::option::of(0..5usize)).prop_map(|(e, v)| Cmd::EntryVendor(e, v)),
|
||
prop::sample::select(RECORD_NAMES.to_vec()).prop_map(Cmd::Vendor),
|
||
prop::sample::select(RECORD_NAMES.to_vec()).prop_map(Cmd::Customer),
|
||
]
|
||
}
|
||
|
||
/// Apply `cmd`, returning whether it was accepted. Party and vendor
|
||
/// indices pick among existing ids (or one past them, to hit "unknown").
|
||
fn apply(l: &mut Ledger, fx: &Fx, cmd: &Cmd) -> bool {
|
||
let party_ids: Vec<PartyId> = l.parties().map(|p| p.id).collect();
|
||
let pick_party = |i: usize| -> PartyId {
|
||
party_ids
|
||
.get(i)
|
||
.copied()
|
||
.unwrap_or_else(|| l.next_party_id() + i as u64)
|
||
};
|
||
let vendor_ids: Vec<u64> = l.list_vendors().iter().map(|v| v.id).collect();
|
||
let pick_vendor = |i: usize| -> u64 { vendor_ids.get(i).copied().unwrap_or(10_000 + i as u64) };
|
||
let entries = [fx.posted, fx.draft, 9_999];
|
||
match cmd {
|
||
Cmd::AddParty(n, roles) => l.add_party(NAMES[*n], roles).is_ok(),
|
||
Cmd::Update(p, f) => l.update_party(pick_party(*p), f.clone()).is_ok(),
|
||
Cmd::Role(p, r, on) => l.set_party_role(pick_party(*p), *r, *on).is_ok(),
|
||
Cmd::W9(p, f) => l.set_party_w9(pick_party(*p), f.clone()).is_ok(),
|
||
Cmd::W4(p, f) => l.set_party_w4(pick_party(*p), f.clone()).is_ok(),
|
||
Cmd::EntryParty(e, p) => l.set_entry_party(entries[*e], p.map(pick_party)).is_ok(),
|
||
Cmd::EntryVendor(e, v) => l.set_entry_vendor(entries[*e], v.map(pick_vendor)).is_ok(),
|
||
Cmd::Vendor(n) => l.add_vendor(NAMES[*n]).is_ok(),
|
||
Cmd::Customer(n) => l.add_customer(NAMES[*n]).is_ok(),
|
||
}
|
||
}
|
||
|
||
proptest! {
|
||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||
|
||
/// Oracle: INVARIANTS 1–4 hold after every accepted command; every
|
||
/// refusal "changes nothing" (the add/update/role SCENARIOS).
|
||
#[test]
|
||
fn random_commands_keep_the_invariants(cmds in prop::collection::vec(cmd_strategy(), 1..40)) {
|
||
let (mut l, fx) = fixture();
|
||
for cmd in &cmds {
|
||
let before = l.clone();
|
||
let ok = apply(&mut l, &fx, cmd);
|
||
if ok {
|
||
check_invariants(&l);
|
||
} else {
|
||
prop_assert!(l == before, "refused {:?} changed the ledger", cmd);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Oracle: W9Facts.vault_record "SHA-256 of the vault record": a hash is
|
||
/// hex, and hex can hold nine digits in a row; that is not a TIN.
|
||
#[test]
|
||
fn a_vault_record_hash_with_nine_digits_is_accepted() {
|
||
let facts = W9Facts {
|
||
received: chrono::NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||
entity_type: "individual/sole proprietor".into(),
|
||
exempt_payee_code: None,
|
||
fatca_code: None,
|
||
tin_last4: "6789".into(),
|
||
kept_at: "wanderbooks vault".into(),
|
||
vault_record: Some(format!("123456789{}", "a".repeat(55))),
|
||
};
|
||
assert_eq!(facts.check(), Ok(()));
|
||
}
|