check / check (push) Waiting to run
Entries may name several parties, each with an exact amount, a percent, or a share derived from the members' capital lines or [[company.members]] shares (entry.parties_set); shares always sum exactly. The 1099 worksheet counts a vendor's share. A member's party page is their partner capital statement, from the same ledger_core::k1 source as the CPA package. books.set_tin stores any party's TIN (members' SSNs for K-1s) in the vault (party.tin_set, last four in the books). The CPA package carries a K-1 member worksheet (k1-members.csv, with a members' total that ties to Schedule L) and the 1099 worksheet with full TINs, saved outside the books only. files_jointly_with notes spouses' two K-1s land on one 1040. Adversarial review fixes: reversed contributions no longer inflate K-1 contributions and distributions; a K-1 row never takes another party's TIN; TIN detection covers dot/slash/dash separators; refusals redact TINs; vendor tagging can't silently replace shared parties; reversals follow re-apportioning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
1824 lines
65 KiB
Rust
1824 lines
65 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, redact_full_tins};
|
||
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,
|
||
tin: 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 three
|
||
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
|
||
/// no-break space) 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", true), // two spaces
|
||
("12--3456789", true), // two hyphens
|
||
("12 -3456789", true), // space and hyphen between neighbors
|
||
("638.27.4915", true), // dots
|
||
("wrong one, was 638.27.4915", true),
|
||
("571/93/2846", true), // slashes
|
||
("571_93_2846", true), // underscores
|
||
("Bob 571 - 93 - 2846", true), // three separators
|
||
("571\u{2013}93\u{2013}2846", true), // en dashes
|
||
("571\u{a0}93\u{a0}2846", true), // no-break spaces
|
||
("12 3456789", false), // four separators break the run: 2 + 7
|
||
("571, 93, 2846", false), // a comma is not a separator
|
||
("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: `redact_full_tins` doc: every run `has_full_tin` finds has its
|
||
/// digits masked, separators kept; other text is untouched.
|
||
#[test]
|
||
fn redact_full_tins_masks_every_full_tin() {
|
||
assert_eq!(
|
||
redact_full_tins("unknown party \"571-93-2846\""),
|
||
"unknown party \"•••-••-••••\""
|
||
);
|
||
assert_eq!(
|
||
redact_full_tins("was 638.27.4915 and 12-3456789, call 801-555"),
|
||
"was •••.••.•••• and ••-•••••••, call 801-555"
|
||
);
|
||
assert_eq!(
|
||
redact_full_tins("invalid type: integer `571932846`"),
|
||
"invalid type: integer `•••••••••`"
|
||
);
|
||
assert_eq!(redact_full_tins("party 12 of 3"), "party 12 of 3");
|
||
for t in ["571 - 93 - 2846", "571932846", "1234567890"] {
|
||
assert!(!has_full_tin(&redact_full_tins(t)), "{t}");
|
||
}
|
||
}
|
||
|
||
/// Oracle: `check_text` refuses what `has_full_tin` finds, so a purge
|
||
/// reason or note quoting the old number with dots is refused (B-26: no
|
||
/// party field may hold a nine-digit number).
|
||
#[test]
|
||
fn check_text_refuses_a_dotted_tin() {
|
||
assert!(check_text("purged", "wrong one, was 638.27.4915").is_err());
|
||
assert!(check_text("no_party_reason", "ssn 571.93.2846").is_err());
|
||
assert!(check_text("name", "Bob 571 - 93 - 2846").is_err());
|
||
}
|
||
|
||
/// 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(()));
|
||
}
|
||
|
||
/// Oracle: parties::entry_flow SCENARIOS 1–6, worked by hand; what_for
|
||
/// always sums to net.
|
||
#[test]
|
||
fn entry_flow_scenarios() {
|
||
use ledger_core::parties::{entry_flow, money_accounts};
|
||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||
use rust_decimal::Decimal;
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||
let bank = acct("1000", AccountType::Asset);
|
||
let savings = acct("1010", AccountType::Asset);
|
||
let equip = acct("1500", AccountType::Asset);
|
||
let card = acct("2050", AccountType::Liability);
|
||
let members = acct("2100", AccountType::Liability);
|
||
let loan = acct("2500", AccountType::Liability);
|
||
let capital = acct("3000", AccountType::Equity);
|
||
let exp = acct("6000", AccountType::Expense);
|
||
let money = money_accounts(&l, &[bank, savings]);
|
||
let d = chrono::NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
|
||
let mut case = |lines: &[(u64, Side, &str)]| {
|
||
let e = l.create_entry(d, "x");
|
||
for (a, side, amt) in lines {
|
||
l.add_line(
|
||
e,
|
||
*a,
|
||
*side,
|
||
Money::new(amt.parse().unwrap(), Currency::USD),
|
||
)
|
||
.unwrap();
|
||
}
|
||
let f = entry_flow(l.get_entry(e).unwrap(), &money);
|
||
let sum: Decimal = f.what_for.iter().map(|(_, v)| *v).sum();
|
||
assert_eq!(sum, f.net, "what_for sums to net: {f:?}");
|
||
f
|
||
};
|
||
use Side::{Credit as C, Debit as D};
|
||
let dec = |s: &str| s.parse::<Decimal>().unwrap();
|
||
// 1. Expense from the bank, a card, a member.
|
||
for src in [bank, card, members] {
|
||
let f = case(&[(exp, D, "10.00"), (src, C, "10.00")]);
|
||
assert_eq!(f.net, dec("10.00"));
|
||
assert_eq!(f.what_for, vec![(exp, dec("10.00"))]);
|
||
}
|
||
// 2. Reimbursing members; paying the card.
|
||
for liab in [members, card] {
|
||
let f = case(&[(liab, D, "387.64"), (bank, C, "387.64")]);
|
||
assert_eq!(f.net, dec("387.64"));
|
||
assert_eq!(f.what_for, vec![(liab, dec("387.64"))]);
|
||
}
|
||
// 3. A loan.
|
||
let f = case(&[(bank, D, "1000.00"), (loan, C, "1000.00")]);
|
||
assert_eq!(f.net, dec("-1000.00"));
|
||
// 4. Refunds to the bank and to the card.
|
||
assert_eq!(
|
||
case(&[(bank, D, "5.00"), (exp, C, "5.00")]).net,
|
||
dec("-5.00")
|
||
);
|
||
assert_eq!(
|
||
case(&[(card, D, "5.00"), (exp, C, "5.00")]).net,
|
||
dec("-5.00")
|
||
);
|
||
// 5. Own-account transfer.
|
||
let f = case(&[(savings, D, "50.00"), (bank, C, "50.00")]);
|
||
assert_eq!(f.net, Decimal::ZERO);
|
||
assert!(f.what_for.is_empty());
|
||
// 6. Property contributed.
|
||
assert_eq!(
|
||
case(&[(equip, D, "750.00"), (capital, C, "750.00")]).net,
|
||
Decimal::ZERO
|
||
);
|
||
// Split payment: part bank, part card.
|
||
let f = case(&[(exp, D, "100.00"), (bank, C, "60.00"), (card, C, "40.00")]);
|
||
assert_eq!(f.net, dec("100.00"));
|
||
}
|
||
|
||
// ------------------------------------------------------- party shares
|
||
//
|
||
// Oracles: the spec block of `ledger_core::shares` (DEFINITIONS,
|
||
// INVARIANTS 1–10, SCENARIOS 1–5), `Ledger::set_entry_parties` (INVARIANTS
|
||
// 1–4), `parties::activity_with` (INVARIANTS 1–4), and
|
||
// docs/parties-and-attachments.md "Shares". Values worked by hand.
|
||
|
||
mod shares {
|
||
use super::{d, date, refused, usd};
|
||
use ledger_core::parties::{activity_with, money_accounts};
|
||
use ledger_core::shares::{check_shares, resolve};
|
||
use ledger_core::{
|
||
AccountId, AccountType, Currency, EntryId, Ledger, PartyId, PartyMember, PartyShare, Role,
|
||
Share, Side,
|
||
};
|
||
use proptest::prelude::*;
|
||
use rust_decimal::Decimal;
|
||
|
||
struct Books {
|
||
l: Ledger,
|
||
bank: AccountId,
|
||
due: AccountId,
|
||
cap_s: AccountId,
|
||
cap_k: AccountId,
|
||
equip: AccountId,
|
||
exp: AccountId,
|
||
stephen: PartyId,
|
||
kat: PartyId,
|
||
acme: PartyId,
|
||
members: Vec<PartyMember>,
|
||
}
|
||
|
||
/// Bluevine 1000, Due to Shareholders 2100, capital 3000 and 3001,
|
||
/// equipment 1500, an expense 6050; members Stephen and Katherine at
|
||
/// 50/50; a party Acme (no records).
|
||
fn books() -> Books {
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let add = |l: &mut Ledger, c: &str, t| l.add_account(c, c, t, None, false).unwrap();
|
||
let bank = add(&mut l, "1000", AccountType::Asset);
|
||
let equip = add(&mut l, "1500", AccountType::Asset);
|
||
let due = add(&mut l, "2100", AccountType::Liability);
|
||
let cap_s = add(&mut l, "3000", AccountType::Equity);
|
||
let cap_k = add(&mut l, "3001", AccountType::Equity);
|
||
let exp = add(&mut l, "6050", AccountType::Expense);
|
||
let stephen = l.add_party("Stephen Waits", &[Role::Member]).unwrap();
|
||
let kat = l
|
||
.add_party("Katherine Pendergast", &[Role::Member])
|
||
.unwrap();
|
||
let acme = l.add_party("Acme", &[Role::Contractor]).unwrap();
|
||
let half = d("0.5");
|
||
let members = vec![
|
||
PartyMember {
|
||
party: stephen,
|
||
share: half,
|
||
capital: cap_s,
|
||
distributions: None,
|
||
},
|
||
PartyMember {
|
||
party: kat,
|
||
share: half,
|
||
capital: cap_k,
|
||
distributions: None,
|
||
},
|
||
];
|
||
Books {
|
||
l,
|
||
bank,
|
||
due,
|
||
cap_s,
|
||
cap_k,
|
||
equip,
|
||
exp,
|
||
stephen,
|
||
kat,
|
||
acme,
|
||
members,
|
||
}
|
||
}
|
||
|
||
fn post(l: &mut Ledger, lines: &[(AccountId, Side, &str)]) -> EntryId {
|
||
let e = l.try_create_entry(date(2026, 5, 1), "joint").unwrap();
|
||
for (a, s, v) in lines {
|
||
l.add_line(e, *a, *s, usd(v)).unwrap();
|
||
}
|
||
l.post_entry(e).unwrap();
|
||
e
|
||
}
|
||
|
||
fn derived(ps: &[PartyId]) -> Vec<PartyShare> {
|
||
ps.iter().map(|&p| PartyShare::whole(p)).collect()
|
||
}
|
||
|
||
fn amounts(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
|
||
ps.iter()
|
||
.map(|&(party, v)| PartyShare {
|
||
party,
|
||
share: Share::Amount(d(v)),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn percents(ps: &[(PartyId, &str)]) -> Vec<PartyShare> {
|
||
ps.iter()
|
||
.map(|&(party, v)| PartyShare {
|
||
party,
|
||
share: Share::Percent(d(v)),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn resolved(b: &Books, e: EntryId) -> Result<Vec<(PartyId, Decimal)>, String> {
|
||
let money = money_accounts(&b.l, &[b.bank]);
|
||
resolve(
|
||
b.l.get_entry(e).unwrap(),
|
||
b.l.entry_parties(e),
|
||
&money,
|
||
&b.members,
|
||
)
|
||
}
|
||
|
||
fn year() -> (chrono::NaiveDate, chrono::NaiveDate) {
|
||
(date(2026, 1, 1), date(2026, 12, 31))
|
||
}
|
||
|
||
fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) {
|
||
let a = activity_with(&b.l, p, year(), &[b.bank], &b.members);
|
||
(a.paid, a.received)
|
||
}
|
||
|
||
/// SCENARIO 1: a reimbursement to both members, derived, 50/50.
|
||
#[test]
|
||
fn reimbursing_both_members_splits_by_member_share() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.due, Side::Debit, "100.00"),
|
||
(b.bank, Side::Credit, "100.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||
.unwrap();
|
||
assert_eq!(
|
||
resolved(&b, e).unwrap(),
|
||
vec![(b.stephen, d("50.00")), (b.kat, d("50.00"))]
|
||
);
|
||
assert_eq!(b.l.entry_party(e), None, "two parties: no sole party");
|
||
assert!(b.l.entry_names_party(e, b.kat));
|
||
assert_eq!(paid_received(&b, b.stephen), (d("50.00"), Decimal::ZERO));
|
||
assert_eq!(paid_received(&b, b.kat), (d("50.00"), Decimal::ZERO));
|
||
}
|
||
|
||
/// SCENARIO 2: a joint contribution derives from the capital lines.
|
||
#[test]
|
||
fn joint_contribution_derives_from_capital_lines() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.bank, Side::Debit, "300.00"),
|
||
(b.cap_s, Side::Credit, "200.00"),
|
||
(b.cap_k, Side::Credit, "100.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||
.unwrap();
|
||
assert_eq!(
|
||
resolved(&b, e).unwrap(),
|
||
vec![(b.stephen, d("-200.00")), (b.kat, d("-100.00"))]
|
||
);
|
||
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, d("200.00")));
|
||
assert_eq!(paid_received(&b, b.kat), (Decimal::ZERO, d("100.00")));
|
||
}
|
||
|
||
/// SCENARIO 3: property contributed moves no money; the shares are of
|
||
/// the total, and activity counts nothing.
|
||
#[test]
|
||
fn property_contribution_resolves_against_the_total() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.equip, Side::Debit, "1000.00"),
|
||
(b.cap_s, Side::Credit, "500.00"),
|
||
(b.cap_k, Side::Credit, "500.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||
.unwrap();
|
||
assert_eq!(
|
||
resolved(&b, e).unwrap(),
|
||
vec![(b.stephen, d("500.00")), (b.kat, d("500.00"))]
|
||
);
|
||
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, Decimal::ZERO));
|
||
}
|
||
|
||
/// Capital lines that don't account for the whole flow are refused.
|
||
#[test]
|
||
fn capital_lines_short_of_the_flow_are_refused() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.bank, Side::Debit, "300.00"),
|
||
(b.cap_s, Side::Credit, "200.00"),
|
||
(b.exp, Side::Credit, "100.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||
.unwrap();
|
||
assert!(resolved(&b, e).is_err());
|
||
let a = activity_with(&b.l, b.stephen, year(), &[b.bank], &b.members);
|
||
assert!(a.unresolved.contains(&e));
|
||
assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO));
|
||
}
|
||
|
||
/// SCENARIO 4: percent rounding, remainder to the first party.
|
||
#[test]
|
||
fn percent_rounding_gives_the_remainder_to_the_first() {
|
||
let mut b = books();
|
||
let c = b.l.add_party("Third", &[]).unwrap();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "100.00"),
|
||
(b.bank, Side::Credit, "100.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_parties(
|
||
e,
|
||
&percents(&[(b.acme, "33.33"), (b.stephen, "33.33"), (c, "33.34")]),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
resolved(&b, e).unwrap(),
|
||
vec![
|
||
(b.acme, d("33.33")),
|
||
(b.stephen, d("33.33")),
|
||
(c, d("33.34"))
|
||
]
|
||
);
|
||
b.l.set_entry_parties(
|
||
e,
|
||
&percents(&[(b.acme, "33.333"), (b.stephen, "33.333"), (c, "33.334")]),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
resolved(&b, e).unwrap(),
|
||
vec![
|
||
(b.acme, d("33.34")),
|
||
(b.stephen, d("33.33")),
|
||
(c, d("33.33"))
|
||
]
|
||
);
|
||
}
|
||
|
||
/// SCENARIO 5 and INVARIANT 7: amounts must sum to what moved.
|
||
#[test]
|
||
fn amounts_must_sum_to_the_flow() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "100.00"),
|
||
(b.bank, Side::Credit, "100.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_parties(e, &amounts(&[(b.stephen, "60.00"), (b.kat, "30.00")]))
|
||
.unwrap();
|
||
assert!(resolved(&b, e).is_err());
|
||
b.l.set_entry_parties(e, &amounts(&[(b.stephen, "70.00"), (b.kat, "30.00")]))
|
||
.unwrap();
|
||
assert_eq!(
|
||
resolved(&b, e).unwrap(),
|
||
vec![(b.stephen, d("70.00")), (b.kat, d("30.00"))]
|
||
);
|
||
assert_eq!(paid_received(&b, b.stephen), (d("70.00"), Decimal::ZERO));
|
||
}
|
||
|
||
/// INVARIANT 10: several derived shares need members.
|
||
#[test]
|
||
fn derived_needs_members_for_several_parties() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "10.00"),
|
||
(b.bank, Side::Credit, "10.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.acme]))
|
||
.unwrap();
|
||
assert!(resolved(&b, e).is_err());
|
||
// With no members passed (plain activity), two members can't derive.
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||
.unwrap();
|
||
let money = money_accounts(&b.l, &[b.bank]);
|
||
assert!(resolve(b.l.get_entry(e).unwrap(), b.l.entry_parties(e), &money, &[]).is_err());
|
||
// A sole party needs no members.
|
||
b.l.set_entry_parties(e, &derived(&[b.acme])).unwrap();
|
||
assert_eq!(resolved(&b, e).unwrap(), vec![(b.acme, d("10.00"))]);
|
||
}
|
||
|
||
/// INVARIANT 10: income shares rescale to the members named.
|
||
#[test]
|
||
fn member_shares_rescale_to_those_named() {
|
||
let mut b = books();
|
||
let third = b.l.add_party("Third Member", &[Role::Member]).unwrap();
|
||
let cap_t =
|
||
b.l.add_account("3002", "Capital T", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
b.members[0].share = d("0.5");
|
||
b.members[1].share = d("0.3");
|
||
b.members.push(PartyMember {
|
||
party: third,
|
||
share: d("0.2"),
|
||
capital: cap_t,
|
||
distributions: None,
|
||
});
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.due, Side::Debit, "80.00"),
|
||
(b.bank, Side::Credit, "80.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||
.unwrap();
|
||
assert_eq!(
|
||
resolved(&b, e).unwrap(),
|
||
vec![(b.stephen, d("50.00")), (b.kat, d("30.00"))]
|
||
);
|
||
}
|
||
|
||
/// INVARIANTS 1–5 of shares and set_entry_parties 1: refusals change
|
||
/// nothing.
|
||
#[test]
|
||
fn bad_shares_are_refused_changing_nothing() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "10.00"),
|
||
(b.bank, Side::Credit, "10.00"),
|
||
],
|
||
);
|
||
let (s, k) = (b.stephen, b.kat);
|
||
refused(&mut b.l, "duplicate", |l| {
|
||
l.set_entry_parties(e, &derived(&[s, s]))
|
||
});
|
||
refused(&mut b.l, "unknown party", |l| {
|
||
l.set_entry_parties(e, &derived(&[s, 999]))
|
||
});
|
||
refused(&mut b.l, "unknown entry", |l| {
|
||
l.set_entry_parties(999, &derived(&[s]))
|
||
});
|
||
refused(&mut b.l, "mixed kinds", |l| {
|
||
l.set_entry_parties(
|
||
e,
|
||
&[
|
||
PartyShare {
|
||
party: s,
|
||
share: Share::Amount(d("5.00")),
|
||
},
|
||
PartyShare::whole(k),
|
||
],
|
||
)
|
||
});
|
||
refused(&mut b.l, "percents sum to 99", |l| {
|
||
l.set_entry_parties(e, &percents(&[(s, "50"), (k, "49")]))
|
||
});
|
||
refused(&mut b.l, "percent over 100", |l| {
|
||
l.set_entry_parties(e, &percents(&[(s, "101"), (k, "-1")]))
|
||
});
|
||
refused(&mut b.l, "finer than a cent", |l| {
|
||
l.set_entry_parties(e, &amounts(&[(s, "5.005"), (k, "4.995")]))
|
||
});
|
||
refused(&mut b.l, "zero amount", |l| {
|
||
l.set_entry_parties(e, &amounts(&[(s, "10.00"), (k, "0")]))
|
||
});
|
||
let v1 = b.l.add_vendor("Vendor One").unwrap();
|
||
let v2 = b.l.add_vendor("Vendor Two").unwrap();
|
||
let (p1, p2) = (
|
||
b.l.party_of_vendor(v1).unwrap(),
|
||
b.l.party_of_vendor(v2).unwrap(),
|
||
);
|
||
refused(&mut b.l, "two vendors", |l| {
|
||
l.set_entry_parties(e, &amounts(&[(p1, "5.00"), (p2, "5.00")]))
|
||
});
|
||
// The checker agrees on its own.
|
||
assert!(check_shares(&percents(&[(s, "100")]), |_| true).is_ok());
|
||
assert!(check_shares(&percents(&[(s, "60"), (k, "40.0")]), |_| true).is_ok());
|
||
}
|
||
|
||
/// set_entry_parties INVARIANT 2: the vendor tag follows the one party
|
||
/// with a vendor record; clearing clears it.
|
||
#[test]
|
||
fn vendor_tag_follows_the_one_vendor_party() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "10.00"),
|
||
(b.bank, Side::Credit, "10.00"),
|
||
],
|
||
);
|
||
let v = b.l.add_vendor("Vendor One").unwrap();
|
||
let pv = b.l.party_of_vendor(v).unwrap();
|
||
b.l.set_entry_parties(e, &amounts(&[(pv, "6.00"), (b.stephen, "4.00")]))
|
||
.unwrap();
|
||
assert_eq!(b.l.entry_vendor(e), Some(v));
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||
.unwrap();
|
||
assert_eq!(b.l.entry_vendor(e), None);
|
||
b.l.set_entry_parties(e, &[]).unwrap();
|
||
assert!(b.l.entry_parties(e).is_empty());
|
||
}
|
||
|
||
/// Single-party tagging is one derived share; INVARIANT 3: naming a
|
||
/// party clears the no-party mark, and the mark is refused while
|
||
/// parties are named.
|
||
#[test]
|
||
fn single_party_and_no_party_mark() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "10.00"),
|
||
(b.bank, Side::Credit, "10.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_party(e, Some(b.acme)).unwrap();
|
||
assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(b.acme)]);
|
||
assert_eq!(b.l.entry_party(e), Some(b.acme));
|
||
b.l.set_entry_party(e, None).unwrap();
|
||
b.l.set_entry_no_party(e, "own transfer").unwrap();
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat]))
|
||
.unwrap();
|
||
assert_eq!(b.l.entry_no_party(e), None);
|
||
refused(&mut b.l, "no-party while named", |l| {
|
||
l.set_entry_no_party(e, "none")
|
||
});
|
||
}
|
||
|
||
/// Reversals carry every share; the pair nets to zero for each party.
|
||
#[test]
|
||
fn reversal_carries_all_shares() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "90.00"),
|
||
(b.bank, Side::Credit, "90.00"),
|
||
],
|
||
);
|
||
let shares = amounts(&[(b.stephen, "60.00"), (b.kat, "30.00")]);
|
||
b.l.set_entry_parties(e, &shares).unwrap();
|
||
let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap();
|
||
assert_eq!(b.l.entry_parties(r), shares.as_slice());
|
||
assert_eq!(
|
||
resolved(&b, r).unwrap(),
|
||
vec![(b.stephen, d("-60.00")), (b.kat, d("-30.00"))]
|
||
);
|
||
assert_eq!(paid_received(&b, b.stephen), (Decimal::ZERO, Decimal::ZERO));
|
||
assert_eq!(paid_received(&b, b.kat), (Decimal::ZERO, Decimal::ZERO));
|
||
}
|
||
|
||
/// Plain `activity` is activity_with no members: a sole party still
|
||
/// counts the whole net.
|
||
#[test]
|
||
fn activity_counts_a_sole_party_in_full() {
|
||
let mut b = books();
|
||
let e = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "12.34"),
|
||
(b.bank, Side::Credit, "12.34"),
|
||
],
|
||
);
|
||
b.l.set_entry_party(e, Some(b.acme)).unwrap();
|
||
let a = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
|
||
assert_eq!(a.paid, d("12.34"));
|
||
assert!(a.entries.contains(&e));
|
||
}
|
||
|
||
fn cents() -> impl Strategy<Value = Decimal> {
|
||
(1i64..10_000_000).prop_map(|c| Decimal::new(c, 2))
|
||
}
|
||
|
||
proptest! {
|
||
/// INVARIANT 6: resolved shares sum to the target exactly, each
|
||
/// with its sign, for percents (any split summing to 100, to
|
||
/// three decimals), member-derived splits, and capital lines.
|
||
#[test]
|
||
fn resolved_shares_sum_exactly(
|
||
total in cents(),
|
||
refund in any::<bool>(),
|
||
cuts in proptest::collection::vec(1u32..100_000, 1..6),
|
||
member_share in 1u32..1000,
|
||
) {
|
||
let mut b = books();
|
||
let (dr, cr) = if refund { (b.bank, b.exp) } else { (b.exp, b.bank) };
|
||
let t = total.to_string();
|
||
let e = post(&mut b.l, &[(dr, Side::Debit, &t), (cr, Side::Credit, &t)]);
|
||
let target = if refund { -total } else { total };
|
||
// Percents: cut 100.000 at sorted points.
|
||
let mut points: Vec<u32> = cuts.iter().map(|c| c % 100_000).collect();
|
||
points.sort_unstable();
|
||
points.dedup();
|
||
points.retain(|&p| p > 0);
|
||
let mut bounds = vec![0u32];
|
||
bounds.extend(points);
|
||
bounds.push(100_000);
|
||
let mut ps = Vec::new();
|
||
for (i, w) in bounds.windows(2).enumerate() {
|
||
let party = b.l.add_party(&format!("P{i}"), &[]).unwrap();
|
||
ps.push((party, Decimal::new(i64::from(w[1] - w[0]), 3)));
|
||
}
|
||
let shares: Vec<PartyShare> = ps
|
||
.iter()
|
||
.map(|&(party, p)| PartyShare { party, share: Share::Percent(p) })
|
||
.collect();
|
||
b.l.set_entry_parties(e, &shares).unwrap();
|
||
let r = resolved(&b, e).unwrap();
|
||
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||
prop_assert!(r.iter().all(|(_, v)| v.is_zero() || v.is_sign_negative() == refund));
|
||
prop_assert!(r.iter().all(|(_, v)| v.normalize().scale() <= 2));
|
||
// Member-derived with uneven income shares.
|
||
let s = Decimal::new(i64::from(member_share), 3);
|
||
b.members[0].share = s;
|
||
b.members[1].share = Decimal::ONE - s;
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])).unwrap();
|
||
let r = resolved(&b, e).unwrap();
|
||
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||
// Amounts that sum: split at the first party's resolved part.
|
||
let first = r[0].1.abs();
|
||
let rest = total - first;
|
||
if !first.is_zero() && !rest.is_zero() {
|
||
b.l.set_entry_parties(e, &[
|
||
PartyShare { party: b.stephen, share: Share::Amount(first) },
|
||
PartyShare { party: b.kat, share: Share::Amount(rest) },
|
||
]).unwrap();
|
||
let r = resolved(&b, e).unwrap();
|
||
prop_assert_eq!(r.iter().map(|(_, v)| *v).sum::<Decimal>(), target);
|
||
}
|
||
}
|
||
|
||
/// Capital-line derivation: two members contribute any amounts in
|
||
/// one entry; each gets their own line, summing to the flow.
|
||
#[test]
|
||
fn capital_line_shares_sum_exactly(a in cents(), c in cents()) {
|
||
let mut b = books();
|
||
let total = a + c;
|
||
let e = post(&mut b.l, &[
|
||
(b.bank, Side::Debit, &total.to_string()),
|
||
(b.cap_s, Side::Credit, &a.to_string()),
|
||
(b.cap_k, Side::Credit, &c.to_string()),
|
||
]);
|
||
b.l.set_entry_parties(e, &derived(&[b.stephen, b.kat])).unwrap();
|
||
let r = resolved(&b, e).unwrap();
|
||
prop_assert_eq!(r, vec![(b.stephen, -a), (b.kat, -c)]);
|
||
let sa = activity_with(&b.l, b.stephen, year(), &[b.bank], &b.members);
|
||
let sk = activity_with(&b.l, b.kat, year(), &[b.bank], &b.members);
|
||
prop_assert_eq!(sa.received + sk.received, total);
|
||
}
|
||
}
|
||
}
|