- The 13K-character instructions (clients cut them short) become a
connect text of at most 1,400 characters whose first 512 stand alone,
plus books.handbook and books://handbook/{section}: the old guidance by
section, with no helper commands, home paths, pull requests or
file-reading suggestions (tool_text_lint).
- Every tool has a title and read-only/destructive hints; no
requiresUserInteraction anywhere (owner decision 2026-10-16).
- books.import_local_file reads only regular files inside the import
roots (the downloads folder by default, or --import-root), never the
data dir, books dir, vault or the key's folder, no symlink escapes or
hard links, and the file opened must be the file checked.
- The store lock is a tokio mutex taken off the runtime threads.
- get_info refreshes settings first; a bare books://digest reads.
Review fixes (P0 review, 2026-10-16):
- Fail closed: a tokio mutex never poisons and spawn_blocking caught the
panic, so after a panic mid-mutation the server kept serving a Store
behind its journal. A panic (the task, or while holding the store)
now stops the shared store; every later request on every session is
refused with a plain message; a restart re-verifies the journal. The
join error goes through redact_refusal and never carries the panic's
text.
- books.set_w9 w9_file_path read any path (a link named w9.pdf to the
vault key was stored and get_w9 wrote it out): it now goes through the
import roots' lockdown, its type judged on the canonical path.
- Sealed even inside a root: the key file in force, where a linked one
leads, both folders, and the default key folder whatever the settings
say. Refusals check the roots first, so outside them every path gets
one message (no probing what exists); inside, other books-dir files say
to copy them out first.
- The open is O_NOFOLLOW | O_NONBLOCK (a pipe swapped in after the check
can't hang a thread) and must match the checked file's device and
inode. New dependency: libc (unix only), for those two open flags,
which std doesn't name.
- Default import roots are the downloads folder only (the plan); files
with full TINs (get_w9, vendor_1099 include_tins, the CPA package with
TINs) are never written into an import root, and the server refuses to
import any file it wrote with TINs (by SHA-256).
- set_tin and set_w9 overwrite vault records: destructive_hint = true;
annotations_complete pins the key tools' hints. The read-only hint is
checked in behaviour on every spec_mcp call (journal and data-dir files
unchanged).
- Text: the connect text names the settings file as the one local file
to edit, says every entry balances on real accounts and names its
party, says invoice/bill/payment/payroll tools post unchecked, rewords
the exemptions, and shows the company name as one plain line; the
handbook takes section "all", lists its ids in the schema and says to
read contract first; books.settings drops the config.json path and no
error names the data dir or the key file. tool_text_lint covers the
whole tool JSON and resources and bans git/commit/WL-0001; tool
descriptions over 700 characters are pinned to today's eight (P5).
- digest: an empty since= is bare; the default date is pinned on every
day of a month.
Round-2 review fixes (2026-10-16):
- TIN files (B-26): the in-memory, per-session set of TIN-file hashes
became one registry beside the vault (vault/tin-files.sha256, hashes
only), shared by every session and every later server. It holds the
W-9 set_w9 reads (recorded as read), get_w9 copies, 1099 worksheets
with TINs, CPA packages with TINs and each file in one that holds a
TIN; a file is recorded before it is written, and a failure refuses.
import_local_file, attach_receipt and file_document (base64 was never
checked) refuse a recorded file, and a text file (by type, name, or
UTF-8/UTF-16 decoding) holding a full TIN. Not caught: an altered
binary copy; the docs say so (P2/P7 remove the path tools).
- The text check is narrow (owner/engineer decision, 2026-10-16: bank
exports are filed for reconciliation, and has_full_tin's "nine digits
however separated" refused their routing numbers, long references and
dates run into amounts). Text is refused when it holds (a) a TIN the
vault holds for a party, anywhere, as nine digits, 3-2-4 joined by
hyphens, spaces or dots, or 2-7 joined by a hyphen or space, compared
on the server against the decrypted vault and never returned or
logged (a vault that can't be opened leaves (b)); or (b) a stand-alone
printed SSN (123-45-6789) or EIN (12-3456789) shape: no digit, and no
hyphen or dot followed by a digit, beside it. Full-width digits and
Unicode hyphens and spaces read as ASCII. has_full_tin still guards
refusals and results (redact_refusal). Known limit, for P7: a refusal
under (a) tells the agent a text it chose holds a vault TIN.
New dependency: zeroize in ledger-mcp (already in the workspace
through ledger-vault, same major version), so the vault's TINs decoded
for the scan are wiped from memory on drop as the vault's own are.
- Mutation testing of the final P0 diff (docs/testing.md): tests for
each law a survivor broke (handbook titles, a dropped default root, a
.jpg W-9, the waker, the walk's folder flags against a link and a
pipe, the 40-link limit, save paths refused as such, the package
marking only its TIN files, a registry that can't be read, text types,
the short receipt rule's counts and a 12-character code). Simpler
code where only a race could tell: inspect places an existing path by
canonicalize itself (no second comparison), read_checked's read limit
is its one size check, openat fails on -1 as specified. The 25 MiB
upload test removes its 50 MB of books however it ends.
- The GUI's stale-rule prompt no longer says to open a pull request: it
asks the agent to find the figure from the rule's official authority,
confirm it with the human, pass it explicitly, and tell the human so it
can be reported upstream (as the handbook says); a lib test pins it and
bans files, git, GitHub and pull requests from every attention prompt.
- Import roots (local_files.rs INVARIANT 0): XDG_DOWNLOAD_DIR="$HOME/"
made $HOME (or /) the root. No root may be /, the home folder or hold
it, or hold the books, data dir, vault or a key: a named one stops the
server at startup, a default one is dropped; the default falls back to
~/Downloads only if that is a real folder other than $HOME; with no
root, an import says so.
- The read is a component walk from / with openat(O_NOFOLLOW |
O_DIRECTORY) per folder and O_NOFOLLOW | O_NONBLOCK for the file, so a
folder swapped for a link after the check fails; no step may land on a
protected inode (one renamed into a root); dev/ino and nlink stay. No
/proc. Portable to macOS. (libc's openat, two small unsafe blocks.)
- One refusal for missing, dangling and outside paths: resolved()
follows every link, dangling ones too, so a link's target existing or
not no longer changes the answer.
- Sealing: the key files (in force, default) and their targets always;
their folders only when not $HOME, not a root, not holding a root; and
~/.config/wanderbooks whatever XDG_CONFIG_HOME says.
- Agent text (I-12): RuleNotKnown names the authority (IRS, with its
address) and says to tell the human, never a repo path, PR or GitHub;
the digest's stale_rules drop the repo catalog and path for it; vault,
outside-books, STOPPED and local-file refusals name no git, path or
flag. tool_text_lint bans docs/, .md, /home, data dir, --data-dir,
--import-root, vault.key, .jsonl, sqlite, .gitignore, github, pull
request, and also lints every error template (scanned from source),
built error texts, and the server's refusal constants.
- The receipt rule told (ReceiptPolicy::rule) is the one enforced: the
exact threshold in the base currency (no "$", no rounding), required
and exempt accounts, reversals exempt; the connect text fits 1,400
characters by dropping reasons, then codes, never the rule.
- The connect text follows the parties setting; "offer books.digest"
everywhere. shown_name drops Unicode format characters (bidi, zero
width).
- read_only_tools_change_nothing compares file contents, the database
by every row, the vault and the settings file (raw database bytes did
not change either; rows are the stable comparison).
- import_local_file takes the name and type from the canonical path;
the digest's journal repair says when the tail was set aside, not the
journal's file name; "version-history messages" back in the TIN rule;
openWorldHint false on every tool, pinned in annotations_complete.
- Outside the MCP crate, for the same texts: ledger-core's DatedRule
gains `authority` (the statute or publication with its address) and
RuleNotKnown carries it; ledger-store's "sqlite error" reads "database
error"; ledger-config's ReceiptPolicy::rule takes the base currency.
New dev-dependency: rusqlite in ledger-mcp (already in the workspace,
same version and features as ledger-store), to read the database's
rows in read_only_tools_change_nothing.
- The filing scan is no TIN oracle (tin_files.rs INVARIANT 4): text
holding more than two TIN-shaped numbers (any rendering rule (a)
reads) that end in a known TIN's last four (the books' TIN and W-9
facts, via known_tin_last4, before the vault is opened; then the
vault's own) is refused with TIN_PROBE, the same words whether or not
one is the vault's TIN; only one or two candidates are compared
against the vault. Tests: unit
text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it,
one_or_two_candidates_are_compared_against_the_vault; over HTTP
text_probing_for_a_tin_is_refused_alike_by_every_filing_tool.
Plan: plan/served.md P0, section 6 "Immediate hole (P0)", 4.1, 4.3, 4.9.
Review round 3. S1: a panic stops the server for every session, so no
input may reach one. Dates are four-digit years (new ledger_core::
calendar, 0001-01-01..9999-12-31): the MCP's parse_date is strict
YYYY-MM-DD, periods are bounded, last_day_of_month returns a refusal
(server and store); schedules, assets, recurring entries, periods,
documents, disposals and entries refuse dates outside the calendar, and
replay refuses such journal dates. Every amount a command takes is at
most a line's (draft lines by size, document lines, payouts, payroll,
statements, shares, tax basis, proceeds, recurring templates), on
replay too, so sums are exact; the 1099 vendor part and the K-1
property share use checked products (TooLargeToCompute); a recurring
run makes at most 1,000 drafts. tests/hostile_args.rs drives every tool
with schema-derived hostile arguments and every resource with generated
URIs, asserting the server never stops; the audit's panics are pinned.
S2: a `.` or `..` path name is refused as NO_SUCH_FILE up front. S3:
NUL-interleaved UTF-16 without a byte-order mark is scanned both ways.
S4: resource-read refusals are TIN-redacted. S5: set_w9 records its W-9
only once the call passes every check, just before the vault write.
S8: idempotentHint only on verify, run_depreciation, run_schedules,
run_recurring, set_party_roles (a repeat leaves journal and vault
alone; spec_vault checks every candidate twice); every tool's hints
pinned in one table. C1: books.settings lists receipt_required_accounts
and receipt_exempt_accounts uncut. C2: the default company name reads
"a new company". C3: the parties rule names no-party reasons. C4/C5:
the handbook names the settings file only as books.settings' books_toml,
and restores "check books.digest and offer it". C6: an import root
inside the books, data dir or vault is refused at startup. The lint
catches `just` however it ends and where the books are;
digest_resource_bare_reads reads the server's month.
Review round 4 (2026-10-17): round 3's bounds ran in replay too, so
journals main wrote and reopened (an entry dated 0000-06-01 or
+10000-01-01, a draft line of 2e15, an asset ending past 9999, a basis
or share past the line limit, sub-cent document and payroll lines) no
longer opened. Posted is forever: the bounds are the commands' only.
- Replay applies main's rule exactly: parse_date takes any date chrono
reads; replay_add_line(_fx) has no size bound (add_line/add_line_fx
do); replay_register_asset/correct_asset (chrono overflow only),
replay_set_asset_tax_basis (check_journaled), replay_set_entry_parties
(check_shares_journaled), replay_register_recurring
(validate_recurring_journaled), replay_register_schedule (months 1 to
600), replay_create_period (order only), replay_dispose_asset, and no
line, date, payment or payroll range checks in replay_invoice/bill,
their payments and replay_payroll_run.
- Derived figures over such books refuse (TooLargeToCompute,
DateOutOfRange), never panic: amortization plans, depreciation into
chrono's last month (and the true-up for lives past 9999), intervals,
document totals and aging, net pay and payroll plans, money flows and
share resolution, the asset register, receipt-policy debits; the MCP
tools and the viewer show what can't be computed.
- tests/fixtures/old-books: the review's 13 journals, its control and 15
more edges, written by main's Store API with main's figures
(expected.json); every one opens in the writer and the viewer to those
figures, every derived figure computes or refuses, every MCP tool and
GUI page answers; hostile_args restarts the server after each
property; each bound has a command/replay test pair.
Final review: a schedule's plan refuses a total of 10^26 or more
(TooLargeToCompute) instead of letting Decimal round its cents silently,
and a null `remaining` in the schedule output is always explained by
plan_error. Replay is unaffected: no journaled schedule month can belong
to a total that large (the line limit bounds every posted month).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2135 lines
77 KiB
Rust
2135 lines
77 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,
|
||
PartyShare, 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(), 10, "ten distinct roles");
|
||
for r in [
|
||
Role::Vendor,
|
||
Role::Contractor,
|
||
Role::Customer,
|
||
Role::Employee,
|
||
Role::Member,
|
||
Role::PayoutPlatform,
|
||
Role::TaxAgency,
|
||
Role::GovernmentAgency,
|
||
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::GovernmentAgency.key(), "government_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));
|
||
}
|
||
|
||
/// Oracle: name_payroll_parties doc: a payroll entry names the employees
|
||
/// it paid as its parties, each with their net pay (one employee: the whole
|
||
/// entry), live and on replay alike; a line naming no employee leaves the
|
||
/// entry untagged; a line paying nothing names no one.
|
||
#[test]
|
||
fn a_payroll_entry_names_the_employees_it_paid() {
|
||
let (mut l, fx) = fixture();
|
||
let alice = l.add_party("Alice", &[Role::Employee]).unwrap();
|
||
let bob = l.add_party("Bob", &[Role::Employee]).unwrap();
|
||
let day = date(2026, 2, 27);
|
||
let net = d("823.50");
|
||
let run = l
|
||
.run_payroll(day, vec![pay_line("Alice", Some(alice))], fx.cash)
|
||
.unwrap();
|
||
let entry = l.get_payroll_run(run).unwrap().entry_id;
|
||
assert_eq!(l.entry_parties(entry), &[PartyShare::whole(alice)]);
|
||
// Alice twice (her two lines one share) and Bob: exact amounts.
|
||
let lines = vec![
|
||
pay_line("Alice", Some(alice)),
|
||
pay_line("Bob", Some(bob)),
|
||
pay_line("Alice", Some(alice)),
|
||
];
|
||
let run = l.run_payroll(day, lines.clone(), fx.cash).unwrap();
|
||
let entry = l.get_payroll_run(run).unwrap().entry_id;
|
||
let amounts = |a: Decimal, b: Decimal| {
|
||
vec![
|
||
PartyShare {
|
||
party: alice,
|
||
share: ledger_core::Share::Amount(a),
|
||
},
|
||
PartyShare {
|
||
party: bob,
|
||
share: ledger_core::Share::Amount(b),
|
||
},
|
||
]
|
||
};
|
||
assert_eq!(l.entry_parties(entry), amounts(net + net, net).as_slice());
|
||
// The same run replayed names them the same way.
|
||
let replayed = l.create_entry(day, "Payroll replayed");
|
||
let hosting = l.chart().find_by_code("6050").unwrap().id;
|
||
l.add_line(replayed, hosting, Side::Debit, usd("1.00"))
|
||
.unwrap();
|
||
l.add_line(replayed, fx.cash, Side::Credit, usd("1.00"))
|
||
.unwrap();
|
||
l.post_entry(replayed).unwrap();
|
||
let id = l.next_payroll_run_id();
|
||
l.replay_payroll_run(id, day, lines, replayed).unwrap();
|
||
assert_eq!(
|
||
l.entry_parties(replayed),
|
||
amounts(net + net, net).as_slice()
|
||
);
|
||
// A line naming no employee: the entry is left for the agent to tag.
|
||
let run = l
|
||
.run_payroll(
|
||
day,
|
||
vec![pay_line("Alice", Some(alice)), pay_line("Old", None)],
|
||
fx.cash,
|
||
)
|
||
.unwrap();
|
||
let entry = l.get_payroll_run(run).unwrap().entry_id;
|
||
assert!(l.entry_parties(entry).is_empty());
|
||
// A line that pays nothing (all withheld) names no one, so Bob's run
|
||
// is his alone.
|
||
let mut nothing = pay_line("Old", None);
|
||
nothing.federal_withholding = d("923.50");
|
||
let run = l
|
||
.run_payroll(day, vec![nothing, pay_line("Bob", Some(bob))], fx.cash)
|
||
.unwrap();
|
||
let entry = l.get_payroll_run(run).unwrap().entry_id;
|
||
assert_eq!(l.entry_parties(entry), &[PartyShare::whole(bob)]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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 DEFINITIONS (across): one liability paying
|
||
/// down two (a member paying both cards from their own funds): across is
|
||
/// taken from the debited liabilities in order of first appearance, each
|
||
/// at most what is left of it, until across is used up.
|
||
#[test]
|
||
fn entry_flow_takes_across_in_order_until_used_up() {
|
||
use ledger_core::parties::{entry_flow, money_accounts};
|
||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||
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 card_a = acct("2050", AccountType::Liability);
|
||
let card_b = acct("2060", AccountType::Liability);
|
||
let members = acct("2100", AccountType::Liability);
|
||
let capital = acct("3000", AccountType::Equity);
|
||
let fees = acct("6000", AccountType::Expense);
|
||
let money = money_accounts(&l, &[bank]);
|
||
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x");
|
||
for (a, side, amt) in [
|
||
(card_a, Side::Debit, "30.00"),
|
||
(card_b, Side::Debit, "20.00"),
|
||
(fees, Side::Debit, "5.00"),
|
||
(members, Side::Credit, "25.00"),
|
||
(bank, Side::Credit, "15.00"),
|
||
(capital, Side::Credit, "15.00"),
|
||
] {
|
||
l.add_line(e, a, side, Money::new(d(amt), Currency::USD))
|
||
.unwrap();
|
||
}
|
||
let f = entry_flow(l.get_entry(e).unwrap(), &money).unwrap();
|
||
// down = min(liab_dr 50, cash_cr 15) = 15: card A's first 15. Left:
|
||
// card A 15, card B 20, members -25; across = min(35, 25) = 25: card
|
||
// A's 15, then 10 of card B's 20, and nothing more.
|
||
assert_eq!(f.net, d("30.00"));
|
||
assert_eq!(
|
||
f.what_for,
|
||
vec![
|
||
(card_a, d("30.00")),
|
||
(fees, d("5.00")),
|
||
(capital, d("-15.00")),
|
||
(card_b, d("10.00"))
|
||
]
|
||
);
|
||
}
|
||
|
||
/// Oracle: shares::share_target: an entry moving no money splits over its
|
||
/// total debits, whatever its credits (a draft not yet balanced).
|
||
#[test]
|
||
fn a_share_target_without_money_is_the_debits() {
|
||
use ledger_core::parties::money_accounts;
|
||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||
let mut l = Ledger::new(Currency::USD);
|
||
let bank = l
|
||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let equip = l
|
||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||
.unwrap();
|
||
let capital = l
|
||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||
.unwrap();
|
||
let money = money_accounts(&l, &[bank]);
|
||
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x");
|
||
l.add_line(e, equip, Side::Debit, Money::new(d("10.00"), Currency::USD))
|
||
.unwrap();
|
||
l.add_line(
|
||
e,
|
||
capital,
|
||
Side::Credit,
|
||
Money::new(d("4.00"), Currency::USD),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
ledger_core::shares::share_target(l.get_entry(e).unwrap(), &money),
|
||
Ok(d("10.00"))
|
||
);
|
||
}
|
||
|
||
/// Oracle: shares::resolve INVARIANT 10: members named on an entry that
|
||
/// touches their capital share it by their capital lines, credits less
|
||
/// debits.
|
||
#[test]
|
||
fn derived_member_shares_net_their_capital_lines() {
|
||
use ledger_core::parties::money_accounts;
|
||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||
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 cap_a = acct("3000", AccountType::Equity);
|
||
let cap_b = acct("3001", AccountType::Equity);
|
||
let a = l.add_party("A", &[Role::Member]).unwrap();
|
||
let b = l.add_party("B", &[Role::Member]).unwrap();
|
||
let money = money_accounts(&l, &[bank]);
|
||
let member = |party, capital| ledger_core::PartyMember {
|
||
party,
|
||
share: d("0.5"),
|
||
capital,
|
||
distributions: None,
|
||
};
|
||
let members = [member(a, cap_a), member(b, cap_b)];
|
||
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x");
|
||
for (acct, side, amt) in [
|
||
(bank, Side::Debit, "100.00"),
|
||
(cap_a, Side::Credit, "100.00"),
|
||
(cap_a, Side::Debit, "30.00"),
|
||
(cap_b, Side::Credit, "30.00"),
|
||
] {
|
||
l.add_line(e, acct, side, Money::new(d(amt), Currency::USD))
|
||
.unwrap();
|
||
}
|
||
let shares = [PartyShare::whole(a), PartyShare::whole(b)].map(|mut s| {
|
||
s.share = ledger_core::Share::Derived;
|
||
s
|
||
});
|
||
assert_eq!(
|
||
ledger_core::shares::resolve(l.get_entry(e).unwrap(), &shares, &money, &members),
|
||
Ok(vec![(a, d("-70.00")), (b, d("-30.00"))])
|
||
);
|
||
}
|
||
|
||
/// 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).unwrap();
|
||
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"));
|
||
// 7. A member pays the card bill from their own funds, or a loan pays
|
||
// it off: the issuer is paid, for the card, as from the bank.
|
||
for funder in [members, loan] {
|
||
let f = case(&[(card, D, "300.00"), (funder, C, "300.00")]);
|
||
assert_eq!(f.net, dec("300.00"));
|
||
assert_eq!(f.what_for, vec![(card, dec("300.00"))]);
|
||
}
|
||
// Part bank, part a member.
|
||
let f = case(&[
|
||
(card, D, "300.00"),
|
||
(bank, C, "200.00"),
|
||
(members, C, "100.00"),
|
||
]);
|
||
assert_eq!(f.net, dec("300.00"));
|
||
assert_eq!(f.what_for, vec![(card, dec("300.00"))]);
|
||
// 4. A charge and its refund on one card offset there: no card paid
|
||
// down, 80.00 paid by card.
|
||
let f = case(&[
|
||
(exp, D, "100.00"),
|
||
(card, C, "100.00"),
|
||
(card, D, "20.00"),
|
||
(exp, C, "20.00"),
|
||
]);
|
||
assert_eq!(f.net, dec("80.00"));
|
||
assert_eq!(f.what_for, vec![(exp, dec("80.00"))]);
|
||
// Paying the card from the bank while the issuer charges a fee on it:
|
||
// 300.00 cash plus the 50.00 fee by card.
|
||
let f = case(&[
|
||
(card, D, "300.00"),
|
||
(bank, C, "300.00"),
|
||
(exp, D, "50.00"),
|
||
(card, C, "50.00"),
|
||
]);
|
||
assert_eq!(f.net, dec("350.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")
|
||
});
|
||
}
|
||
|
||
/// set_entry_no_party INVARIANT 1 and SCENARIO 3: a marked transfer's
|
||
/// reversal carries the mark; marking a reversed entry marks the
|
||
/// whole chain; naming a party on any of them clears it on all.
|
||
#[test]
|
||
fn a_no_party_mark_follows_the_reversal_chain() {
|
||
let mut b = books();
|
||
let own = |b: &mut Books| {
|
||
let (due, bank) = (b.due, b.bank);
|
||
post(
|
||
&mut b.l,
|
||
&[(due, Side::Debit, "10.00"), (bank, Side::Credit, "10.00")],
|
||
)
|
||
};
|
||
let e = own(&mut b);
|
||
b.l.set_entry_no_party(e, "transfer between our own accounts")
|
||
.unwrap();
|
||
let r = b.l.reverse_entry(e, date(2026, 5, 2), "undo").unwrap();
|
||
assert_eq!(
|
||
b.l.entry_no_party(r),
|
||
Some("transfer between our own accounts")
|
||
);
|
||
// Marked after it was reversed: the whole chain.
|
||
let e2 = own(&mut b);
|
||
let r2 = b.l.reverse_entry(e2, date(2026, 5, 2), "undo").unwrap();
|
||
b.l.set_entry_no_party(r2, "accrual").unwrap();
|
||
assert_eq!(b.l.entry_no_party(e2), Some("accrual"));
|
||
assert_eq!(b.l.entry_no_party(r2), Some("accrual"));
|
||
// A party named on the original clears both.
|
||
b.l.set_entry_party(e2, Some(b.acme)).unwrap();
|
||
assert_eq!(b.l.entry_no_party(e2), None);
|
||
assert_eq!(b.l.entry_no_party(r2), None);
|
||
refused(&mut b.l, "chain names a party", |l| {
|
||
l.set_entry_no_party(r2, "none")
|
||
});
|
||
}
|
||
|
||
/// set_entry_vendor SCENARIO 3: tagging a vendor names its party and
|
||
/// clears a no-party mark, so the entry never carries both; clearing
|
||
/// the vendor afterwards leaves no stale mark to satisfy "require".
|
||
#[test]
|
||
fn tagging_a_vendor_clears_a_no_party_mark() {
|
||
let mut b = books();
|
||
let e = b.l.try_create_entry(date(2026, 5, 1), "hosting").unwrap();
|
||
b.l.add_line(e, b.exp, Side::Debit, usd("12.00")).unwrap();
|
||
b.l.add_line(e, b.bank, Side::Credit, usd("12.00")).unwrap();
|
||
b.l.set_entry_no_party(e, "none really").unwrap();
|
||
let v = b.l.add_vendor("Namecheap").unwrap();
|
||
let pv = b.l.party_of_vendor(v).unwrap();
|
||
b.l.set_entry_vendor(e, Some(v)).unwrap();
|
||
assert_eq!(b.l.entry_parties(e), &[PartyShare::whole(pv)]);
|
||
assert_eq!(b.l.entry_no_party(e), None);
|
||
b.l.set_entry_vendor(e, None).unwrap();
|
||
assert!(b.l.entry_parties(e).is_empty());
|
||
assert_eq!(b.l.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));
|
||
}
|
||
|
||
/// activity_with INVARIANT 2: a reversal of a reversal reinstates the
|
||
/// payment on its root original's side: paid, never a negative
|
||
/// received.
|
||
#[test]
|
||
fn reversing_a_reversal_reinstates_the_payment_as_paid() {
|
||
let mut b = books();
|
||
let a = post(
|
||
&mut b.l,
|
||
&[
|
||
(b.exp, Side::Debit, "100.00"),
|
||
(b.bank, Side::Credit, "100.00"),
|
||
],
|
||
);
|
||
b.l.set_entry_party(a, Some(b.acme)).unwrap();
|
||
let r1 = b.l.reverse_entry(a, date(2026, 5, 2), "undo").unwrap();
|
||
let r2 = b.l.reverse_entry(r1, date(2026, 5, 3), "redo").unwrap();
|
||
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
|
||
assert_eq!(act.entries, std::collections::BTreeSet::from([a, r1, r2]));
|
||
assert_eq!((act.paid, act.received), (d("100.00"), Decimal::ZERO));
|
||
// A third level takes it back out again, still on the paid side.
|
||
let _r3 =
|
||
b.l.reverse_entry(r2, date(2026, 5, 4), "undo again")
|
||
.unwrap();
|
||
let act = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]);
|
||
assert_eq!((act.paid, act.received), (Decimal::ZERO, Decimal::ZERO));
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|