From c2ceabbae9b0f57e7717719d055d32cb8555deb2 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 20 Oct 2026 19:59:25 -0600 Subject: [PATCH] P3: party totals by what moved the money, once (defects 14, 11, 24) Each party's part of an entry is signed as its own account would show it, with a direction and its cash and in-kind amounts; a reversal is its original negated. Totals on a cash or all basis: for a member, all equals the members' equity statement and cash the K-1's cash. Money is cash, cards and the financing accounts, and a liability credit never pays its own creditor (creditor_of), so accruals and declared distributions count once. tin_last4 from the TIN or W-9 facts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- bookspec/README.md | 76 +- .../p3-party-accrual-counts-once.toml | 134 ++ .../p3-party-distribution-still-owed.toml | 200 ++ .../p3-party-gift-card-is-a-promise.toml | 92 + .../p3-party-members-equity-and-k1.toml | 219 ++ .../p3-party-own-liability-never-pays.toml | 240 +++ .../p3-party-reversed-card-payment.toml | 99 + .../p3-party-signs-and-reversals.toml | 289 +++ bookspec/examples/p3-party-tin-last4.toml | 86 + crates/ledger-core/src/form1099.rs | 21 +- crates/ledger-core/src/k1.rs | 2 +- crates/ledger-core/src/kernel.rs | 223 ++- crates/ledger-core/src/ledger.rs | 99 + crates/ledger-core/src/parties.rs | 418 +++- crates/ledger-core/src/proofs.rs | 138 ++ crates/ledger-core/src/shares.rs | 360 +++- crates/ledger-core/tests/bookspec.rs | 527 ++++- crates/ledger-core/tests/kernel_bridge.rs | 33 + crates/ledger-core/tests/spec_bounds.rs | 5 +- crates/ledger-core/tests/spec_parties.rs | 188 +- .../spec_party_totals.proptest-regressions | 8 + crates/ledger-core/tests/spec_party_totals.rs | 1767 +++++++++++++++++ .../tests/spec_replayed_extremes.rs | 43 +- crates/ledger-gui/src/books.rs | 240 ++- crates/ledger-gui/src/pages/entry.rs | 77 +- crates/ledger-gui/src/pages/journal.rs | 15 + crates/ledger-gui/src/pages/parties.rs | 59 +- crates/ledger-gui/tests/edges.rs | 17 +- crates/ledger-gui/tests/parties.rs | 278 ++- .../ledger-mcp/handbook/parties-and-tins.md | 6 +- crates/ledger-mcp/src/server.rs | 152 +- crates/ledger-mcp/src/types.rs | 130 +- crates/ledger-mcp/tests/spec_parties.rs | 31 +- crates/ledger-mcp/tests/spec_party_totals.rs | 713 +++++++ crates/ledger-mcp/tests/spec_vault.rs | 58 + crates/ledger-package/src/package.rs | 15 +- crates/ledger-package/src/vendor_1099.rs | 9 +- crates/ledger-store/src/format.rs | 6 + crates/ledger-store/src/reconcilable.rs | 13 +- crates/ledger-store/src/store.rs | 35 +- crates/ledger-store/tests/party_creditors.rs | 234 +++ crates/ledger-store/tests/store.rs | 9 +- docs/mcp-interface.md | 122 +- docs/parties-and-attachments.md | 67 +- docs/spec/rules.md | 201 +- docs/spec/sources/helvering-v-price-1940.md | 11 + docs/spec/sources/irs-capital-accounts.md | 35 + docs/testing.md | 49 + docs/verification.md | 26 + verify/lean/Audit.lean | 4 + verify/lean/Wanderbooks.lean | 1 + verify/lean/Wanderbooks/Parties.lean | 215 ++ 52 files changed, 7535 insertions(+), 560 deletions(-) create mode 100644 bookspec/examples/p3-party-accrual-counts-once.toml create mode 100644 bookspec/examples/p3-party-distribution-still-owed.toml create mode 100644 bookspec/examples/p3-party-gift-card-is-a-promise.toml create mode 100644 bookspec/examples/p3-party-members-equity-and-k1.toml create mode 100644 bookspec/examples/p3-party-own-liability-never-pays.toml create mode 100644 bookspec/examples/p3-party-reversed-card-payment.toml create mode 100644 bookspec/examples/p3-party-signs-and-reversals.toml create mode 100644 bookspec/examples/p3-party-tin-last4.toml create mode 100644 crates/ledger-core/tests/spec_party_totals.proptest-regressions create mode 100644 crates/ledger-core/tests/spec_party_totals.rs create mode 100644 crates/ledger-mcp/tests/spec_party_totals.rs create mode 100644 crates/ledger-store/tests/party_creditors.rs create mode 100644 docs/spec/sources/helvering-v-price-1940.md create mode 100644 docs/spec/sources/irs-capital-accounts.md create mode 100644 verify/lean/Wanderbooks/Parties.lean diff --git a/bookspec/README.md b/bookspec/README.md index 0d36835..f1b760c 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -79,9 +79,10 @@ listed first; rules.md B-13). An example that closes to the members declares `largest-remainder`, the rule a members close splits by. Required everywhere: the top-level keys, `[setup]` with its four keys and -its accounts, the events, and at least one `[[expect]]` with values: a -figure query's are exact decimal strings (`"2340.00"`, `"-12.50"`; never a -float), a format query's are text (below). Amounts on lines are positive; +its accounts, the events (`events = []`, before `[setup]`, when nothing is +posted), and at least one `[[expect]]` with values: a figure query's are +exact decimal strings (`"2340.00"`, `"-12.50"`; never a float), a format +query's are text (below). Amounts on lines are positive; the side gives the direction. ### Optional keys, for the rules that need them @@ -99,26 +100,55 @@ the side gives the direction. - `today` (in `[setup]`): the day the books are asked on, `YYYY-MM-DD`, in the company's time zone: what an omitted date means (B-30). A query that omits a date needs it. -- `cash_accounts`, `financing_accounts`, `investing_accounts`: arrays of - account codes, the statement of cash flows' account roles (ASC - 230-10-45-10 to 45-15). The cash and financing accounts are also what a - payee is paid from, for `vendor_1099_paid` (a payment card is neither: - its charges go on Form 1099-K; rules.md B-19), and the cash accounts - are an IIF file's bank accounts (B-34). +- `cash_accounts`, `financing_accounts`, `investing_accounts` (in + `[setup]`): arrays of account codes, the statement of cash flows' + account roles (ASC 230-10-45-10 to 45-15). The cash and financing + accounts are also what a payee is paid from, for `vendor_1099_paid` (a + payment card is neither: its charges go on Form 1099-K; rules.md B-19); + with the cards, what party money is, for + `party_part` and `party_totals` (B-35 item 5: no `cash_accounts`, every + asset account but receivables); the cash accounts are what the K-1 + worksheet reads as money (`k1`) and an IIF file's bank accounts (B-34). - `[[setup.fixed_assets]]` `{ cost_account, accumulated_account, expense_account }`: accounts that hold property and equipment (their cost and accumulated depreciation are investing; ASC 230-10-45-12(c), 45-13(c)). -- `[[setup.members]]` `{ name, capital_account, share? }`: the members of - a multi-member LLC. `share` is the member's share of income, an exact - decimal fraction (`"0.5"`, `"0.3333"`); every member has one or none - does, and the shares sum to 1. A close to the members and - `members_equity` need them. +- `[[setup.parties]]` `{ name, roles, creditor_of?, tin_last4?, + w9_tin_last4?, tin_purged? }`: the company's directory (B-26). `roles` + are role keys (`member`, `vendor`, `customer`, `contractor`, + `landlord`, `employee`, `financial_institution`, `payout_platform`, + `tax_agency`, `government_agency`); `vendor` or `customer` (one at + most) gives the party its record. `creditor_of`: the liabilities, by + code, the company owes the party itself (a card's issuer, a lender, + each member for what the members advanced: B-35 item 6). `tin_last4`: + its TIN facts' last four (an SSN's); `w9_tin_last4`: its W-9's (a 1099 + role's); `tin_purged = true`: the vault's copy purged, the facts kept. + Never a full TIN. Each name once. +- `[[setup.members]]` `{ name, capital_account, share?, + distributions_account? }`: the members of a multi-member LLC. `share` + is the member's share of income, an exact decimal fraction (`"0.5"`, + `"0.3333"`); every member has one or none does, and the shares sum to + 1. A close to the members, `members_equity` and `k1` need them. + `distributions_account`: the member's distributions account (B-13). A + member whose `name` is a party's in `setup.parties` with the `member` + role is that party, as the company's settings name them: an entry's + parties' shares resolve with those members (B-35), and so does the + 1099 worksheet. - `[[setup.payees]]` `{ name, form }`: a payee marked for an information - return, `form` `"1099-NEC"` or `"1099-MISC"`; each name once. + return from the beginning, `form` `"1099-NEC"` or `"1099-MISC"`; each + name once. A payee in `setup.parties` with the `vendor` role is that + party's vendor record; any other is a vendor record of its own. - `payee` on an event: the payee (by name, one of `setup.payees`) the entry paid. Not on a reversal, which takes its original's, nor on a close. +- `parties = [{ party, amount? }, { party, percent? }, ...]` on an event: + the parties (by name, in `setup.parties`) the entry is with, each with + its share: an `amount` (an exact decimal string), a `percent`, or + neither (derived from the entry: all of it for one party, the members' + capital lines or income shares for several: B-35). An entry naming a + party with a vendor record is tagged with that payee too; with + `payee` as well, the payee's party is one of the parties. + Not on a reversal, which takes its original's. - `close = { start, to, rounding? }` as an event: closes the period from `start` through the event's `date` (rules.md B-11, B-13), `to` `"members"` (split by share, in the order listed) or @@ -150,8 +180,9 @@ A query over a period takes `from` and `to`; a query as of a day takes `as_of`; the other is refused. A date left out is resolved by the one rule (rules.md B-30) on `setup.today`: an omitted `to` is today, an omitted `from` the first day of the fiscal year that holds `to`, an -omitted `as_of` today (`vendor_1099_paid` and the format queries give -theirs). A query no crate answers is refused, never skipped. +omitted `as_of` today (`vendor_1099_paid`, `party_totals` and the +format queries give theirs). A query no crate +answers is refused, never skipped. | query | dates | values | checked by | |---|---|---|---| @@ -161,7 +192,11 @@ theirs). A query no crate answers is refused, never skipped. | `entry` | `entry = N`, no dates | entry N's lines, `".debit"` and `".credit"` each the sum of that account's lines on that side, every line of the entry named | `ledger-core` `tests/bookspec.rs` | | `balances` | `as_of` | each account named by code, its balance on its normal side (a member's capital after a loss is negative); the example names the accounts it shows | `ledger-core` `tests/bookspec.rs` | | `members_equity` | `from`, `to` | the statement of changes in members' equity (rules.md B-13): `".beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | -| `vendor_1099_paid` | `from` (January 1 of `to`'s year), `to` | each payee's payments by name, out of the cash and financing accounts (`cash_accounts` required), every payee listed | `ledger-core` `tests/bookspec.rs` | +| `k1` | `from`, `to` | the K-1 worksheet (B-13; Schedule K-1 Item L): `".beginning"`, `.contributions_cash`, `.contributions_property`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | +| `vendor_1099_paid` | `from` (January 1 of `to`'s year), `to` | each payee's payments that count toward its 1099, by name: out of the cash and financing accounts (`cash_accounts` required), a card's or payment network's left out (B-19), every payee listed | `ledger-core` `tests/bookspec.rs` | +| `party_part` | `entry = N`, `party`, no dates | entry N's part for the party (B-35): `resolved`, signed as a line on the party's own account would be (paid or given positive, received negative), the entry's `direction` (`paid`, `received` or `neither`), and what of it moved as money (`cash`) and as property (`in_kind`), every one given | `ledger-core` `tests/bookspec.rs` | +| `party_totals` | `party`, `basis` (`cash` or `all`), `from`, `to` | what was `paid` to the party and `received` from it over the period (B-35 item 4: basis `cash` money, `all` money and property in kind; each entry on its first original's side), both given | `ledger-core` `tests/bookspec.rs` | +| `party_tin_last4` | `party`, no dates | `tin_last4`: the party's TIN facts' last four, else its W-9's, `""` for none (B-26) | `ledger-core` `tests/bookspec.rs` | | `period` | `from`, `to`, either or both left out | the dates a period report asked with those dates covers, by the one rule (B-30): `from`, `to` (`YYYY-MM-DD`); or `refused` | `ledger-core` `tests/bookspec.rs` | | `day` | `as_of`, or left out | the day a report as of a day is made as of: `as_of` | `ledger-core` `tests/bookspec.rs` | | `calendar_year` | none | the year a calendar-year count (1099s, party totals) asked without one counts: `year` | `ledger-core` `tests/bookspec.rs` | @@ -172,8 +207,9 @@ theirs). A query no crate answers is refused, never skipped. A figure query is a table (`[expect.values]`, or `values = { ... }`); a format query, and `uncategorized_accounts`, an array (`values = [ ... -]`). `period`, `day` and `calendar_year` give dates and a year, and -`account_names` names, not figures. +]`). `period`, `day` and `calendar_year` give dates and a year, +`account_names` names, `party_part`'s `direction` and +`party_tin_last4` words, not figures. A command is tried on a copy of the books, as the books' own command does it, and changes nothing checked after it. It shows no values: it is diff --git a/bookspec/examples/p3-party-accrual-counts-once.toml b/bookspec/examples/p3-party-accrual-counts-once.toml new file mode 100644 index 0000000..8d8d099 --- /dev/null +++ b/bookspec/examples/p3-party-accrual-counts-once.toml @@ -0,0 +1,134 @@ +# P3 defect 24 (found in design; wanderbooks plan/served.md P3 "Settled in +# design"): party totals treated every liability but payables and payroll +# as money, so an accrual through another liability counted when it was +# booked and again when it was settled. Regression test: ledger-core +# tests/spec_party_totals.rs +# defect24_an_accrual_through_another_liability_counts_once. The shape: +# bookspec/README.md. + +id = "p3-party-accrual-counts-once" +title = "An accrual through a liability that isn't money counts once, when it is settled" +rule = """ +Money is cash, the company's cards, the members' financing and loans. +Every other liability (accrued expenses, deferred revenue) is a promise: +an entry through it moves nothing with the party until it is settled in +money. +""" +status = "CHOICE" +sources = [ + "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay is not payment (wanderbooks docs/spec/sources/helvering-v-price-1940.md)", + "DECISION (owner, 2026-10-20): money is cash, cards, the members' financing and loans (wanderbooks rules.md B-35 item 5)", +] +tags = ["parties", "accruals", "deferred-revenue", "money"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "2200" +name = "Accrued Expenses" +type = "liability" + +[[setup.accounts]] +code = "2400" +name = "Deferred Revenue" +type = "liability" + +[[setup.accounts]] +code = "4000" +name = "Revenue" +type = "revenue" + +[[setup.accounts]] +code = "6050" +name = "Domain & Hosting" +type = "expense" + +[[setup.parties]] +name = "Northwind Filings" +roles = ["vendor"] + +[[setup.parties]] +name = "Riley Chen" +roles = ["customer"] + +[[events]] +date = "2026-03-31" +description = "Hosting accrued" +parties = [{ party = "Northwind Filings" }] +lines = [ + { account = "6050", side = "debit", amount = "300.00" }, + { account = "2200", side = "credit", amount = "300.00" }, +] + +[[events]] +date = "2026-04-15" +description = "Hosting paid" +parties = [{ party = "Northwind Filings" }] +lines = [ + { account = "2200", side = "debit", amount = "300.00" }, + { account = "1000", side = "credit", amount = "300.00" }, +] + +[[events]] +date = "2026-01-02" +description = "A year paid ahead" +parties = [{ party = "Riley Chen" }] +lines = [ + { account = "1000", side = "debit", amount = "1200.00" }, + { account = "2400", side = "credit", amount = "1200.00" }, +] + +[[events]] +date = "2026-01-31" +description = "January earned" +parties = [{ party = "Riley Chen" }] +lines = [ + { account = "2400", side = "debit", amount = "100.00" }, + { account = "4000", side = "credit", amount = "100.00" }, +] + +[[expect]] +query = "party_part" +entry = 1 +party = "Northwind Filings" +values = { resolved = "300.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +# Before P3: 600.00. +[[expect]] +query = "party_totals" +party = "Northwind Filings" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "300.00", received = "0.00" } + +[[expect]] +query = "party_part" +entry = 4 +party = "Riley Chen" +values = { resolved = "100.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +# Before P3: 1,300.00. +[[expect]] +query = "party_totals" +party = "Riley Chen" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "1200.00" } diff --git a/bookspec/examples/p3-party-distribution-still-owed.toml b/bookspec/examples/p3-party-distribution-still-owed.toml new file mode 100644 index 0000000..68b868b --- /dev/null +++ b/bookspec/examples/p3-party-distribution-still-owed.toml @@ -0,0 +1,200 @@ +# P3 defect 24 in a member's entry (found in review, 2026-10-21): a +# distribution declared to a liability that isn't money counted as paid +# when declared and again when paid. A distribution's cash is the money it +# paid, its in kind the property it gave; the rest is still owed. Regression +# tests: ledger-core tests/spec_party_totals.rs +# defect24_a_distribution_declared_to_a_payable_counts_once_when_paid and +# a_distribution_of_money_and_depreciated_property_splits_by_what_moved. +# The shape: bookspec/README.md. + +id = "p3-party-distribution-still-owed" +title = "A distribution counts the money it paid and the property it gave; what is still owed counts when paid" +rule = """ +An entry naming only members that moves their capital counts what moved +it. Of a distribution, the cash is the money it paid (through the cash +accounts, a card, the members' financing or a loan), the in kind the +property it gave up (at what the books carried it), and the rest, credited +to a liability that isn't money for the entry (distributions payable, or +what the members are owed when they are named its creditors), is a +promise: it counts nothing until it is paid, then once, as paid. The +statement of members' equity counts a distribution when it is declared, +so party totals catch up with it when it is paid. +""" +status = "AUTHORITY" +sources = [ + "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay is not payment (wanderbooks docs/spec/sources/helvering-v-price-1940.md)", + "Treas. Reg. §1.704-1(b)(2)(iv)(b)(4)-(5): a partner's capital account falls by the money and the property distributed (docs/spec/sources/irs-capital-accounts.md)", + "DECISION (owner, 2026-10-20): a member's entry counts what moved their capital; money is cash, cards, the members' financing and loans (rules.md B-35)", +] +tags = ["parties", "members", "distributions", "promises", "in-kind", "money"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "1510" +name = "Accumulated Depreciation — Equipment" +type = "asset" + +[[setup.accounts]] +code = "2150" +name = "Distributions Payable" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital — Tobin Harlow" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital — Morgan Ellis" +type = "equity" + +[[setup.accounts]] +code = "3100" +name = "Distributions — Tobin Harlow" +type = "equity" + +[[setup.parties]] +name = "Tobin Harlow" +roles = ["member"] + +[[setup.parties]] +name = "Morgan Ellis" +roles = ["member"] + +[[setup.members]] +name = "Tobin Harlow" +share = "0.5" +capital_account = "3000" +distributions_account = "3100" + +[[setup.members]] +name = "Morgan Ellis" +share = "0.5" +capital_account = "3001" + +# 1. A distribution declared to Tobin: still owed, counts nothing. +[[events]] +date = "2026-06-30" +description = "Distribution declared" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "3100", side = "debit", amount = "200.00" }, + { account = "2150", side = "credit", amount = "200.00" }, +] + +# 2. It is paid: paid, money, once. +[[events]] +date = "2026-07-15" +description = "Distribution paid" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "2150", side = "debit", amount = "200.00" }, + { account = "1000", side = "credit", amount = "200.00" }, +] + +# 3. Declared and partly paid at once: the money now, the rest when paid. +[[events]] +date = "2026-08-31" +description = "Distribution, part paid" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "3100", side = "debit", amount = "300.00" }, + { account = "1000", side = "credit", amount = "100.00" }, + { account = "2150", side = "credit", amount = "200.00" }, +] + +# 4. Equipment carried at 1,000.00 less 300.00 depreciated, with 100.00 in +# cash, distributed to Morgan: 100.00 cash, 700.00 in kind. +[[events]] +date = "2026-09-01" +description = "Equipment and cash distributed" +parties = [{ party = "Morgan Ellis" }] +lines = [ + { account = "3001", side = "debit", amount = "800.00" }, + { account = "1510", side = "debit", amount = "300.00" }, + { account = "1500", side = "credit", amount = "1000.00" }, + { account = "1000", side = "credit", amount = "100.00" }, +] + +[[expect]] +query = "party_part" +entry = 1 +party = "Tobin Harlow" +values = { resolved = "200.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 3 +party = "Tobin Harlow" +values = { resolved = "300.00", direction = "paid", cash = "100.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 4 +party = "Morgan Ellis" +values = { resolved = "800.00", direction = "paid", cash = "100.00", in_kind = "700.00" } + +# Before the review: 700.00 (event 1's 200.00 and the 200.00 still owed +# on event 3 counted as paid, and event 2 paid the first again). +[[expect]] +query = "party_totals" +party = "Tobin Harlow" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "300.00", received = "0.00" } + +# Through June: declared, not yet paid. The statement counts it already. +[[expect]] +query = "party_totals" +party = "Tobin Harlow" +basis = "all" +from = "2026-01-01" +to = "2026-06-30" +values = { paid = "0.00", received = "0.00" } + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-06-30" +values = { "Tobin Harlow.contributions" = "0.00", "Tobin Harlow.distributions" = "200.00" } + +# Before the review: 0.00 cash, 800.00 in kind. +[[expect]] +query = "party_totals" +party = "Morgan Ellis" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "100.00", received = "0.00" } + +[[expect]] +query = "party_totals" +party = "Morgan Ellis" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "800.00", received = "0.00" } + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-12-31" +values = { "Morgan Ellis.contributions" = "0.00", "Morgan Ellis.distributions" = "800.00" } diff --git a/bookspec/examples/p3-party-gift-card-is-a-promise.toml b/bookspec/examples/p3-party-gift-card-is-a-promise.toml new file mode 100644 index 0000000..852eaab --- /dev/null +++ b/bookspec/examples/p3-party-gift-card-is-a-promise.toml @@ -0,0 +1,92 @@ +# P3 defect 24 and the card rule (found in review, 2026-10-21): the +# company's cards are the liabilities whose names say "card", so a gift +# card the company sold (deferred revenue) read as money: received when +# sold and again when redeemed. Regression +# test: ledger-core tests/spec_party_totals.rs +# defect24_a_gift_card_sold_is_received_once. +# The shape: bookspec/README.md. + +id = "p3-party-gift-card-is-a-promise" +title = "A gift card the company sold is a promise, not a card it pays with" +rule = """ +The company's cards are liability accounts whose names say "card", but not +"gift card": gift cards the company sold are revenue it owes its customers +in service (deferred revenue), a promise. Selling one is money received +from the buyer; redeeming it moves no money and counts nothing. +""" +status = "CONVENTION" +sources = [ + "DECISION (owner, 2026-10-20): money is cash, cards, the members' financing and loans; every other liability is a promise, counted when settled (wanderbooks rules.md B-35 item 5)", + "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay is not payment (docs/spec/sources/helvering-v-price-1940.md)", +] +tags = ["parties", "cards", "deferred-revenue", "money"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "2050" +name = "Business Card" +type = "liability" + +[[setup.accounts]] +code = "2410" +name = "Gift Card Liability" +type = "liability" + +[[setup.accounts]] +code = "4000" +name = "Revenue" +type = "revenue" + +[[setup.parties]] +name = "Riley Chen" +roles = ["customer"] + +[[events]] +date = "2026-03-01" +description = "Gift card sold" +parties = [{ party = "Riley Chen" }] +lines = [ + { account = "1000", side = "debit", amount = "100.00" }, + { account = "2410", side = "credit", amount = "100.00" }, +] + +[[events]] +date = "2026-03-05" +description = "Gift card redeemed" +parties = [{ party = "Riley Chen" }] +lines = [ + { account = "2410", side = "debit", amount = "100.00" }, + { account = "4000", side = "credit", amount = "100.00" }, +] + +[[expect]] +query = "party_part" +entry = 1 +party = "Riley Chen" +values = { resolved = "-100.00", direction = "received", cash = "-100.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 2 +party = "Riley Chen" +values = { resolved = "100.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +# Before the review: 200.00. +[[expect]] +query = "party_totals" +party = "Riley Chen" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "100.00" } diff --git a/bookspec/examples/p3-party-members-equity-and-k1.toml b/bookspec/examples/p3-party-members-equity-and-k1.toml new file mode 100644 index 0000000..f9b20ee --- /dev/null +++ b/bookspec/examples/p3-party-members-equity-and-k1.toml @@ -0,0 +1,219 @@ +# P3 defect 14, settled in design (wanderbooks plan/served.md P3): party +# totals count a member's entry by what moved their capital, so basis all +# is the statement of members' equity and basis cash means what it means +# on the K-1 worksheet. Regression tests: ledger-core +# tests/spec_party_totals.rs a_members_mixed_entry_splits_as_the_k1_does, +# basis_all_is_the_members_equity_statement_and_cash_the_k1s, and the +# property member_totals_are_the_members_equity_statement. The shape: +# bookspec/README.md. + +id = "p3-party-members-equity-and-k1" +title = "A member's totals are their capital account: basis all the statement of members' equity, basis cash the K-1's cash" +rule = """ +An entry naming only members that moves their equity counts what moved +their capital: a contribution received, a distribution paid. The +property in it (asset accounts that are neither cash nor receivables) is +in kind, shared among the members in proportion to their parts, to the +cent, leftover cents by largest remainder, ties to the member listed +first; the rest is cash, as the K-1 worksheet counts it: money, or what a +member paid for the company (an expense booked straight to capital, a +debt to them converted to capital). +""" +status = "CHOICE" +sources = [ + "Treas. Reg. §1.704-1(b)(2)(iv)(b)(1)-(2), (c)(1): money and property contributed; a partnership liability a partner pays is money contributed (wanderbooks docs/spec/sources/irs-capital-accounts.md)", + "Partner's Instructions for Schedule K-1 (Form 1065), Item L: the capital contributed during the year (docs/spec/sources/irs-capital-accounts.md)", + "DECISION (owner, 2026-10-15): what a member paid for the company is cash on the K-1 (wanderbooks ledger-core k1 INVARIANT 3); DECISION (owner, 2026-10-20): a member's entry counts what moved their capital (rules.md B-35)", +] +tags = ["parties", "members", "k1", "members-equity", "in-kind", "rounding"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "largest-remainder" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital — Tobin Harlow" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital — Morgan Ellis" +type = "equity" + +[[setup.accounts]] +code = "6050" +name = "Domain & Hosting" +type = "expense" + +[[setup.parties]] +name = "Tobin Harlow" +roles = ["member"] + +[[setup.parties]] +name = "Morgan Ellis" +roles = ["member"] + +[[setup.members]] +name = "Tobin Harlow" +share = "0.5" +capital_account = "3000" + +[[setup.members]] +name = "Morgan Ellis" +share = "0.5" +capital_account = "3001" + +# 1. A joint contribution of cash and property (the K-1 worksheet's +# SCENARIO 3): 500.00 each, of it 300.00 cash and 200.00 in kind. +[[events]] +date = "2026-02-01" +description = "Joint contribution, cash and equipment" +parties = [{ party = "Morgan Ellis" }, { party = "Tobin Harlow" }] +lines = [ + { account = "1000", side = "debit", amount = "600.00" }, + { account = "1500", side = "debit", amount = "400.00" }, + { account = "3000", side = "credit", amount = "500.00" }, + { account = "3001", side = "credit", amount = "500.00" }, +] + +# 2. One cent of property shared by two equal parts: the cent goes to the +# member listed first in the company's members (Tobin), whatever the +# order the entry names them in. +[[events]] +date = "2026-02-02" +description = "A cent of property" +parties = [{ party = "Morgan Ellis" }, { party = "Tobin Harlow" }] +lines = [ + { account = "1000", side = "debit", amount = "0.99" }, + { account = "1500", side = "debit", amount = "0.01" }, + { account = "3000", side = "credit", amount = "0.50" }, + { account = "3001", side = "credit", amount = "0.50" }, +] + +# 3. An expense Morgan paid, booked straight to capital: cash. +[[events]] +date = "2026-02-03" +description = "Hosting Morgan paid" +parties = [{ party = "Morgan Ellis" }] +lines = [ + { account = "6050", side = "debit", amount = "50.00" }, + { account = "3001", side = "credit", amount = "50.00" }, +] + +# 4. What the company owed Tobin, converted to capital: cash. +[[events]] +date = "2026-02-04" +description = "Advance converted" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "2100", side = "debit", amount = "70.00" }, + { account = "3000", side = "credit", amount = "70.00" }, +] + +[[expect]] +query = "party_part" +entry = 1 +party = "Tobin Harlow" +values = { resolved = "-500.00", direction = "received", cash = "-300.00", in_kind = "-200.00" } + +[[expect]] +query = "party_part" +entry = 2 +party = "Tobin Harlow" +values = { resolved = "-0.50", direction = "received", cash = "-0.49", in_kind = "-0.01" } + +[[expect]] +query = "party_part" +entry = 2 +party = "Morgan Ellis" +values = { resolved = "-0.50", direction = "received", cash = "-0.50", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 3 +party = "Morgan Ellis" +values = { resolved = "-50.00", direction = "received", cash = "-50.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 4 +party = "Tobin Harlow" +values = { resolved = "-70.00", direction = "received", cash = "-70.00", in_kind = "0.00" } + +# Tobin: cash 300.00 + 0.49 + 70.00; in kind 200.00 + 0.01. +[[expect]] +query = "party_totals" +party = "Tobin Harlow" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "370.49" } + +[[expect]] +query = "party_totals" +party = "Tobin Harlow" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "570.50" } + +# Morgan: cash 300.00 + 0.50 + 50.00; in kind 200.00. +[[expect]] +query = "party_totals" +party = "Morgan Ellis" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "350.50" } + +[[expect]] +query = "party_totals" +party = "Morgan Ellis" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "550.50" } + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-12-31" +values = { "Tobin Harlow.contributions" = "570.50", "Tobin Harlow.distributions" = "0.00" } + +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-12-31" +values = { "Morgan Ellis.contributions" = "550.50", "Morgan Ellis.distributions" = "0.00" } + +[[expect]] +query = "k1" +from = "2026-01-01" +to = "2026-12-31" +values = { "Tobin Harlow.contributions_cash" = "370.49", "Tobin Harlow.contributions_property" = "200.01" } + +[[expect]] +query = "k1" +from = "2026-01-01" +to = "2026-12-31" +values = { "Morgan Ellis.contributions_cash" = "350.50", "Morgan Ellis.contributions_property" = "200.00" } diff --git a/bookspec/examples/p3-party-own-liability-never-pays.toml b/bookspec/examples/p3-party-own-liability-never-pays.toml new file mode 100644 index 0000000..15f5b1b --- /dev/null +++ b/bookspec/examples/p3-party-own-liability-never-pays.toml @@ -0,0 +1,240 @@ +# P3 defect 24 and wanderbooks rules.md B-35 item 6: a credit to a +# liability the company owes a party itself never counts as paying that +# party. Regression test: ledger-core tests/spec_party_totals.rs +# defect24_a_credit_to_a_partys_own_liability_never_pays_it. The shape: +# bookspec/README.md. + +id = "p3-party-own-liability-never-pays" +title = "A credit to what the company owes a party itself never pays it" +rule = """ +A party may be named the creditor of the liabilities the company owes it +itself: a card's issuer, a loan's lender, each member for what the +members advanced. For an entry naming it, a credit to one is a promise, +not a payment: a fee on the issuer's own card, equipment bought on the +seller's own note, a distribution credited to what the members are owed. +It counts when it is paid. Property a member contributes subject to a +loan counts at its value net of the loan, in kind. +""" +status = "AUTHORITY" +sources = [ + "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay, even a secured one, is not payment (wanderbooks docs/spec/sources/helvering-v-price-1940.md)", + "Treas. Reg. §1.704-1(b)(2)(iv)(b)(2): property contributed, net of liabilities the partnership takes it subject to (docs/spec/sources/irs-capital-accounts.md)", + "DECISION (owner, 2026-10-20): a liability credit never counts as paying that liability's own creditor (rules.md B-35)", +] +tags = ["parties", "creditors", "cards", "notes", "distributions", "members"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] +financing_accounts = ["2100", "2500"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "2050" +name = "Business Card" +type = "liability" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "2500" +name = "Note Payable" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital — Tobin Harlow" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital — Morgan Ellis" +type = "equity" + +[[setup.accounts]] +code = "6400" +name = "Bank Fees" +type = "expense" + +[[setup.parties]] +name = "Harbor Bank" +roles = ["financial_institution"] +creditor_of = ["2050"] + +[[setup.parties]] +name = "Lakeside Supply" +roles = [] +creditor_of = ["2500"] + +[[setup.parties]] +name = "Tobin Harlow" +roles = ["member"] +creditor_of = ["2100"] + +[[setup.parties]] +name = "Morgan Ellis" +roles = ["member"] +creditor_of = ["2100"] + +[[setup.members]] +name = "Tobin Harlow" +share = "0.5" +capital_account = "3000" + +[[setup.members]] +name = "Morgan Ellis" +share = "0.5" +capital_account = "3001" + +# 1. The issuer's fee on its own card: neither. +[[events]] +date = "2026-05-01" +description = "Card fee" +parties = [{ party = "Harbor Bank" }] +lines = [ + { account = "6400", side = "debit", amount = "25.00" }, + { account = "2050", side = "credit", amount = "25.00" }, +] + +# 2. The card paid off: paid to the issuer. +[[events]] +date = "2026-05-20" +description = "Card payment" +parties = [{ party = "Harbor Bank" }] +lines = [ + { account = "2050", side = "debit", amount = "125.00" }, + { account = "1000", side = "credit", amount = "125.00" }, +] + +# 3. A distribution to Tobin credited to what the members are owed: +# still owed, counts as nothing. +[[events]] +date = "2026-06-30" +description = "Distribution declared" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "3000", side = "debit", amount = "200.00" }, + { account = "2100", side = "credit", amount = "200.00" }, +] + +# 4. It is paid: paid, money. +[[events]] +date = "2026-07-15" +description = "Distribution paid" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "2100", side = "debit", amount = "200.00" }, + { account = "1000", side = "credit", amount = "200.00" }, +] + +# 5. Equipment on the seller's own note: neither. +[[events]] +date = "2026-08-01" +description = "Equipment on a note" +parties = [{ party = "Lakeside Supply" }] +lines = [ + { account = "1500", side = "debit", amount = "900.00" }, + { account = "2500", side = "credit", amount = "900.00" }, +] + +# 6. A payment on the note: paid. +[[events]] +date = "2026-09-01" +description = "Note payment" +parties = [{ party = "Lakeside Supply" }] +lines = [ + { account = "2500", side = "debit", amount = "300.00" }, + { account = "1000", side = "credit", amount = "300.00" }, +] + +# 7. Equipment worth 1,000.00 Morgan contributes subject to 200.00 owed +# on the note: 800.00 received, in kind (before P3: "paid 200.00"). +[[events]] +date = "2026-09-15" +description = "Equipment contributed with its loan" +parties = [{ party = "Morgan Ellis" }] +lines = [ + { account = "1500", side = "debit", amount = "1000.00" }, + { account = "2500", side = "credit", amount = "200.00" }, + { account = "3001", side = "credit", amount = "800.00" }, +] + +[[expect]] +query = "party_part" +entry = 1 +party = "Harbor Bank" +values = { resolved = "-25.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +# Before naming the issuer the card's creditor: 150.00. +[[expect]] +query = "party_totals" +party = "Harbor Bank" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "125.00", received = "0.00" } + +[[expect]] +query = "party_part" +entry = 3 +party = "Tobin Harlow" +values = { resolved = "200.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +[[expect]] +query = "party_totals" +party = "Tobin Harlow" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "200.00", received = "0.00" } + +[[expect]] +query = "party_part" +entry = 5 +party = "Lakeside Supply" +values = { resolved = "-900.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +[[expect]] +query = "party_totals" +party = "Lakeside Supply" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "300.00", received = "0.00" } + +[[expect]] +query = "party_part" +entry = 7 +party = "Morgan Ellis" +values = { resolved = "-800.00", direction = "received", cash = "0.00", in_kind = "-800.00" } + +[[expect]] +query = "party_totals" +party = "Morgan Ellis" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "0.00" } + +[[expect]] +query = "party_totals" +party = "Morgan Ellis" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "800.00" } diff --git a/bookspec/examples/p3-party-reversed-card-payment.toml b/bookspec/examples/p3-party-reversed-card-payment.toml new file mode 100644 index 0000000..4c39c96 --- /dev/null +++ b/bookspec/examples/p3-party-reversed-card-payment.toml @@ -0,0 +1,99 @@ +# P3 defect 14, the second bug found in design (wanderbooks P3 brief +# §1.4): a member pays the card from their own funds; reversing it read +# as a second payment. Regression test: ledger-core +# tests/spec_party_totals.rs +# defect14_a_reversed_member_card_payment_nets_to_zero. The shape: +# bookspec/README.md (`after = N` asks a query after event N). + +id = "p3-party-reversed-card-payment" +title = "A reversed payment of one liability by another nets to zero" +rule = """ +A reversal's part for each party is its original's negated, never read +from the reversal's own lines. A member paying the company's card from +their own funds (Dr card / Cr due to members), tagged with the card's +issuer, is paid to the issuer; its reversal takes that back, so the pair +nets to zero; reversing the reversal pays it again. +""" +status = "AUTHORITY" +sources = [ + "Pacioli, Summa (1494), Geijsbeek tr.: a mistake is corrected by the opposite entry (wanderbooks docs/spec/sources/pacioli-1494.md, rules.md B-9)", +] +tags = ["parties", "reversals", "liabilities"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "2050" +name = "Business Card" +type = "liability" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.parties]] +name = "Harbor Bank" +roles = ["financial_institution"] + +[[events]] +date = "2026-06-01" +description = "A member pays the card from their own funds" +parties = [{ party = "Harbor Bank" }] +lines = [ + { account = "2050", side = "debit", amount = "100.00" }, + { account = "2100", side = "credit", amount = "100.00" }, +] + +[[events]] +date = "2026-06-02" +description = "Reversed" +reverses = 1 + +[[events]] +date = "2026-06-03" +description = "The reversal reversed" +reverses = 2 + +[[expect]] +query = "party_part" +entry = 1 +party = "Harbor Bank" +values = { resolved = "100.00", direction = "paid", cash = "100.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 2 +party = "Harbor Bank" +values = { resolved = "-100.00", direction = "received", cash = "-100.00", in_kind = "0.00" } + +# After the reversal: nothing paid (before P3: 200.00, the reversal read +# as a second payment). +[[expect]] +query = "party_totals" +after = 2 +party = "Harbor Bank" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "0.00", received = "0.00" } + +[[expect]] +query = "party_totals" +after = 3 +party = "Harbor Bank" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "100.00", received = "0.00" } diff --git a/bookspec/examples/p3-party-signs-and-reversals.toml b/bookspec/examples/p3-party-signs-and-reversals.toml new file mode 100644 index 0000000..85fc5c3 --- /dev/null +++ b/bookspec/examples/p3-party-signs-and-reversals.toml @@ -0,0 +1,289 @@ +# P3 defect 14 (wanderbooks plan/served.md P3; audit findings F36, F37): +# a party's part of an entry is signed as a line on the party's own +# account, with its direction and what of it was money or property; a +# reversal is the exact opposite of what it reverses; party totals take a +# basis. Regression test: ledger-core tests/spec_party_totals.rs +# defect14_shares_take_the_partys_side_and_reversals_flip. The shape: +# bookspec/README.md. + +id = "p3-party-signs-and-reversals" +title = "A party's part of an entry is signed as its own account would show it, and a reversal is the exact opposite" +rule = """ +A party's part of an entry is debit-positive, as a line on the party's own +account would be: positive when the company paid it or gave it value, +negative when the company received from it. Each part has a direction +(paid, received, neither) and a cash and an in-kind amount. An entry +naming only members that moves equity counts what moved their capital, +the property in it in kind. A reversal's part is exactly its original's +negated. A bill or invoice moves nothing with the party yet (neither). +Party totals on basis cash count the cash parts, on basis all the cash +and in-kind parts, each on its root original's side. +""" +status = "CONVENTION" +sources = [ + "Pacioli, Summa (1494), Geijsbeek tr., ch. 36: debit is what owes, credit what is owed (wanderbooks docs/spec/sources/pacioli-1494.md)", + "Treas. Reg. §1.704-1(b)(2)(iv)(b)(1)-(2), (4)-(5): a partner's capital account rises by money and property contributed, falls by money and property distributed (docs/spec/sources/irs-capital-accounts.md)", + "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay is not payment (docs/spec/sources/helvering-v-price-1940.md)", + "DECISION (owner, 2026-10-20): a member's entry counts what moved their capital (wanderbooks rules.md B-35)", +] +tags = ["parties", "signs", "reversals", "members", "in-kind", "basis"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "1200" +name = "Accounts Receivable" +type = "asset" + +[[setup.accounts]] +code = "1500" +name = "Equipment" +type = "asset" + +[[setup.accounts]] +code = "2000" +name = "Accounts Payable" +type = "liability" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital — Tobin Harlow" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital — Morgan Ellis" +type = "equity" + +[[setup.accounts]] +code = "3999" +name = "Retained Earnings" +type = "equity" + +[[setup.accounts]] +code = "4000" +name = "Revenue" +type = "revenue" + +[[setup.accounts]] +code = "6000" +name = "Organizational Costs" +type = "expense" + +[[setup.parties]] +name = "Tobin Harlow" +roles = ["member"] + +[[setup.parties]] +name = "Morgan Ellis" +roles = ["member"] + +[[setup.parties]] +name = "Northwind Filings" +roles = ["vendor"] + +[[setup.parties]] +name = "Riley Chen" +roles = ["customer"] + +[[setup.members]] +name = "Tobin Harlow" +share = "0.5" +capital_account = "3000" + +[[setup.members]] +name = "Morgan Ellis" +share = "0.5" +capital_account = "3001" + +# 1. Tobin pays the filing fee for the company (through the members' +# financing account), tagged with the vendor: paid to the vendor, money. +[[events]] +date = "2026-10-04" +description = "Filing fee, paid by Tobin" +parties = [{ party = "Northwind Filings" }] +lines = [ + { account = "6000", side = "debit", amount = "64.00" }, + { account = "2100", side = "credit", amount = "64.00" }, +] + +# 2. Tobin's cash contribution: received, money. +[[events]] +date = "2026-10-05" +description = "Tobin's contribution" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "1000", side = "debit", amount = "400.00" }, + { account = "3000", side = "credit", amount = "400.00" }, +] + +# 3. Reimbursing Tobin for the fee: paid, money (cash paying down what +# the company owed him). +[[events]] +date = "2026-10-08" +description = "Reimbursement" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "2100", side = "debit", amount = "64.00" }, + { account = "1000", side = "credit", amount = "64.00" }, +] + +# 4. A laptop Tobin contributes: received, in kind. +[[events]] +date = "2026-10-12" +description = "Laptop contributed" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "1500", side = "debit", amount = "1480.00" }, + { account = "3000", side = "credit", amount = "1480.00" }, +] + +# 5. Its reversal: the exact opposite, paid, in kind. +[[events]] +date = "2026-10-13" +description = "Laptop contribution reversed" +reverses = 4 + +# 6. The laptop again, half each, by amount. +[[events]] +date = "2026-10-14" +description = "Laptop contributed jointly" +parties = [{ party = "Tobin Harlow", amount = "740.00" }, { party = "Morgan Ellis", amount = "740.00" }] +lines = [ + { account = "1500", side = "debit", amount = "1480.00" }, + { account = "3000", side = "credit", amount = "740.00" }, + { account = "3001", side = "credit", amount = "740.00" }, +] + +# 7. A bill from the vendor (credit to payables, its own account): neither. +[[events]] +date = "2026-10-20" +description = "Bill" +parties = [{ party = "Northwind Filings" }] +lines = [ + { account = "6000", side = "debit", amount = "80.00" }, + { account = "2000", side = "credit", amount = "80.00" }, +] + +# 8. The bill reversed: the exact opposite, still neither. +[[events]] +date = "2026-10-21" +description = "Bill reversed" +reverses = 7 + +# 9. An invoice to the customer (a debit to receivables): neither. +[[events]] +date = "2026-10-20" +description = "Invoice" +parties = [{ party = "Riley Chen" }] +lines = [ + { account = "1200", side = "debit", amount = "120.00" }, + { account = "4000", side = "credit", amount = "120.00" }, +] + +[[expect]] +query = "party_part" +entry = 1 +party = "Northwind Filings" +values = { resolved = "64.00", direction = "paid", cash = "64.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 2 +party = "Tobin Harlow" +values = { resolved = "-400.00", direction = "received", cash = "-400.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 3 +party = "Tobin Harlow" +values = { resolved = "64.00", direction = "paid", cash = "64.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 4 +party = "Tobin Harlow" +values = { resolved = "-1480.00", direction = "received", cash = "0.00", in_kind = "-1480.00" } + +[[expect]] +query = "party_part" +entry = 5 +party = "Tobin Harlow" +values = { resolved = "1480.00", direction = "paid", cash = "0.00", in_kind = "1480.00" } + +[[expect]] +query = "party_part" +entry = 6 +party = "Morgan Ellis" +values = { resolved = "-740.00", direction = "received", cash = "0.00", in_kind = "-740.00" } + +[[expect]] +query = "party_part" +entry = 7 +party = "Northwind Filings" +values = { resolved = "-80.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 8 +party = "Northwind Filings" +values = { resolved = "80.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +[[expect]] +query = "party_part" +entry = 9 +party = "Riley Chen" +values = { resolved = "120.00", direction = "neither", cash = "0.00", in_kind = "0.00" } + +# Tobin: cash in 400.00 (event 2), paid back 64.00 (event 3); in kind +# 1,480.00 − 1,480.00 (events 4 and 5 net) + 740.00 (event 6). +[[expect]] +query = "party_totals" +party = "Tobin Harlow" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "64.00", received = "400.00" } + +[[expect]] +query = "party_totals" +party = "Tobin Harlow" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "64.00", received = "1140.00" } + +# The vendor: the fee a member paid for the company; the bill and its +# reversal count nothing. +[[expect]] +query = "party_totals" +party = "Northwind Filings" +basis = "all" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "64.00", received = "0.00" } + +# Basis all over Tobin's equity entries is the statement of members' +# equity: contributions 400.00 + 740.00 (events 4 and 5 cancel). +[[expect]] +query = "members_equity" +from = "2026-01-01" +to = "2026-12-31" +values = { "Tobin Harlow.contributions" = "1140.00", "Tobin Harlow.distributions" = "0.00" } + diff --git a/bookspec/examples/p3-party-tin-last4.toml b/bookspec/examples/p3-party-tin-last4.toml new file mode 100644 index 0000000..6bffe0d --- /dev/null +++ b/bookspec/examples/p3-party-tin-last4.toml @@ -0,0 +1,86 @@ +# P3 defect 11 (audit finding F28): the directory showed a member's TIN as +# missing because it read W-9 facts only. Regression tests: ledger-core +# tests/spec_party_totals.rs +# defect11_a_partys_tin_last4_is_its_tin_facts_else_its_w9s; ledger-mcp +# tests/spec_vault.rs defect11_the_directory_shows_a_members_tin_last4. +# The shape: bookspec/README.md. + +id = "p3-party-tin-last4" +title = "A party's TIN last four is its TIN facts', else its W-9's" +rule = """ +The books keep a taxpayer identification number truncated to its last +four digits. A party's last four is its TIN facts' (a member's SSN for +the K-1), else its W-9 facts'; the two never disagree. A TIN purged from +the vault keeps its last four in the books. A party with neither has +none. +""" +status = "AUTHORITY" +sources = [ + "Treas. Reg. §301.6109-4(a): a truncated TIN shows the last four digits (wanderbooks docs/spec/sources/irs-truncated-tin.md)", + "DECISION (owner, 2026-10-15): a party's TIN facts and W-9 facts never disagree (wanderbooks ledger-core parties INVARIANT 2a)", +] +tags = ["parties", "tin", "w9", "k1"] +# Nothing is posted: the facts are the directory's. +events = [] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "accrual" +rounding = "half-even" + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.parties]] +name = "Tobin Harlow" +roles = ["member"] +tin_last4 = "4321" + +[[setup.parties]] +name = "Riley Chen" +roles = ["contractor"] +w9_tin_last4 = "8765" + +[[setup.parties]] +name = "Morgan Ellis" +roles = ["member", "contractor"] +tin_last4 = "1111" +w9_tin_last4 = "1111" + +[[setup.parties]] +name = "Lakeside Supply" +roles = [] + +[[setup.parties]] +name = "Avery Quinn" +roles = ["member"] +tin_last4 = "2468" +tin_purged = true + +[[expect]] +query = "party_tin_last4" +party = "Tobin Harlow" +values = { tin_last4 = "4321" } + +[[expect]] +query = "party_tin_last4" +party = "Riley Chen" +values = { tin_last4 = "8765" } + +[[expect]] +query = "party_tin_last4" +party = "Morgan Ellis" +values = { tin_last4 = "1111" } + +[[expect]] +query = "party_tin_last4" +party = "Lakeside Supply" +values = { tin_last4 = "" } + +[[expect]] +query = "party_tin_last4" +party = "Avery Quinn" +values = { tin_last4 = "2468" } diff --git a/crates/ledger-core/src/form1099.rs b/crates/ledger-core/src/form1099.rs index cb12fe5..718e1fe 100644 --- a/crates/ledger-core/src/form1099.rs +++ b/crates/ledger-core/src/form1099.rs @@ -223,10 +223,11 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result t, None => default_threshold(year)?, }; - let money = crate::parties::money_accounts(ledger, &sources.cash); + let money = crate::parties::money_accounts(ledger, &sources.cash, &sources.financing); let mut paid: std::collections::HashMap = Default::default(); let mut unresolved = Vec::new(); for entry in ledger.posted_entries() { @@ -293,12 +294,10 @@ pub fn vendor_1099_report_with_members( cash } else { let party = ledger.party_of_vendor(vendor); - match crate::shares::resolve(entry, shares, &money, members) { - Ok(parts) => { - match parts.into_iter().find(|(p, _)| Some(*p) == party) { - Some((_, share)) => { - vendor_part(cash, share, crate::shares::share_target(entry, &money)?)? - } + match crate::shares::resolve(ledger, entry.id(), shares, &money, members) { + Ok(resolved) => { + match resolved.parts.iter().find(|p| Some(p.party) == party) { + Some(share) => vendor_part(cash, share.amount, resolved.target.amount)?, // Tagged by hand with a vendor none of the parties // is: nothing to scale by, so the whole payment. None => { diff --git a/crates/ledger-core/src/k1.rs b/crates/ledger-core/src/k1.rs index ce029bd..f8024a8 100644 --- a/crates/ledger-core/src/k1.rs +++ b/crates/ledger-core/src/k1.rs @@ -281,7 +281,7 @@ pub fn k1_detail( /// largest remainder. Each part is at most its own credit. /// Refused when whole × credit is beyond exact arithmetic (credits near /// the line limit; P0 review round 3, S1). -fn share_property( +pub(crate) fn share_property( pool: Decimal, credits: &[Decimal], places: u32, diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 8252efa..40d659c 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -36,7 +36,10 @@ //! to zero, and a reversal's parts are the original's negated (B-14); //! * what a member or lender paid or received for the company, with no //! cash account moving, is split among the kinds it settled, netting to -//! zero, and mirrored by a reversal (B-14 rule 11). +//! zero, and mirrored by a reversal (B-14 rule 11); +//! * a party's part of an entry is signed as a line on the party's own +//! account would be, a reversal's is its original's negated, and what of +//! it counts as money or as property never exceeds it (B-35). use crate::{AccountType, Side}; use rust_decimal::Decimal; @@ -642,6 +645,224 @@ pub fn exchanged(debits: T, credits: T, inflow: T, outflow: T) -> Opt Some(d.min(c).max(T::ZERO)) } +/// Which way an entry moved value with its parties (rules.md B-35). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Direction { + /// The company paid the party, or gave it value: a line on the + /// party's own account would be a debit. + Paid, + /// The company received from the party: a line on its own account + /// would be a credit. + Received, + /// Neither: nothing moved with the party yet (a bill, an invoice, a + /// fee charged on its own card, a distribution still owed), or the + /// entry moved nothing with anyone (a close, a reclassification). + /// Counted in no total. + Neither, +} + +impl Direction { + /// The other way: paid and received swap; neither stays neither. + pub fn flip(self) -> Self { + match self { + Direction::Paid => Direction::Received, + Direction::Received => Direction::Paid, + Direction::Neither => Direction::Neither, + } + } + + /// The direction of a signed amount: paid above zero, received below, + /// neither at zero. + pub fn of(amount: T) -> Self { + if amount > T::ZERO { + Direction::Paid + } else if amount < T::ZERO { + Direction::Received + } else { + Direction::Neither + } + } + + /// Its wire name: `paid`, `received`, or `neither`. + pub fn key(self) -> &'static str { + match self { + Direction::Paid => "paid", + Direction::Received => "received", + Direction::Neither => "neither", + } + } +} + +/// What an entry's parties split, and what of it counts in their totals +/// (rules.md B-35). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PartyTarget { + /// The whole, signed as a line on the parties' own account would be + /// (debit-positive): positive, the company paid or gave; negative, it + /// received. + pub amount: T, + /// Which way value moved. + pub direction: Direction, + /// The part of `amount` that moved as money (of a member's + /// contribution, what the K-1 worksheet counts as cash; of a + /// distribution, the money it paid): counted on both bases. + pub cash: T, + /// The part of `amount` that moved as property: counted on basis + /// "all" only. + pub in_kind: T, +} + +/// The magnitude of `a`, or `None` past the type's range. +fn magnitude(a: T) -> Option { + if a < T::ZERO { + a.checked_neg() + } else { + Some(a) + } +} + +/// The magnitude `m` with the sign of `like`, never a negative zero (a +/// zero `m` is the plain zero, whatever sign arithmetic left on it). +fn with_sign(like: T, m: T) -> Option { + if m == T::ZERO { + Some(T::ZERO) + } else if like < T::ZERO { + m.checked_neg() + } else { + Some(m) + } +} + +/// The smaller of `a` and `b`. +fn least(a: T, b: T) -> T { + if a < b { a } else { b } +} + +/// What an entry's parties split and what of it counts (rules.md B-35 +/// item 3, every case but a close and a reversal, which the caller +/// handles: a close is its parties' own-account movement with direction +/// neither, a reversal is [`reverse_target`] of its original's). +/// +/// ```text +/// GOAL: one signed whole for an entry's parties, debit-positive as a line +/// on their own account, with its direction and what of it moved as +/// money or as property, so a property contribution and its +/// reversal never read alike, and a promise is never counted as if +/// it were paid. +/// SOURCE: rules.md B-35 (B-2 signs; DECISION, owner, 2026-10-20: a +/// member's entry counts what moved their capital, money is cash, +/// cards, the members' financing and loans, and a liability credit +/// never counts as paying that liability's own creditor; Helvering +/// v. Price: a promise to pay is not payment). +/// INPUTS (each signed debit-positive over the entry's lines): +/// capital: Some((e, property)) when every party named is a member: e +/// the movement of equity other than retained earnings; property what +/// property accounts took in (their debits, when e < 0) or gave up +/// (their credits, when e > 0). +/// net: the money paid (+) or received (−) (parties::entry_flow, a +/// named party's own liabilities not counted as money). +/// own: the movement of the parties' own accounts (payables, +/// receivables, liabilities owed to a party named). +/// debits: the entry's total debits. +/// INVARIANTS: +/// 1. capital with e < 0 (a contribution): amount = e; |in_kind| = +/// min(|e|, property⁺); cash the rest (what the K-1 worksheet counts +/// as cash: money, or what the member paid for the company). +/// 2. capital with e > 0 (a distribution): amount = e; cash = min(e, +/// net⁺), the money it paid; in_kind = min(e − cash, property⁺), the +/// property it gave; the rest (credited to a liability that isn't +/// money for the entry: a distribution declared, still owed) counts +/// as nothing until it is paid. +/// 3. In 1 and 2, direction is the sign of e, or neither when nothing +/// counts. +/// 4. Else net ≠ 0: amount = cash = net, in_kind = 0, direction its +/// sign. +/// 5. Else amount = own when it isn't zero, else debits; neither. +/// 6. cash and in_kind each have amount's sign or are zero (never a +/// negative zero), |cash| + |in_kind| ≤ |amount|, and direction is +/// neither exactly when both are zero. +/// 7. Odd, but for the debits fallback and a member's entry whose cash +/// isn't exactly its money (an expense booked straight to capital, a +/// debt converted, a distribution still owed): negating e, net and +/// own (property comes from the other side of the lines, so it keeps +/// its size) gives reverse_target of the result. +/// ``` +/// +/// `None` past the type's range. +pub fn party_target( + capital: Option<(T, T)>, + net: T, + own: T, + debits: T, +) -> Option> { + if let Some((e, property)) = capital + && e != T::ZERO + { + let whole = magnitude(e)?; + let property = if property > T::ZERO { + property + } else { + T::ZERO + }; + let (cash, in_kind) = if e < T::ZERO { + let in_kind = least(property, whole); + (whole.checked_sub(in_kind)?, in_kind) + } else { + let paid = if net > T::ZERO { + least(net, whole) + } else { + T::ZERO + }; + (paid, least(property, whole.checked_sub(paid)?)) + }; + let counted = cash.checked_add(in_kind)?; + return Some(PartyTarget { + amount: e, + direction: if counted == T::ZERO { + Direction::Neither + } else { + Direction::of(e) + }, + cash: with_sign(e, cash)?, + in_kind: with_sign(e, in_kind)?, + }); + } + if net != T::ZERO { + return Some(PartyTarget { + amount: net, + direction: Direction::of(net), + cash: net, + in_kind: T::ZERO, + }); + } + Some(PartyTarget { + amount: if own != T::ZERO { own } else { debits }, + direction: Direction::Neither, + cash: T::ZERO, + in_kind: T::ZERO, + }) +} + +/// A reversal's target from its original's (rules.md B-35 item 3; B-9: a +/// correction is the opposite entry): every amount negated (never to a +/// negative zero), paid and received swapped. An involution. `None` past +/// the type's range. +pub fn reverse_target(t: PartyTarget) -> Option> { + let neg = |a: T| { + if a == T::ZERO { + Some(T::ZERO) + } else { + a.checked_neg() + } + }; + Some(PartyTarget { + amount: neg(t.amount)?, + direction: t.direction.flip(), + cash: neg(t.cash)?, + in_kind: neg(t.in_kind)?, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 8d7caf4..620d294 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -2358,6 +2358,14 @@ impl Ledger { .collect() } + /// Whether `entry` is a period's closing entry ([`Self::closing_entry_ids`] + /// without building the set: O(periods)). + pub fn is_closing_entry(&self, entry: EntryId) -> bool { + self.periods + .values() + .any(|p| p.closing_entry == Some(entry)) + } + /// All periods, in creation order. pub fn list_periods(&self) -> Vec<&Period> { let mut ids: Vec = self.periods.keys().copied().collect(); @@ -3272,6 +3280,7 @@ impl Ledger { tin: None, vendor: None, customer: None, + creditor_of: std::collections::BTreeSet::new(), }, ); id @@ -3397,6 +3406,7 @@ impl Ledger { tin: None, vendor: None, customer: None, + creditor_of: std::collections::BTreeSet::new(), }, ); Ok(id) @@ -3653,6 +3663,95 @@ impl Ledger { Ok(()) } + /// Name a party the creditor of a liability account the company owes + /// it itself on (`on`), or stop (`!on`): the card it issued, the loan + /// it made, what the members advanced (each member). + /// + /// ```text + /// GOAL: the books know whose each card, loan and advance is, so a + /// credit to it never counts as paying that party (a fee on its + /// own card, interest on its own loan, a distribution still owed + /// to a member: a promise, not a payment). + /// SOURCE: crate::parties INVARIANT 5; rules.md B-35 (DECISION, owner, + /// 2026-10-20: "a liability credit never counts as paying that + /// liability's own creditor"). + /// SCENARIOS: + /// 1. IF the party or the account is unknown: SHOULD refuse. + /// 2. IF the account isn't a liability, is a placeholder, or is + /// payables or a payroll withholding (owed to many, through their + /// own records): SHOULD refuse, changing nothing. + /// 3. Naming it twice, or stopping one never named, changes nothing. + /// 4. Several parties may be one account's creditors (both members, + /// for what the members advanced). + /// ``` + pub fn set_party_creditor( + &mut self, + id: crate::PartyId, + account: AccountId, + on: bool, + ) -> Result<(), LedgerError> { + self.check_set_party_creditor(id, account, on)?; + let p = self.parties.get_mut(&id).expect("checked"); + if on { + p.creditor_of.insert(account); + } else { + p.creditor_of.remove(&account); + } + Ok(()) + } + + /// The validation behind [`Self::set_party_creditor`]. + pub fn check_set_party_creditor( + &self, + id: crate::PartyId, + account: AccountId, + on: bool, + ) -> Result<(), LedgerError> { + if !self.parties.contains_key(&id) { + return Err(LedgerError::UnknownParty(id)); + } + let a = self + .chart + .get(account) + .ok_or(LedgerError::UnknownAccount(account))?; + if !on { + return Ok(()); + } + if a.account_type != AccountType::Liability { + return Err(LedgerError::InvalidParty(format!( + "account {} isn't a liability: a party is the creditor only of what the company owes it (a card, a loan, what the members advanced)", + a.code + ))); + } + if a.placeholder { + return Err(LedgerError::InvalidParty(format!( + "account {} is a placeholder: name the creditor of the account under it that is posted to", + a.code + ))); + } + let many = PAYROLL[2..] + .iter() + .chain([&PAYABLES]) + .any(|c| self.role_account(c) == Some(account)); + if many { + return Err(LedgerError::InvalidParty(format!( + "account {} is owed to many parties through their own records (bills, payroll): it has no one creditor", + a.code + ))); + } + Ok(()) + } + + /// The parties named the creditor of `account`, by id + /// ([`Self::set_party_creditor`]). + pub fn creditors_of(&self, account: AccountId) -> Vec { + self.parties + .values() + .filter(|p| p.creditor_of.contains(&account)) + .map(|p| p.id) + .collect() + } + /// Record (or clear) a party's W-4 facts. pub fn set_party_w4( &mut self, diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index 655ad44..d441a1c 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -26,6 +26,12 @@ //! 3. Names are unique, ignoring case and surrounding space, among parties //! not archived. //! 4. Every vendor and customer record links to exactly one party. +//! 5. A party's `creditor_of` names only liability accounts that are +//! not placeholders, payables or payroll withholdings: what the +//! company owes that party itself on (a card's issuer, a loan's +//! lender, a member for what the members advanced). A credit to one +//! never counts as paying that party (rules.md B-35; DECISION, owner, +//! 2026-10-20). //! ``` use chrono::NaiveDate; @@ -247,6 +253,11 @@ pub struct Party { /// Its customer record, if the company invoices it. #[serde(default)] pub customer: Option, + /// The liability accounts the company owes this party itself on: the + /// card it issued, the loan it made, what the members advanced (each + /// member). INVARIANT 5. + #[serde(default)] + pub creditor_of: BTreeSet, } impl Party { @@ -255,6 +266,43 @@ impl Party { self.display_name.as_deref().unwrap_or(&self.name) } + /// The last four digits of its TIN, when the books know them: its TIN + /// facts' (`books.set_tin`), else its W-9 facts'. + /// + /// ```text + /// GOAL: one answer to "which TIN is on file for them?" wherever the + /// books show a last four (defect 11: the directory showed a + /// member's as missing because it read W-9 facts only). + /// SOURCE: rules.md B-26 (Treas. Reg. §301.6109-4(a): a truncated TIN + /// shows its last four); INVARIANT 2a (DECISION, owner, + /// 2026-10-15: the two never disagree, so either answers). + /// SCENARIOS: + /// 1. A member with TIN facts and no W-9: the TIN facts' last four. + /// 2. A contractor with W-9 facts only: the W-9's. + /// 3. Both on file: the same four (2a), the TIN facts'. + /// 4. Neither: None. + /// 5. A TIN purged from the vault keeps its facts' last four + /// (books.purge_tin drops only the vault record): still shown. + /// 6. NEVER more than four digits: facts whose last four isn't + /// exactly four digits (built by hand, not through the checking + /// setters) show nothing from that source. + /// ``` + pub fn tin_last4(&self) -> Option<&str> { + // Exactly four digits or nothing, however the facts were built + // (the setters check it; this never shows more, B-26). + let four = |s: &str| s.len() == 4 && s.chars().all(|c| c.is_ascii_digit()); + self.tin + .as_ref() + .map(|t| t.last4.as_str()) + .filter(|s| four(s)) + .or_else(|| { + self.w9 + .as_ref() + .map(|w| w.tin_last4.as_str()) + .filter(|s| four(s)) + }) + } + /// Its roles: the ones set by hand, plus vendor and customer when it /// has those records. pub fn all_roles(&self) -> BTreeSet { @@ -522,26 +570,91 @@ pub enum PartyField { Archived(bool), } +/// Which of a party's totals to report (rules.md B-35 item 4). Not the +/// books' accounting method (`books.accounting_basis`): party totals count +/// what moved, whichever method the books keep. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Basis { + /// Money: through the company's cash accounts, a card, the members' + /// financing or a loan; on a member's capital, what the K-1 counts as + /// cash. The default. + #[default] + Cash, + /// Money and property contributed or distributed in kind: for a + /// member, what the statement of members' equity counts. + All, +} + +impl Basis { + /// Every basis, the default first. + pub const ALL: [Basis; 2] = [Basis::Cash, Basis::All]; + + /// Its wire name: `cash` or `all`. + pub fn key(self) -> &'static str { + match self { + Basis::Cash => "cash", + Basis::All => "all", + } + } + + /// The basis named `key` (surrounding space ignored); None for any + /// other word. + pub fn parse(key: &str) -> Option { + Basis::ALL.into_iter().find(|b| b.key() == key.trim()) + } +} + /// What the books did with one party over a span of dates -/// ([`activity`]). +/// ([`activity_with`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Activity { /// The party's entries, drafts included, by id. pub entries: BTreeSet, - /// Paid to it in the span (net of refunds and reversals of payments). + /// Paid to it in money in the span (basis cash), net of reversals of + /// payments (a refund recorded as its own entry is received from it). pub paid: rust_decimal::Decimal, - /// Received from it in the span (net of reversals of receipts). + /// Received from it in money in the span (basis cash), net of + /// reversals of receipts. pub received: rust_decimal::Decimal, - /// Posted entries in the span naming it among several parties whose - /// shares don't resolve against the entry and the members given - /// ([`crate::shares::resolve`]): counted in neither total. + /// Given to it as property in the span (a distribution in kind), net + /// of reversals: basis all adds it to `paid`. + pub paid_in_kind: rust_decimal::Decimal, + /// Received from it as property in the span (a contribution in kind), + /// net of reversals: basis all adds it to `received`. + pub received_in_kind: rust_decimal::Decimal, + /// Posted entries in the span naming it whose shares don't resolve + /// against the entry and the members given ([`crate::shares::resolve`]), + /// or whose part is past exact arithmetic: counted in no total. pub unresolved: BTreeSet, } -/// The accounts [`entry_flow`] reads money from: the cash accounts (every -/// asset account but receivables when `cash` is empty), and the -/// liabilities other than payables and the payroll withholdings (cards, -/// what members paid for the company and are owed back, loans). +impl Activity { + /// (paid, received) on `basis`: cash is `paid` and `received`; all + /// adds what moved in kind. [`activity_with`] admits an entry only when + /// every one of these sums stays exact, so this never overflows for an + /// activity it made. + pub fn totals(&self, basis: Basis) -> (rust_decimal::Decimal, rust_decimal::Decimal) { + match basis { + Basis::Cash => (self.paid, self.received), + Basis::All => ( + self.paid + .checked_add(self.paid_in_kind) + .expect("activity_with keeps the all-basis sums exact"), + self.received + .checked_add(self.received_in_kind) + .expect("activity_with keeps the all-basis sums exact"), + ), + } + } +} + +/// The accounts [`entry_flow`] reads money from (rules.md B-35, defect +/// 24): the cash accounts (every asset account but receivables when none +/// are given), and the money liabilities: the cards ([`card_accounts`]), +/// what the members advanced and loans (`books.financing_accounts`). +/// Never payables, the payroll withholdings, or any other liability (an +/// accrued expense, deferred revenue): an accrual is a promise, counted +/// when it is settled, never also when it is booked. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct MoneyAccounts { /// Cash: bank, savings, cash on hand. @@ -550,21 +663,80 @@ pub struct MoneyAccounts { pub liabilities: std::collections::HashSet, } -/// The [`MoneyAccounts`] of `ledger`, with `cash` the books' cash accounts. -pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> MoneyAccounts { +impl MoneyAccounts { + /// These accounts less every liability owed to one of `parties` + /// ([`Party::creditor_of`]): for an entry naming them, a credit to + /// what the company owes a party itself never counts as paying it (a + /// fee charged on its own card, interest added to its own loan, + /// equipment bought on its own note, a distribution still owed to a + /// member), and a debit to it is what the money paid for (rules.md + /// B-35; DECISION, owner, 2026-10-20). + pub fn for_parties( + &self, + ledger: &crate::Ledger, + parties: &[PartyId], + ) -> std::borrow::Cow<'_, MoneyAccounts> { + let owed: Vec = parties + .iter() + .filter_map(|p| ledger.get_party(*p)) + .flat_map(|p| p.creditor_of.iter().copied()) + .filter(|a| self.liabilities.contains(a)) + .collect(); + if owed.is_empty() { + return std::borrow::Cow::Borrowed(self); + } + let mut out = self.clone(); + for a in owed { + out.liabilities.remove(&a); + } + std::borrow::Cow::Owned(out) + } +} + +/// The company's cards: the liability accounts whose names say "card" +/// (ignoring case), but not "gift card" (gift cards the company sold are +/// deferred revenue it owes its customers, a promise, not a card it pays +/// with), the rule the monthly reconciliation reminders read +/// (`ledger_store::reconcilable`), so the two never disagree on what a +/// card is. CONVENTION until payment instruments name the card accounts +/// (plan/served.md P3 defect 12). +pub fn card_accounts(ledger: &crate::Ledger) -> Vec { + ledger + .chart() + .all() + .filter(|a| { + let name = a.name.to_lowercase(); + a.account_type == crate::AccountType::Liability + && name.contains("card") + && !name.contains("gift") + }) + .map(|a| a.id) + .collect() +} + +/// The [`MoneyAccounts`] of `ledger`: `cash` the books' cash accounts +/// (every asset account but receivables when empty), `financing` the +/// books' financing liabilities (`books.financing_accounts`: what the +/// members advanced, loans), with the cards ([`card_accounts`]). +pub fn money_accounts( + ledger: &crate::Ledger, + cash: &[crate::AccountId], + financing: &[crate::AccountId], +) -> MoneyAccounts { use crate::AccountType; let not_money: std::collections::HashSet = crate::PAYROLL[2..] .iter() .chain([&crate::PAYABLES, &crate::RECEIVABLES]) .filter_map(|c| ledger.role_account(c)) .collect(); + let cards = card_accounts(ledger); let mut out = MoneyAccounts::default(); for a in ledger.chart().all().filter(|a| !not_money.contains(&a.id)) { match a.account_type { AccountType::Asset if cash.is_empty() || cash.contains(&a.id) => { out.cash.insert(a.id); } - AccountType::Liability => { + AccountType::Liability if cards.contains(&a.id) || financing.contains(&a.id) => { out.liabilities.insert(a.id); } _ => {} @@ -573,6 +745,28 @@ pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> Mone out } +/// The original a reversal chain starts from, and how many reversals lie +/// between it and `entry` (zero when `entry` reverses nothing): followed +/// up `reversal_of`, O(depth). Ids strictly fall along the chain (a +/// reversal is made after its original), so the walk always ends. +pub fn root_original<'a>( + ledger: &'a crate::Ledger, + entry: &'a crate::JournalEntry, +) -> (&'a crate::JournalEntry, usize) { + let mut root = entry; + let mut depth = 0; + while let Some(o) = root.reversal_of() { + match ledger.get_entry(o) { + Some(orig) if orig.id() < root.id() => { + root = orig; + depth += 1; + } + _ => break, + } + } + (root, depth) +} + /// What one entry moved with its counterparty ([`entry_flow`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct EntryFlow { @@ -630,6 +824,12 @@ pub struct EntryFlow { /// 7. A member paying the card bill from their own funds (Dr 2050 / Cr /// 2100), or a loan paying it off (Dr 2050 / Cr loan): net is the /// amount, what for is the card, as if paid from the bank. +/// 8. An accrual through a liability that isn't money (Dr expense / Cr +/// accrued expenses): zero; its settlement (Dr accrued / Cr 1000) is +/// the payment (defect 24: counted once, not twice). +/// Line-level: not odd for one liability paying down another (a +/// reversal of SCENARIO 7 reads as another payment, `across` again), so a +/// party's figures read a reversal through [`flow`], never its lines. /// ``` pub fn entry_flow( entry: &crate::JournalEntry, @@ -743,64 +943,119 @@ pub fn entry_flow( }) } -/// One party's entries, and the money paid to and received from it in +/// The money an entry moved with the parties it names, and what for, +/// read as party figures read it: a reversal's is exactly its original's +/// negated (B-9), never re-read from its own lines; and a liability owed +/// to a party named is not money for the entry +/// ([`MoneyAccounts::for_parties`]). +/// +/// ```text +/// GOAL: what a party's entry paid for, right for reversals too. +/// SOURCE: rules.md B-9 ("a mistake is corrected by an opposite entry"), +/// B-35. +/// INVARIANTS: +/// 1. An entry reversing none: entry_flow of its lines, with +/// money.for_parties(the parties of `shares`). +/// 2. flow(reversal) = −flow(its original): net and every what_for +/// negated, accounts in the same order; a reversal of a reversal +/// gives the original's back. Found up reversal_of (root_original), +/// O(depth). +/// SCENARIOS: +/// 1. A member pays the card from their own funds (Dr 2050 / Cr 2100, +/// tagged with the issuer): +100.00, for 2050. Its reversal (Dr 2100 +/// / Cr 2050) is −100.00, though entry_flow reads its lines as +/// another +100.00 (the across term isn't odd). +/// ``` +pub fn flow( + ledger: &crate::Ledger, + id: crate::EntryId, + shares: &[crate::PartyShare], + money: &MoneyAccounts, +) -> Result { + let entry = ledger + .get_entry(id) + .ok_or(crate::LedgerError::UnknownEntry(id))?; + let (root, depth) = root_original(ledger, entry); + let named: Vec = shares.iter().map(|s| s.party).collect(); + let f = entry_flow(root, &money.for_parties(ledger, &named))?; + if depth % 2 == 0 { + return Ok(f); + } + // Every figure a flow holds is nonzero or a plain zero, and a + // Decimal's negation is exact. + let neg = |v: rust_decimal::Decimal| { + if v.is_zero() { + rust_decimal::Decimal::ZERO + } else { + -v + } + }; + Ok(EntryFlow { + net: neg(f.net), + what_for: f.what_for.into_iter().map(|(a, v)| (a, neg(v))).collect(), + }) +} + +/// One party's entries, and what was paid to and received from it in /// `start..=end`, with no members ([`activity_with`] with none: an entry /// naming several parties then needs explicit shares to count). pub fn activity( ledger: &crate::Ledger, party: PartyId, span: (NaiveDate, NaiveDate), - cash: &[crate::AccountId], + money: &MoneyAccounts, ) -> Activity { - activity_with(ledger, party, span, cash, &[]) + activity_with(ledger, party, span, money, &[]) } -/// One party's entries, and its share of the money paid to and received -/// from the entries' parties in `start..=end`. +/// One party's entries, and its part of what was paid to and received +/// from the entries' parties in `start..=end`, on both bases. /// /// ```text -/// GOAL: one answer to "what did we pay them this year?" for the MCP -/// server and the viewer alike, right when an entry was with -/// several parties. -/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); DECISION -/// (owner, 2026-10-15). +/// GOAL: one answer to "what did we pay them this year, and what did we +/// get from them?" for the MCP server and the viewer alike, right +/// when an entry was with several parties, for property as well as +/// money, and for reversals. +/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); rules.md +/// B-35 (DECISION, owner, 2026-10-15 and 2026-10-20). /// INVARIANTS: /// 1. entries: every entry Ledger::entry_parties names the party in /// (hand tags, vendor tags, bills and their payments, invoices and /// their payments, and reversals of any of these carry it). -/// 2. Each posted entry dated in the span whose entry_flow net is not -/// zero counts the party's resolved share of it -/// (crate::shares::resolve with `members`; the whole net for a sole -/// party): positive is a payment, negative a receipt. A reversal -/// counts on its root original's side (the first entry of -/// Ledger::reversal_chain), so a reversal subtracts and a reversal -/// of that reversal adds back, on the same side. -/// 3. An entry moving no money (net zero, like property contributed) -/// counts nothing. An entry whose shares don't resolve counts -/// nothing and is listed in `unresolved`. -/// 4. Over all the parties an entry names, the shares counted sum to its -/// net exactly (crate::shares INVARIANT 6). -/// 5. A card charge counts as paid (the company paid them, by card); a -/// bill doesn't until it is paid. Not the 1099 amount: that is +/// 2. Each posted entry dated in the span counts the party's part of it +/// (crate::shares::resolve with `money` and `members`): its cash in +/// paid or received, its in_kind in paid_in_kind or received_in_kind, +/// by the direction of the entry's root original (Target::counts_as), +/// so a reversal subtracts on its original's side, and a reversal of +/// that reversal adds back there. +/// 3. An entry whose target is direction neither at its root (a bill, +/// an invoice, a close, an accrual, a fee on the party's own card), +/// or whose part counts nothing (a distribution still owed), counts +/// nothing and is never unresolved: its target is found before its +/// split. +/// 4. An entry whose target or split is refused (shares that don't fit, +/// a sum past exact arithmetic), or whose part would take any total, +/// on either basis, past exact arithmetic, counts nothing and is +/// listed in `unresolved`. +/// 5. Over all the parties an entry names, the parts counted sum to its +/// target's cash and in_kind exactly (crate::shares INVARIANT 6). +/// 6. A card charge counts as paid (the company paid them, by card); a +/// bill doesn't until it is paid; a member's property contribution is +/// received in kind. Not the 1099 amount: that is /// form1099::vendor_1099_report_with. /// ``` pub fn activity_with( ledger: &crate::Ledger, party: PartyId, (start, end): (NaiveDate, NaiveDate), - cash: &[crate::AccountId], + money: &MoneyAccounts, members: &[crate::PartyMember], ) -> Activity { - use rust_decimal::Decimal; + use crate::kernel::Direction; let entries: BTreeSet = (0..ledger.next_entry_id()) .filter(|&id| ledger.entry_names_party(id, party)) .collect(); - let money = money_accounts(ledger, cash); - // A posted entry's lines are in a line's range, so its flow computes; - // one that doesn't counts nothing and is listed as unresolved. - let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).map(|f| f.net); - let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO); - let mut unresolved = BTreeSet::new(); + let mut a = Activity::default(); for &id in &entries { let Some(e) = ledger.get_entry(id) else { continue; @@ -808,43 +1063,52 @@ pub fn activity_with( if e.status() != crate::EntryStatus::Posted || e.date() < start || e.date() > end { continue; } - let Ok(net) = net_out(e) else { - unresolved.insert(id); + let shares = ledger.entry_parties(id); + // The target first: an entry that counts nothing is never + // unresolved for shares that don't fit it (rules.md B-35 item 4). + let Ok(t) = crate::shares::target(ledger, id, shares, money) else { + a.unresolved.insert(id); continue; }; - if net.is_zero() { + if t.counts_as == Direction::Neither || (t.cash.is_zero() && t.in_kind.is_zero()) { continue; } - let share = match crate::shares::resolve(e, ledger.entry_parties(id), &money, members) { - Ok(parts) => parts - .into_iter() - .find(|(p, _)| *p == party) - .map(|(_, v)| v) - .unwrap_or_default(), - Err(_) => { - unresolved.insert(id); - continue; + let Ok(parts) = crate::shares::split(e, shares, &t, members) else { + a.unresolved.insert(id); + continue; + }; + let Some(p) = parts.iter().find(|p| p.party == party) else { + continue; + }; + // Paid adds, received subtracts (a receipt is negative), each on + // the root original's side; every total, the all-basis sums + // included, stays exact or the entry counts nothing. + let next = match t.counts_as { + Direction::Paid => a + .paid + .checked_add(p.cash) + .zip(a.paid_in_kind.checked_add(p.in_kind)) + .map(|(c, k)| (c, k, a.received, a.received_in_kind)), + _ => a + .received + .checked_sub(p.cash) + .zip(a.received_in_kind.checked_sub(p.in_kind)) + .map(|(c, k)| (a.paid, a.paid_in_kind, c, k)), + }; + match next.filter(|(pc, pk, rc, rk)| { + pc.checked_add(*pk).is_some() && rc.checked_add(*rk).is_some() + }) { + Some((pc, pk, rc, rk)) => { + a.paid = pc; + a.paid_in_kind = pk; + a.received = rc; + a.received_in_kind = rk; + } + None => { + a.unresolved.insert(id); } - }; - let side = match e.reversal_of() { - Some(_) => ledger - .reversal_chain(id) - .first() - .and_then(|&root| ledger.get_entry(root)) - .map_or(Ok(net), net_out) - .unwrap_or(net), - None => net, - }; - if side > Decimal::ZERO { - paid += share; - } else { - received -= share; } } - Activity { - entries, - paid, - received, - unresolved, - } + a.entries = entries; + a } diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index a3a6c88..fbd5946 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -1091,6 +1091,144 @@ fn constructive_mirrors() { } } +// --------------------------------------------------------------------------- +// A party's part of an entry (rules.md B-35) +// --------------------------------------------------------------------------- + +/// The inputs `kernel::party_target` reads, any in range: a capital +/// movement (when every party named is a member) with the property beside +/// it, the money net, the own-account movement, and the debits (a +/// magnitude). +fn any_party_inputs() -> (Option<(i64, i64)>, i64, i64, i64) { + let capital = if kani::any() { + let property = any_amount(); + kani::assume(property >= 0); + Some((any_amount(), property)) + } else { + None + }; + let debits = any_amount(); + kani::assume(debits >= 0); + (capital, any_amount(), any_amount(), debits) +} + +/// B-35 items 1-4: the direction is the sign of what counts (paid above +/// zero, received below), neither exactly when nothing counts; cash and +/// in kind each carry the whole's sign and together never exceed it; a +/// money target is all cash; a contribution counts all of itself; a +/// distribution's cash is never more than the money it paid, nor its in +/// kind more than the property it gave (the rest is still owed). +#[kani::proof] +fn party_target_direction_is_its_sign() { + let (capital, net, own, debits) = any_party_inputs(); + let t = kernel::party_target(capital, net, own, debits).expect("in range"); + let sign = |a: i64| a.signum(); + assert!(t.cash == 0 || sign(t.cash) == sign(t.amount)); + assert!(t.in_kind == 0 || sign(t.in_kind) == sign(t.amount)); + assert!(t.cash.abs() + t.in_kind.abs() <= t.amount.abs()); + match t.direction { + kernel::Direction::Neither => { + assert!(t.cash == 0 && t.in_kind == 0); + } + kernel::Direction::Paid => { + assert!(t.amount > 0 && t.cash + t.in_kind > 0); + } + kernel::Direction::Received => { + assert!(t.amount < 0 && t.cash + t.in_kind < 0); + } + } + let capital_case = matches!(capital, Some((e, _)) if e != 0); + if !capital_case && net != 0 { + assert!(t.amount == net && t.cash == net && t.in_kind == 0); + } + if let Some((e, property)) = capital { + if e < 0 { + // A contribution counts all of itself, money or property. + assert_eq!(t.cash + t.in_kind, e); + assert!(-t.in_kind <= property); + } + if e > 0 { + // A distribution: what it paid, then what it gave; no more. + assert!(t.cash <= net.max(0) && t.in_kind <= property); + } + } + kani::cover!( + t.direction == kernel::Direction::Paid && t.in_kind != 0 && t.cash != 0, + "a distribution partly in kind" + ); + kani::cover!( + capital_case && t.direction == kernel::Direction::Neither, + "a distribution all still owed" + ); + kani::cover!( + t.direction == kernel::Direction::Paid && t.cash + t.in_kind < t.amount, + "a distribution partly still owed" + ); +} + +/// B-35 item 3 and B-9: the target is odd. Negating the lines negates the +/// capital movement, the money net and the own-account movement, and the +/// property comes from the other side, so it keeps its size: the result +/// is the original's reverse_target, whenever it isn't the debits fallback +/// (a sign means nothing there) and a member's entry counts exactly its +/// money as cash (not an expense booked straight to capital, a debt +/// converted, or a distribution still owed). The reversal arm, not the +/// lines, gives every reversal its target, so these exceptions never reach +/// one. +#[kani::proof] +fn party_target_is_odd() { + let (capital, net, own, debits) = any_party_inputs(); + if let Some((e, property)) = capital + && e != 0 + { + // The money the member's entry moved their way, signed as the + // distribution side reads it, is exactly what isn't property. + let side_net = if e > 0 { net } else { -net }; + let whole = e.abs(); + kani::assume(side_net.max(0).min(whole) == whole - property.min(whole)); + } + let t = kernel::party_target(capital, net, own, debits).expect("in range"); + let negated = capital.map(|(e, p)| (-e, p)); + let n = kernel::party_target(negated, -net, -own, debits).expect("in range"); + let fallback = !matches!(capital, Some((e, _)) if e != 0) && net == 0 && own == 0; + if !fallback { + assert_eq!(Some(n), kernel::reverse_target(t)); + } + kani::cover!(!fallback && capital.is_some(), "a member's entry, negated"); + kani::cover!( + matches!(capital, Some((e, p)) if e > 0 && p > 0 && net > 0 && net < e), + "a distribution of money and property, negated" + ); +} + +/// B-9: reversing a reversal gives the original back, and reverse_target +/// never panics: past the range it refuses. +#[kani::proof] +fn reverse_target_is_an_involution() { + let direction = match kani::any::() { + 0 => kernel::Direction::Paid, + 1 => kernel::Direction::Received, + _ => kernel::Direction::Neither, + }; + let t = kernel::PartyTarget { + amount: kani::any::(), + direction, + cash: kani::any::(), + in_kind: kani::any::(), + }; + match kernel::reverse_target(t) { + Some(r) => { + assert_eq!(r.direction, t.direction.flip()); + assert_eq!(r.amount, -t.amount); + assert_eq!(kernel::reverse_target(r), Some(t)); + } + None => { + assert!(t.amount == i64::MIN || t.cash == i64::MIN || t.in_kind == i64::MIN); + } + } + kani::cover!(t.amount == i64::MIN, "the range's edge"); +} + // --------------------------------------------------------------------------- // The checker itself // --------------------------------------------------------------------------- diff --git a/crates/ledger-core/src/shares.rs b/crates/ledger-core/src/shares.rs index 4db2c98..fc05688 100644 --- a/crates/ledger-core/src/shares.rs +++ b/crates/ledger-core/src/shares.rs @@ -9,22 +9,58 @@ //! itself ([`Share::Derived`]). //! //! ```text -//! GOAL: "what did we pay each of them?" right when one entry was with -//! several parties, with apportioning the human controls exactly. +//! GOAL: "what did we pay each of them, and what did we get from each?" +//! right when one entry was with several parties, with apportioning +//! the human controls exactly, every part signed as the party's own +//! account would show it, and a reversal always the opposite of what +//! it reverses. //! SOURCE: DECISION (owner, 2026-10-15): no "joint" party; a joint //! transaction is with both members, each with their share, and //! shares can be apportioned explicitly ("do you allow for //! controlled apportioning?" Yes). docs/parties-and-attachments.md -//! "Shares". +//! "Shares". rules.md B-35 (the sign, direction and money of a +//! part; DECISION, owner, 2026-10-20: a member's entry counts what +//! moved their capital). //! DEFINITIONS: -//! flow: crate::parties::entry_flow(entry, money). -//! target: flow.net when it is not zero, else the entry's total debits -//! (a member contributing property moves no money but is still all -//! theirs). magnitude = |target|; sign = the sign of target (positive -//! when net is zero). -//! A member: a PartyMember (party, income share, capital account, -//! distributions account), passed in by the caller from the company's -//! settings ([[company.members]]); core doesn't read the settings. +//! target (target(), rules.md B-35 item 3): what the shares split, +//! signed debit-positive as a line on the parties' own account would +//! be (positive: the company paid or gave; negative: it received), +//! with its direction (paid, received, neither) and what of it moved +//! as money (cash) and as property (in_kind), in this order of cases: +//! 1. A period's closing entry: the parties' own-account movement (the +//! members' equity, when every party named is a member), else its +//! total debits; neither: a close moves nothing with anyone. +//! 2. A reversal: its original's target negated, paid and received +//! swapped (kernel::reverse_target), from its root original down; +//! never read from its own lines (parties::entry_flow isn't odd +//! for one liability paying down another). +//! 3. Every party named has the member role in the directory and the +//! entry moves equity other than retained earnings (E): E, received +//! when a contribution (E < 0), paid when a distribution (E > 0). +//! Of a contribution, in_kind is the property it brought in (asset +//! accounts neither cash nor receivables: the K-1 worksheet's test, +//! k1 INVARIANT 3), at most |E|, and cash the rest (what the K-1 +//! counts as cash: money, or what a member paid for the company). +//! Of a distribution, cash is the money it paid (case 4's net, at +//! most E) and in_kind the property it gave up, at most what is +//! left; the rest, credited to a liability that isn't money for the +//! entry (distributions payable, or what the members are owed when +//! they are named its creditors), is still owed and counts as +//! nothing until it is paid (kernel::party_target INVARIANT 2). +//! 4. Money moved (parties::entry_flow net, a liability owed to a +//! party named not counted as money: MoneyAccounts::for_parties): +//! the net, paid (+) or received (−), all of it cash. +//! 5. Otherwise the movement of the parties' own accounts (payables, +//! receivables, liabilities owed to a party named: a bill −, an +//! invoice +, a fee on the issuer's own card −), else the total +//! debits; neither. +//! magnitude = |target.amount|. +//! A member (PartyMember): a party, income share, capital account, +//! distributions account, passed in by the caller from the company's +//! settings ([[company.members]]); core doesn't read the settings. Only +//! a derived split among several members reads them (INVARIANT 10), +//! and the server journals that split as amounts once the entry is +//! posted, so posted history never depends on today's settings. //! INVARIANTS (of an entry's shares, checked by check_shares, journaled): //! 1. No party appears twice; every party is in the directory. //! 2. All shares are one kind: all Amount, all Percent, or all Derived. @@ -33,14 +69,16 @@ //! 100. //! 5. At most one party has a vendor record: a 1099 counts a payment //! toward a single vendor. -//! INVARIANTS (of resolve, against the entry's lines and the members): -//! 6. Resolved amounts sum to target exactly, and each has target's sign. +//! INVARIANTS (of split and resolve, against the entry's lines, the +//! target and the members): +//! 6. The parts sum to target.amount exactly, and each has its sign (a +//! zero part is a plain zero, never "-0"). //! 7. Amount: each party gets sign × its amount; refused unless the //! amounts sum to magnitude. //! 8. Percent: each party gets magnitude × percent / 100 rounded toward //! zero to the cent; the cents left over (fewer than one per party) //! go to the first party in order. Then the sign. -//! 9. Derived, one party: it gets target. +//! 9. Derived, one party: it gets target.amount. //! 10. Derived, several parties: every one must be a member (else //! refused: give amounts or percents). If the entry has a line on any //! named member's capital or distributions account, each member gets @@ -48,21 +86,49 @@ //! to magnitude (else refused). Otherwise each gets their income share //! of magnitude, the shares rescaled to the members named, rounded as //! in 8 (remainder to the first). +//! 11. A reversal's parts are its original's negated: its target is the +//! original's negated, and its lines are the original's with every +//! side flipped (Ledger::reverse_entry), so every magnitude above is +//! the same. +//! 12. Each part's cash and in_kind have its sign, |cash| + |in_kind| ≤ +//! |part|, and over the parts they sum to the target's cash and +//! in_kind exactly: in_kind is shared by each part's size, cash by +//! what is left of each (k1's share_property: in proportion, to the +//! cent, leftover cents by largest remainder, ties to the party +//! listed first in the company's members), so a member's in-kind part +//! of a joint contribution is the property the K-1 worksheet counts +//! for them. //! SCENARIOS: //! 1. Reimbursing both members $100.00 (Dr 2100 / Cr 1000), both named, -//! derived, 50/50: $50.00 each. +//! derived, 50/50: $50.00 each, paid, cash. //! 2. A joint contribution (Dr 1000 300 / Cr 3000 200 / Cr 3001 100): -//! derived from the capital lines, Tobin −200.00, Morgan −100.00 -//! (received). +//! derived from the capital lines, Tobin −200.00, Morgan −100.00, +//! received, cash. //! 3. A property contribution (Dr Equipment 1000 / Cr 3000 500 / Cr 3001 -//! 500): net is zero; target is the total, 500.00 each. +//! 500): −500.00 each, received, in kind; its reversal +500.00 each, +//! paid, in kind. //! 4. $100.00 split 1/3 each three ways by percent 33.33, 33.33, 33.34: //! 33.33, 33.33, 33.34. By 33.333/33.333/33.334: 33.34, 33.33, 33.33 //! (the leftover cent to the first). //! 5. Amounts of $60.00 and $30.00 on a $100.00 payment: refused. +//! 6. A bill for $80.00 tagged with its vendor (Dr expense / Cr +//! payables): −80.00, neither; its reversal +80.00, neither. +//! 7. A joint contribution of cash and property (Dr 1000 600, Dr 1500 +//! 400 / Cr 3000 500, Cr 3001 500): −500.00 each, received, of it +//! −300.00 cash and −200.00 in kind (the K-1 worksheet's SCENARIO 3). +//! 8. A distribution declared to one member (Dr 3000 200 / Cr +//! Distributions Payable 200): +200.00, neither, nothing counted; its +//! payment (Dr Distributions Payable 200 / Cr 1000 200) is the 200.00 +//! paid, once. +//! 9. Equipment carried at 1,000 less 300 depreciated, and 100 in cash, +//! distributed to one member (Dr 3000 800, Dr accumulated +//! depreciation 300 / Cr equipment 1000, Cr 1000 100): +800.00, paid, +//! of it 100.00 cash and 700.00 in kind. //! ``` -use crate::{AccountId, JournalEntry, PartyId, Side}; +pub use crate::kernel::Direction; +use crate::kernel::{self, PartyTarget}; +use crate::{AccountId, AccountType, EntryId, JournalEntry, Ledger, LedgerError, PartyId, Side}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -202,28 +268,6 @@ pub fn check_shares_journaled( Ok(()) } -/// What the shares are split over: the entry's flow net, else its total -/// debits (the module's DEFINITIONS). -/// Refused (TooLargeToCompute), never a panic, when a sum is past exact -/// arithmetic: a draft's lines and an entry's shares are as journaled, of -/// any size (rules.md B-28). -pub fn share_target( - entry: &JournalEntry, - money: &crate::parties::MoneyAccounts, -) -> Result { - let net = crate::parties::entry_flow(entry, money)?.net; - if net.is_zero() { - entry - .lines() - .iter() - .filter(|l| l.side == Side::Debit) - .try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount)) - .ok_or_else(|| too_large(format!("entry {}'s debits", entry.id()))) - } else { - Ok(net) - } -} - /// The refusal of a sum past exact arithmetic. fn too_large(what: String) -> crate::LedgerError { crate::LedgerError::TooLargeToCompute(what) @@ -259,23 +303,182 @@ fn split_first(magnitude: Decimal, weights: &[Decimal]) -> Option> Some(parts) } -/// Each party's exact part of what `entry` moved (the module's INVARIANTS -/// 6–10), in the shares' order. Refused, with why, when the shares can't -/// resolve against these lines and members. -pub fn resolve( +/// What an entry's shares split, and what of it counts (the module's +/// DEFINITIONS: target; rules.md B-35). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Target { + /// The whole, signed debit-positive as a line on the parties' own + /// account would be. + pub amount: Decimal, + /// Which way value moved with the parties. + pub direction: Direction, + /// What of `amount` moved as money: counted on both bases. + pub cash: Decimal, + /// What of `amount` moved as property: counted on basis "all". + pub in_kind: Decimal, + /// Which side the parts count on in a party's totals: the direction + /// of the entry's root original (itself, unless it is a reversal), so + /// a reversal nets what it reverses (B-35 item 4). + pub counts_as: Direction, +} + +/// One party's part of an entry (the module's INVARIANTS 6-12). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Part { + /// The party. + pub party: PartyId, + /// Its part of the target, with the target's sign. + pub amount: Decimal, + /// What of it moved as money. + pub cash: Decimal, + /// What of it moved as property. + pub in_kind: Decimal, +} + +/// An entry's target and each party's part of it ([`resolve`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Resolved { + /// What the shares split. + pub target: Target, + /// Each party's part, in the shares' order. + pub parts: Vec, +} + +impl Resolved { + /// The part of `party`, when the entry names it. + pub fn part(&self, party: PartyId) -> Option<&Part> { + self.parts.iter().find(|p| p.party == party) + } +} + +/// What entry `id`'s shares split (the module's DEFINITIONS: target). +/// `shares` are passed, not read from the ledger, so a proposed set can be +/// checked; they cover the whole reversal chain (Ledger::set_entry_parties +/// INVARIANT 5). Refused (TooLargeToCompute), never a panic, when a sum is +/// past exact arithmetic: a draft's lines are as journaled, of any size +/// (rules.md B-28). +pub fn target( + ledger: &Ledger, + id: EntryId, + shares: &[PartyShare], + money: &crate::parties::MoneyAccounts, +) -> Result { + let entry = ledger.get_entry(id).ok_or(LedgerError::UnknownEntry(id))?; + let (root, depth) = crate::parties::root_original(ledger, entry); + let base = original_target(ledger, root, shares, money)?; + let t = if depth % 2 == 1 { + kernel::reverse_target(base) + .ok_or_else(|| too_large(format!("entry {id}'s reversal target")))? + } else { + base + }; + Ok(Target { + amount: t.amount, + direction: t.direction, + cash: t.cash, + in_kind: t.in_kind, + counts_as: base.direction, + }) +} + +/// The target of an entry that reverses none (the module's DEFINITIONS: +/// target, cases 1 and 3-5). +fn original_target( + ledger: &Ledger, entry: &JournalEntry, shares: &[PartyShare], money: &crate::parties::MoneyAccounts, +) -> Result, LedgerError> { + let past_exact = || too_large(format!("entry {}'s target", entry.id())); + let named: Vec = shares.iter().map(|s| s.party).collect(); + let money = money.for_parties(ledger, &named); + let parties: Vec<&crate::Party> = named.iter().filter_map(|p| ledger.get_party(*p)).collect(); + let members_only = !named.is_empty() + && parties.len() == named.len() + && parties + .iter() + .all(|p| p.roles.contains(&crate::Role::Member)); + let owed = |a: AccountId| parties.iter().any(|p| p.creditor_of.contains(&a)); + let retained = ledger.role_account(&crate::RETAINED_EARNINGS); + let receivables = ledger.role_account(&crate::RECEIVABLES); + let payables = ledger.role_account(&crate::PAYABLES); + let kind = |a: AccountId| ledger.chart().get(a).map(|x| x.account_type); + // Property: the K-1 worksheet's test (k1 INVARIANT 3). + let property = |a: AccountId| { + kind(a) == Some(AccountType::Asset) && !money.cash.contains(&a) && Some(a) != receivables + }; + let add = |t: &mut Decimal, v: Decimal| -> Result<(), LedgerError> { + *t = t.checked_add(v).ok_or_else(past_exact)?; + Ok(()) + }; + let zero = Decimal::ZERO; + let (mut equity, mut prop_in, mut prop_out, mut own, mut debits) = + (zero, zero, zero, zero, zero); + for l in entry.lines() { + let v = l.amount.amount; + let signed = kernel::signed(l.side, v).ok_or_else(past_exact)?; + let k = kind(l.account); + if k == Some(AccountType::Equity) && Some(l.account) != retained { + add(&mut equity, signed)?; + } + if property(l.account) { + match l.side { + Side::Debit => add(&mut prop_in, v)?, + Side::Credit => add(&mut prop_out, v)?, + } + } + let theirs = k == Some(AccountType::Liability) && owed(l.account); + if theirs || Some(l.account) == payables || Some(l.account) == receivables { + add(&mut own, signed)?; + } + if l.side == Side::Debit { + add(&mut debits, v)?; + } + } + let property = if equity < zero { prop_in } else { prop_out }; + let capital = members_only.then_some((equity, property)); + if ledger.is_closing_entry(entry.id()) { + let t = kernel::party_target(capital, zero, own, debits).ok_or_else(past_exact)?; + return Ok(PartyTarget { + amount: t.amount, + direction: Direction::Neither, + cash: zero, + in_kind: zero, + }); + } + let net = crate::parties::entry_flow(entry, &money)?.net; + kernel::party_target(capital, net, own, debits).ok_or_else(past_exact) +} + +/// Split `whole` (at most the sum of `sizes`) over `sizes` in proportion, +/// to the cent, leftover cents by largest remainder (k1's +/// share_property), ties to the earlier in `order` (each part's rank). +/// None past exact arithmetic. +fn share_by(whole: Decimal, sizes: &[Decimal], order: &[usize]) -> Option> { + let ranked: Vec = order.iter().map(|&i| sizes[i]).collect(); + let shared = crate::k1::share_property(whole, &ranked, 2).ok()?; + let mut out = vec![Decimal::ZERO; sizes.len()]; + for (k, &i) in order.iter().enumerate() { + out[i] = shared[k]; + } + Some(out) +} + +/// Each party's part of `target` (the module's INVARIANTS 6-12), in the +/// shares' order. Refused, with why, when the shares can't resolve against +/// these lines and members. +pub fn split( + entry: &JournalEntry, + shares: &[PartyShare], + target: &Target, members: &[PartyMember], -) -> Result, String> { - let target = share_target(entry, money).map_err(|e| e.to_string())?; +) -> Result, String> { let past_exact = || too_large(format!("entry {}'s shares", entry.id())).to_string(); - let magnitude = target.abs(); - let signed = |v: Decimal| if target.is_sign_negative() { -v } else { v }; + let magnitude = target.amount.abs(); let Some(first) = shares.first() else { return Ok(Vec::new()); }; - let parts: Vec = match first.share { + let sizes: Vec = match first.share { Share::Amount(_) => { let amounts: Vec = shares .iter() @@ -355,9 +558,62 @@ pub fn resolve( } } }; + // Ties go to the party listed first in the company's members, as on + // the K-1 worksheet; other parties after them, in the shares' order. + let mut order: Vec = (0..shares.len()).collect(); + order.sort_by_key(|&i| { + members + .iter() + .position(|m| m.party == shares[i].party) + .unwrap_or(members.len() + i) + }); + let (cash, in_kind) = (target.cash.abs(), target.in_kind.abs()); + let in_kind_sizes = if in_kind.is_zero() { + vec![Decimal::ZERO; sizes.len()] + } else { + share_by(in_kind, &sizes, &order).ok_or_else(past_exact)? + }; + let left: Vec = sizes + .iter() + .zip(&in_kind_sizes) + .map(|(s, k)| s.checked_sub(*k)) + .collect::>() + .ok_or_else(past_exact)?; + let cash_sizes = if cash == checked_sum(left.iter().copied()).ok_or_else(past_exact)? { + left + } else if cash.is_zero() { + vec![Decimal::ZERO; sizes.len()] + } else { + share_by(cash, &left, &order).ok_or_else(past_exact)? + }; + let negative = target.amount < Decimal::ZERO; + let signed = |v: Decimal| if negative && !v.is_zero() { -v } else { v }; Ok(shares .iter() - .zip(parts) - .map(|(s, v)| (s.party, signed(v))) + .enumerate() + .map(|(i, s)| Part { + party: s.party, + amount: signed(sizes[i]), + cash: signed(cash_sizes[i]), + in_kind: signed(in_kind_sizes[i]), + }) .collect()) } + +/// Entry `id`'s target and each party's exact part of it (the module's +/// INVARIANTS 6-12), in the shares' order. Refused, with why, when the +/// shares can't resolve against these lines and members. +pub fn resolve( + ledger: &Ledger, + id: EntryId, + shares: &[PartyShare], + money: &crate::parties::MoneyAccounts, + members: &[PartyMember], +) -> Result { + let entry = ledger + .get_entry(id) + .ok_or_else(|| LedgerError::UnknownEntry(id).to_string())?; + let target = target(ledger, id, shares, money).map_err(|e| e.to_string())?; + let parts = split(entry, shares, &target, members)?; + Ok(Resolved { target, parts }) +} diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index fa20e21..6ed3293 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -5,13 +5,15 @@ //! Each example is a setup, events and expected values worked by hand from //! its cited source (`bookspec/README.md` gives the shape). Every example //! must load, whatever it asks or whichever appendix it is in: its shape, -//! its accounts, fixed assets, members and payees, every event (an entry, -//! a reversal, a close or a bill, posting entry #N as event N) and every -//! change that posts no entry (a rename, a policy). The queries the core -//! answers (`cash_flow`, `trial_balance`, `profit_and_loss`, -//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`, `period`, -//! `day`, `calendar_year`, `account_names`, `uncategorized_accounts`) are -//! then checked value by value, and the commands it tries +//! its accounts, fixed assets, parties, members and payees, every event +//! (an entry, a reversal, a close or a bill, posting entry #N as event N, +//! with the parties and payee it is tagged with) and every change that +//! posts no entry (a rename, a policy). The queries the core answers +//! (`cash_flow`, `trial_balance`, `profit_and_loss`, `vendor_1099_paid`, +//! `entry`, `balances`, `members_equity`, `k1`, `party_part`, +//! `party_totals`, `party_tin_last4`, `period`, `day`, `calendar_year`, +//! `account_names`, `uncategorized_accounts`) are then checked value by +//! value, and the commands it tries //! (`rename_account`, `add_account`, `set_tax_category`) accepted or //! refused on a copy of the books, each after the event it names (`after`) //! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are @@ -24,11 +26,13 @@ use std::path::{Path, PathBuf}; use chrono::NaiveDate; use ledger_core::calendar::{PeriodDefaults, period_defaults}; +use ledger_core::parties::{Basis, MoneyAccounts}; use ledger_core::settings::Company; use ledger_core::{ AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, DocumentLine, EntryId, Form1099, Ledger, LedgerError, MemberCapital, MemberClose, Money, - NewAsset, PaymentAccounts, ReconcilingKind, Side, VendorId, cash_flow, members_equity, + NewAsset, PartyId, PartyMember, PartyShare, PaymentAccounts, ReconcilingKind, Role, Share, + Side, TinFacts, TinType, VendorId, W9Facts, cash_flow, members_equity, profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report_with_members, }; use rust_decimal::Decimal; @@ -71,6 +75,10 @@ struct Setup { investing_accounts: Vec, #[serde(default)] fixed_assets: Vec, + /// The company's directory: parties other than a payee's vendor + /// record, and the payees' when the example names their roles. + #[serde(default)] + parties: Vec, #[serde(default)] members: Vec, #[serde(default)] @@ -124,6 +132,8 @@ struct FixedAsset { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct MemberSpec { + /// The member's name: a party's in `setup.parties` (with the member + /// role) when the example asks about the member as a party. name: String, capital_account: String, /// Their share of income, an exact decimal fraction; every member has @@ -131,9 +141,14 @@ struct MemberSpec { /// needs them. #[serde(default)] share: Option, + /// Their distributions account (rules.md B-13), when they have one. + #[serde(default)] + distributions_account: Option, } -/// A payee marked for an information return (rules.md B-19). +/// A payee marked for an information return from the beginning (rules.md +/// B-19): a vendor record, the party's of that name in `setup.parties` +/// when there is one with the vendor role. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct PayeeSpec { @@ -141,6 +156,43 @@ struct PayeeSpec { form: String, } +/// One party in the directory (rules.md B-26, B-35). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PartySpec { + name: String, + /// Role keys (`member`, `vendor`, `customer`, `contractor`, + /// `financial_institution`, ...); `vendor` and `customer` give the + /// party its record (one of them at most). + roles: Vec, + /// The liabilities, by code, the company owes the party itself + /// (rules.md B-35 item 6). + #[serde(default)] + creditor_of: Vec, + /// Its TIN facts' last four digits (an SSN's). + #[serde(default)] + tin_last4: Option, + /// Its W-9 facts' last four digits (a 1099 role's). + #[serde(default)] + w9_tin_last4: Option, + /// The vault's copy of the TIN purged, the facts kept (needs + /// `tin_last4`). + #[serde(default)] + tin_purged: bool, +} + +/// A party named on an entry, with its share: an `amount`, a `percent`, +/// or neither (derived from the entry: rules.md B-35). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ShareSpec { + party: String, + #[serde(default)] + amount: Option, + #[serde(default)] + percent: Option, +} + /// One event: an entry (`lines`), a reversal (`reverses`), a close /// (`close`) or a bill (`bill`), exactly one of them. #[derive(Deserialize)] @@ -158,6 +210,10 @@ struct Event { /// The payee (in `setup.payees`) the entry paid. #[serde(default)] payee: Option, + /// The parties (in `setup.parties`) the entry is with, and their + /// shares. + #[serde(default)] + parties: Vec, } /// A bill from `vendor` for `amount`, dated and due on the event's date: @@ -224,8 +280,13 @@ struct Expect { from: Option, to: Option, as_of: Option, - /// The entry an `entry` query asks about. + /// The entry an `entry` or `party_part` query asks about. entry: Option, + /// The party a `party_part`, `party_totals` or `party_tin_last4` + /// query asks about (in `setup.parties`). + party: Option, + /// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4). + basis: Option, /// The books after event N, not after the last. after: Option, /// The account a `rename_account` or `set_tax_category` command @@ -412,15 +473,103 @@ struct Books<'a> { currency: Currency, /// The members, with their shares (zero when the example gives none). members: Vec, + /// The members who are parties (`setup.parties` with the member + /// role), as the settings give them to party shares and the 1099. + party_members: Vec, + /// The directory's parties, by name. + parties: BTreeMap, /// The payees' vendor records, by name. payees: BTreeMap, /// The vendors bills came from, by name (a payee's record if one). vendors: BTreeMap, } +/// The example's directory (`setup.parties`), by name: each party with +/// its roles (a vendor or customer with its record), the liabilities it +/// is the creditor of, and its TIN and W-9 facts' last four. +fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap { + let mut out = BTreeMap::new(); + for p in &ex.setup.parties { + let what = format!("{} party {}", ex.id, p.name); + let roles: Vec = p + .roles + .iter() + .map(|r| Role::parse(r).unwrap_or_else(|| panic!("{what}: role {r:?}"))) + .collect(); + let record = |r: Role| roles.contains(&r); + let others: Vec = roles + .iter() + .copied() + .filter(|r| !matches!(r, Role::Vendor | Role::Customer)) + .collect(); + let id = match (record(Role::Vendor), record(Role::Customer)) { + (true, true) => panic!("{what}: a vendor or a customer, not both"), + (true, false) => { + let v = l + .add_vendor(&p.name) + .unwrap_or_else(|e| panic!("{what}: {e}")); + l.party_of_vendor(v).expect("a vendor's party") + } + (false, true) => { + let c = l + .add_customer(&p.name) + .unwrap_or_else(|e| panic!("{what}: {e}")); + l.party_of_customer(c).expect("a customer's party") + } + (false, false) => l + .add_party(&p.name, &others) + .unwrap_or_else(|e| panic!("{what}: {e}")), + }; + if record(Role::Vendor) || record(Role::Customer) { + for r in &others { + l.set_party_role(id, *r, true) + .unwrap_or_else(|e| panic!("{what}: {e}")); + } + } + for code in &p.creditor_of { + let account = l + .chart() + .find_by_code(code) + .unwrap_or_else(|| panic!("{what}: no account {code}")) + .id; + l.set_party_creditor(id, account, true) + .unwrap_or_else(|e| panic!("{what}: creditor of {code}: {e}")); + } + assert!( + !p.tin_purged || p.tin_last4.is_some(), + "{what}: tin_purged needs tin_last4" + ); + if let Some(last4) = &p.tin_last4 { + let facts = TinFacts { + tin_type: TinType::Ssn, + last4: last4.clone(), + vault_record: (!p.tin_purged).then(|| "0".repeat(64)), + purged: p.tin_purged.then(|| "purged: retention ended".into()), + }; + l.set_party_tin(id, Some(facts)) + .unwrap_or_else(|e| panic!("{what}: {e}")); + } + if let Some(last4) = &p.w9_tin_last4 { + let facts = W9Facts { + received: date("2026-01-01", &what), + entity_type: "individual/sole proprietor".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: last4.clone(), + kept_at: "wanderbooks vault".into(), + vault_record: None, + }; + l.set_party_w9(id, Some(facts)) + .unwrap_or_else(|e| panic!("{what}: {e}")); + } + assert!(out.insert(p.name.clone(), id).is_none(), "{what}: twice"); + } + out +} + impl<'a> Books<'a> { /// The books an example sets up, before any event: its shape checked, - /// its accounts, fixed assets, members and payees. + /// its accounts, fixed assets, parties, members and payees. fn new(ex: &'a Example) -> Self { let s = &ex.setup; let currency = Currency::new(&s.currency).unwrap(); @@ -508,7 +657,7 @@ impl<'a> Books<'a> { .map(|m| MemberCapital { name: m.name.clone(), capital_account: id(&l, &m.capital_account), - distribution_account: None, + distribution_account: m.distributions_account.as_deref().map(|c| id(&l, c)), share: m.share.as_deref().map_or(Decimal::ZERO, |x| { let share = exact(x, &format!("{} {} share", ex.id, m.name)); assert!(share > Decimal::ZERO, "{}: {}'s share", ex.id, m.name); @@ -520,14 +669,36 @@ impl<'a> Books<'a> { let total: Decimal = members.iter().map(|m| m.share).sum(); assert_eq!(total, Decimal::ONE, "{}: the shares sum to 1", ex.id); } + let parties = add_parties(ex, &mut l); + // A member who is a party (by name, with the member role), as the + // settings' members are (settings::Company::party_members). + let party_members: Vec = members + .iter() + .filter_map(|m| { + let p = l.party_by_name(&m.name)?; + p.roles.contains(&Role::Member).then_some(PartyMember { + party: p.id, + share: m.share, + capital: m.capital_account, + distributions: m.distribution_account, + }) + }) + .collect(); let mut payees = BTreeMap::new(); for p in &s.payees { let form = Form1099::parse(&p.form) + .filter(|_| p.form.starts_with("1099-")) .unwrap_or_else(|| panic!("{}: payee {}: form {:?}", ex.id, p.name, p.form)); - let v = l - .add_vendor(&p.name) + // A payee in the directory with the vendor role is that + // party's vendor record. + let v = match parties.get(&p.name).and_then(|&id| l.get_party(id)?.vendor) { + Some(v) => v, + None => l + .add_vendor(&p.name) + .unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)), + }; + l.set_vendor_1099(v, Some(form), false) .unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)); - l.set_vendor_1099(v, Some(form), false).unwrap(); assert!( payees.insert(p.name.clone(), v).is_none(), "{}: payee {} twice", @@ -540,6 +711,8 @@ impl<'a> Books<'a> { l, currency, members, + party_members, + parties, vendors: payees.clone(), payees, } @@ -561,6 +734,10 @@ impl<'a> Books<'a> { e.payee.is_none() || (e.reverses.is_none() && e.close.is_none() && e.bill.is_none()), "{ex} event {n}: a reversal takes its original's payee, a bill names its vendor, and a close has none" ); + assert!( + e.parties.is_empty() || e.reverses.is_none(), + "{ex} event {n}: a reversal takes its original's parties" + ); let posted = match (&e.close, e.reverses, e.lines.is_empty()) { (None, None, true) if e.bill.is_some() => { let b = e.bill.as_ref().unwrap(); @@ -674,6 +851,29 @@ impl<'a> Books<'a> { _ => panic!("{ex} event {n}: an event is one of lines, reverses, close or bill"), }; assert_eq!(posted, n, "{ex}: event {n} must be entry #{n}"); + if !e.parties.is_empty() { + let shares: Vec = e + .parties + .iter() + .map(|sh| { + let party = *self.parties.get(&sh.party).unwrap_or_else(|| { + panic!("{ex} event {n}: party {:?} not in setup", sh.party) + }); + let share = match (&sh.amount, &sh.percent) { + (None, None) => Share::Derived, + (Some(a), None) => Share::Amount(exact(a, ex)), + (None, Some(p)) => Share::Percent(exact(p, ex)), + _ => panic!("{ex} event {n}: a share is an amount or a percent"), + }; + PartyShare { party, share } + }) + .collect(); + self.l + .set_entry_parties(n, &shares) + .unwrap_or_else(|err| panic!("{ex} event {n}: {err}")); + } + // The payee after the parties: a payee's party must be among + // several named (the vendor tag follows the parties). if let Some(p) = &e.payee { let v = *self .payees @@ -973,21 +1173,19 @@ fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) { } } -/// Check one `vendor_1099_paid` expectation: each payee's payments out of -/// the cash (and financing) accounts from January 1 through `to`, a -/// payment card's left out, every payee listed (rules.md B-19, B-33). -fn check_vendor_1099_paid( - ex: &Example, - l: &Ledger, - payees: &BTreeMap, - x: &Expect, -) { +/// The 1099 report a `vendor_1099_paid` expectation asks for: the +/// calendar year of `to`, from January 1 through `to`, out of the cash +/// and financing accounts, an entry's shares resolved with the members +/// who are parties (rules.md B-19, B-33). +fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core::Vendor1099Report) { + let (ex, l) = (b.ex, &b.l); // A calendar-year count, never the fiscal default (rules.md B-30): // both dates given. assert!( x.from.is_some() && x.to.is_some(), - "{}: vendor_1099_paid gives from and to", - ex.id + "{}: {} gives from and to", + ex.id, + x.query ); let (from, to) = x.period(ex); let year = chrono::Datelike::year(&to); @@ -999,28 +1197,221 @@ fn check_vendor_1099_paid( ); assert!( !ex.setup.cash_accounts.is_empty(), - "{}: vendor_1099_paid needs cash_accounts (what payees are paid from)", - ex.id + "{}: {} needs cash_accounts (what payees are paid from)", + ex.id, + x.query ); let sources = PaymentAccounts { cash: codes(l, &ex.setup.cash_accounts, &ex.id), financing: codes(l, &ex.setup.financing_accounts, &ex.id), }; - let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &[]) + let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &b.party_members) .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); - exactly(ex, x, &payees.keys().cloned().collect()); + (from, to, r) +} + +/// Check one `vendor_1099_paid` expectation: each payee's payments that +/// count toward its 1099 ([`report_1099`]), a payment card's left out, +/// every payee listed (rules.md B-19, B-33). +fn check_vendor_1099_paid(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + let (from, to, r) = report_1099(b, x); + exactly(ex, x, &b.payees.keys().cloned().collect()); for (name, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {name}", ex.id)); let got: Decimal = r .vendors .iter() - .filter(|v| v.vendor_id == payees[name]) + .filter(|v| v.vendor_id == b.payees[name]) .map(|v| v.paid) .sum(); assert_eq!(got, want, "{} 1099 paid [{from} .. {to}]: {name}", ex.id); } } +/// The money party shares resolve against: the cash accounts (every +/// asset account but receivables when none are named), the cards and the +/// financing accounts (rules.md B-35 item 5). +fn party_money(ex: &Example, l: &Ledger) -> MoneyAccounts { + ledger_core::parties::money_accounts( + l, + &codes(l, &ex.setup.cash_accounts, &ex.id), + &codes(l, &ex.setup.financing_accounts, &ex.id), + ) +} + +/// The party an expectation names. +fn expect_party(b: &Books<'_>, x: &Expect) -> PartyId { + let name = x + .party + .as_deref() + .unwrap_or_else(|| panic!("{}: {} names its party", b.ex.id, x.query)); + *b.parties + .get(name) + .unwrap_or_else(|| panic!("{}: party {name:?} not in setup.parties", b.ex.id)) +} + +/// Check one `party_part` expectation: entry N's part for the party +/// (rules.md B-35): `resolved`, signed as a line on the party's own +/// account (debit-positive: paid or given positive, received negative), +/// the entry's `direction` (`paid`, `received` or `neither`), and what +/// of it moved as money (`cash`) and as property (`in_kind`); every one +/// given. +fn check_party_part(b: &Books<'_>, x: &Expect) { + let (ex, l) = (b.ex, &b.l); + assert!( + x.from.is_none() && x.to.is_none() && x.as_of.is_none(), + "{}: party_part asks about one entry, no dates", + ex.id + ); + let n = x + .entry + .unwrap_or_else(|| panic!("{}: a party_part query names its entry", ex.id)); + let party = expect_party(b, x); + let r = ledger_core::shares::resolve( + l, + n, + l.entry_parties(n), + &party_money(ex, l), + &b.party_members, + ) + .unwrap_or_else(|why| panic!("{}: entry {n}'s shares: {why}", ex.id)); + let part = r + .part(party) + .unwrap_or_else(|| panic!("{}: entry {n} doesn't name {:?}", ex.id, x.party)); + let keys = ["resolved", "direction", "cash", "in_kind"]; + exactly(ex, x, &keys.iter().map(|k| k.to_string()).collect()); + let what = |k: &str| format!("{} entry {n} {:?}: {k}", ex.id, x.party); + for (key, want) in x.figures(&ex.id) { + if key == "direction" { + assert_eq!(r.target.direction.key(), want, "{}", what(key)); + continue; + } + let got = match key.as_str() { + "resolved" => part.amount, + "cash" => part.cash, + _ => part.in_kind, + }; + assert_eq!(got, exact(want, &what(key)), "{}", what(key)); + } +} + +/// Check one `party_totals` expectation: what was `paid` to and +/// `received` from the party from `from` through `to` on `basis` (`cash`: +/// money; `all`: money and property in kind), each entry's part on its +/// root original's side (rules.md B-35 item 4); both given. +fn check_party_totals(b: &Books<'_>, x: &Expect) { + let (ex, l) = (b.ex, &b.l); + assert!( + x.from.is_some() && x.to.is_some(), + "{}: party_totals gives from and to", + ex.id + ); + let (from, to) = x.period(ex); + let basis = x + .basis + .as_deref() + .map(|k| Basis::parse(k).unwrap_or_else(|| panic!("{}: basis {k:?}", ex.id))) + .unwrap_or_else(|| panic!("{}: party_totals names its basis", ex.id)); + let party = expect_party(b, x); + let a = ledger_core::parties::activity_with( + l, + party, + (from, to), + &party_money(ex, l), + &b.party_members, + ); + assert!( + a.unresolved.is_empty(), + "{}: {:?}'s entries {:?} don't resolve", + ex.id, + x.party, + a.unresolved + ); + let (paid, received) = a.totals(basis); + exactly(ex, x, &["paid".to_string(), "received".to_string()].into()); + let f = x.figures(&ex.id); + let what = |k: &str| { + format!( + "{} {:?} [{from} .. {to}] {}: {k}", + ex.id, + x.party, + basis.key() + ) + }; + assert_eq!(paid, exact(&f["paid"], &what("paid")), "{}", what("paid")); + assert_eq!( + received, + exact(&f["received"], &what("received")), + "{}", + what("received") + ); +} + +/// Check one `party_tin_last4` expectation: the last four of the party's +/// TIN the books keep (its TIN facts', else its W-9's; `""` for none: +/// rules.md B-26). +fn check_party_tin_last4(b: &Books<'_>, x: &Expect) { + let ex = b.ex; + assert!( + x.from.is_none() && x.to.is_none() && x.as_of.is_none(), + "{}: party_tin_last4 takes no dates", + ex.id + ); + let party = expect_party(b, x); + exactly(ex, x, &["tin_last4".to_string()].into()); + let got = + b.l.get_party(party) + .and_then(|p| p.tin_last4()) + .unwrap_or_default(); + assert_eq!( + got, + x.figures(&ex.id)["tin_last4"], + "{}: {:?}'s TIN last four", + ex.id, + x.party + ); +} + +/// Check one `k1` expectation: the K-1 worksheet over the period (rules.md +/// B-13; Schedule K-1 Item L), `"."` for the columns +/// `beginning`, `contributions_cash`, `contributions_property`, +/// `net_income`, `distributions`, `other` and `ending`; the figures named +/// are the example's choice. It needs `cash_accounts` (what is money +/// rather than property). +fn check_k1(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { + let (from, to) = x.period(ex); + assert!( + !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), + "{}: k1 needs the members and their shares", + ex.id + ); + let cash = codes(l, &ex.setup.cash_accounts, &ex.id); + let rows = ledger_core::k1::k1_worksheet(l, from, to, members, &cash) + .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); + let mut got = BTreeMap::new(); + for r in &rows { + for (column, v) in [ + ("beginning", r.beginning), + ("contributions_cash", r.contributions_cash), + ("contributions_property", r.contributions_property), + ("net_income", r.net_income), + ("distributions", r.distributions), + ("other", r.other), + ("ending", r.ending), + ] { + got.insert(format!("{}.{column}", r.name), v); + } + } + for (key, want) in x.figures(&ex.id) { + let want = exact(want, &format!("{} {key}", ex.id)); + let got = got + .get(key) + .unwrap_or_else(|| panic!("{}: k1 has no {key}", ex.id)); + assert_eq!(*got, want, "{} K-1 [{from} .. {to}]: {key}", ex.id); + } +} + /// The formats appendix's queries, and the test (another crate's) that /// checks them against the engine. const FORMAT_QUERIES: [(&str, &str); 2] = [ @@ -1374,7 +1765,13 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { queries.join(" and ") ); }; - only(x.entry.is_some(), "entry", &["entry"]); + only(x.entry.is_some(), "entry", &["entry", "party_part"]); + only( + x.party.is_some(), + "party", + &["party_part", "party_totals", "party_tin_last4"], + ); + only(x.basis.is_some(), "basis", &["party_totals"]); only( x.code.is_some(), "code", @@ -1419,10 +1816,14 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { "cash_flow" => check_cash_flow(ex, l, &b.members, x), "trial_balance" => check_trial_balance(ex, l, x), "profit_and_loss" => check_profit_and_loss(ex, l, x), - "vendor_1099_paid" => check_vendor_1099_paid(ex, l, &b.payees, x), + "vendor_1099_paid" => check_vendor_1099_paid(b, x), "entry" => check_entry(ex, l, x), "balances" => check_balances(ex, l, x), "members_equity" => check_members_equity(ex, l, &b.members, x), + "k1" => check_k1(ex, l, &b.members, x), + "party_part" => check_party_part(b, x), + "party_totals" => check_party_totals(b, x), + "party_tin_last4" => check_party_tin_last4(b, x), q => { let &(_, by) = FORMAT_QUERIES .iter() @@ -1508,8 +1909,8 @@ fn every_bookspec_example_holds() { } // Each query the examples use ran: the cash flow's six examples at // least, the as-of and 1099 examples', the members closes', the - // formats appendix's, and P3's default period, rename and tax - // category examples'. + // formats appendix's, and P3's default period, rename, tax category + // and parties examples'. for (q, at_least) in [ ("cash_flow", 6), ("trial_balance", 2), @@ -1517,7 +1918,11 @@ fn every_bookspec_example_holds() { ("vendor_1099_paid", 3), ("entry", 8), ("balances", 4), - ("members_equity", 5), + ("members_equity", 10), + ("k1", 2), + ("party_part", 27), + ("party_totals", 21), + ("party_tin_last4", 5), ("iif_lines", 1), ("iif_changes", 1), ("period", 6), @@ -1842,3 +2247,53 @@ fn a_command_with_values_is_refused() { ) }); } + +/// An event's parties must be in the setup's directory. +#[test] +#[should_panic(expected = "not in setup")] +fn a_party_not_in_the_setup_is_refused() { + run_edited("p3-party-signs-and-reversals.toml", |t| { + t.replacen( + "parties = [{ party = \"Tobin Harlow\" }]", + "parties = [{ party = \"Tobin Harlo\" }]", + 1, + ) + }); +} + +/// A party's part is checked for its direction as well as its figures: +/// a contribution read as paid fails. +#[test] +#[should_panic(expected = "entry 2 Some(\"Tobin Harlow\"): direction")] +fn a_party_parts_direction_is_checked() { + run_edited("p3-party-signs-and-reversals.toml", |t| { + t.replacen( + "resolved = \"-400.00\", direction = \"received\"", + "resolved = \"-400.00\", direction = \"paid\"", + 1, + ) + }); +} + +/// A reversal takes its original's parties: naming them again is +/// refused, never read as a second tag. +#[test] +#[should_panic(expected = "takes its original's parties")] +fn a_reversal_naming_parties_is_refused() { + run_edited("p3-party-signs-and-reversals.toml", |t| { + t.replacen( + "reverses = 4\n", + "reverses = 4\nparties = [{ party = \"Tobin Harlow\" }]\n", + 1, + ) + }); +} + +/// Party totals name their basis: cash and all differ for property. +#[test] +#[should_panic(expected = "party_totals names its basis")] +fn party_totals_without_a_basis_are_refused() { + run_edited("p3-party-signs-and-reversals.toml", |t| { + t.replacen("basis = \"all\"\n", "", 1) + }); +} diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index 57022c4..71fe559 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -315,4 +315,37 @@ proptest! { prop_assert_eq!(fi.into_iter().map(dec).collect::>(), fd); prop_assert_eq!(ti.into_iter().map(dec).collect::>(), td); } + + /// rules.md B-35: a party's target, and a reversal's, are the same on + /// i64 cents and on Decimal. + #[test] + fn party_target_agrees( + capital in prop::option::of((arb_cents(), 0i64..MAX_CENTS)), + net in arb_cents(), + own in arb_cents(), + debits in 0i64..MAX_CENTS, + ) { + let as_dec = |t: kernel::PartyTarget| kernel::PartyTarget { + amount: dec(t.amount), + direction: t.direction, + cash: dec(t.cash), + in_kind: dec(t.in_kind), + }; + let ti = kernel::party_target(capital, net, own, debits); + let td = kernel::party_target( + capital.map(|(e, p)| (dec(e), dec(p))), + dec(net), + dec(own), + dec(debits), + ); + prop_assert_eq!(ti.map(as_dec), td); + // Never a negative zero on Decimal (party_target INVARIANT 6). + for t in td.iter().chain(td.and_then(kernel::reverse_target).iter()) { + for v in [t.amount, t.cash, t.in_kind] { + prop_assert!(!(v.is_zero() && v.is_sign_negative()), "{t:?}"); + } + } + let ri = ti.and_then(kernel::reverse_target); + prop_assert_eq!(ri.map(as_dec), td.and_then(kernel::reverse_target)); + } } diff --git a/crates/ledger-core/tests/spec_bounds.rs b/crates/ledger-core/tests/spec_bounds.rs index ae21c74..e261c6e 100644 --- a/crates/ledger-core/tests/spec_bounds.rs +++ b/crates/ledger-core/tests/spec_bounds.rs @@ -561,9 +561,8 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() { )); b.ledger.replay_set_entry_parties(e, &shares).unwrap(); assert_eq!(b.ledger.entry_parties(e), &shares); - let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash]); - let why = ledger_core::shares::resolve(b.ledger.get_entry(e).unwrap(), &shares, &money, &[]) - .unwrap_err(); + let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash], &[]); + let why = ledger_core::shares::resolve(&b.ledger, e, &shares, &money, &[]).unwrap_err(); assert!(why.contains("too large to compute"), "{why}"); // Replay still checks what every version checked. assert_eq!( diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index db0752a..d5b8019 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -132,6 +132,7 @@ fn bare_party(name: &str) -> Party { tin: None, vendor: None, customer: None, + creditor_of: BTreeSet::new(), } } @@ -1401,14 +1402,14 @@ 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 mut acct = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap(); + let bank = acct("1000", "Bank", AccountType::Asset); + let card_a = acct("2050", "Card A", AccountType::Liability); + let card_b = acct("2060", "Card B", AccountType::Liability); + let members = acct("2100", "Due to Members", AccountType::Liability); + let capital = acct("3000", "Capital", AccountType::Equity); + let fees = acct("6000", "Fees", AccountType::Expense); + let money = money_accounts(&l, &[bank], &[members]); 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"), @@ -1437,11 +1438,15 @@ fn entry_flow_takes_across_in_order_until_used_up() { ); } -/// Oracle: shares::share_target: an entry moving no money splits over its -/// total debits, whatever its credits (a draft not yet balanced). +/// Oracle: shares::target DEFINITIONS, case 3 (rules.md B-35): members +/// named on an entry moving no money split what moved equity, E (here +/// −4.00 of a draft not yet balanced), received, in kind (the equipment's +/// 10.00, at most |E|); case 5: with a party named who isn't a member, +/// the total debits, direction neither, counting nothing. #[test] -fn a_share_target_without_money_is_the_debits() { +fn a_share_target_without_money_is_the_capital_or_the_debits() { use ledger_core::parties::money_accounts; + use ledger_core::shares::{Direction, Target, target}; use ledger_core::{AccountType, Currency, Ledger, Money, Side}; let mut l = Ledger::new(Currency::USD); let bank = l @@ -1453,7 +1458,9 @@ fn a_share_target_without_money_is_the_debits() { let capital = l .add_account("3000", "Capital", AccountType::Equity, None, false) .unwrap(); - let money = money_accounts(&l, &[bank]); + let member = l.add_party("Tobin Harlow", &[Role::Member]).unwrap(); + let vendor = l.add_party("Riley Chen", &[Role::Contractor]).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(); @@ -1465,8 +1472,25 @@ fn a_share_target_without_money_is_the_debits() { ) .unwrap(); assert_eq!( - ledger_core::shares::share_target(l.get_entry(e).unwrap(), &money), - Ok(d("10.00")) + target(&l, e, &[PartyShare::whole(member)], &money), + Ok(Target { + amount: d("-4.00"), + direction: Direction::Received, + cash: Decimal::ZERO, + in_kind: d("-4.00"), + counts_as: Direction::Received, + }) + ); + let both = [PartyShare::whole(member), PartyShare::whole(vendor)]; + assert_eq!( + target(&l, e, &both, &money), + Ok(Target { + amount: d("10.00"), + direction: Direction::Neither, + cash: Decimal::ZERO, + in_kind: Decimal::ZERO, + counts_as: Direction::Neither, + }) ); } @@ -1484,7 +1508,7 @@ fn derived_member_shares_net_their_capital_lines() { 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 money = money_accounts(&l, &[bank], &[]); let member = |party, capital| ledger_core::PartyMember { party, share: d("0.5"), @@ -1506,9 +1530,13 @@ fn derived_member_shares_net_their_capital_lines() { s.share = ledger_core::Share::Derived; s }); + let r = ledger_core::shares::resolve(&l, e, &shares, &money, &members).unwrap(); assert_eq!( - ledger_core::shares::resolve(l.get_entry(e).unwrap(), &shares, &money, &members), - Ok(vec![(a, d("-70.00")), (b, d("-30.00"))]) + r.parts + .iter() + .map(|p| (p.party, p.amount)) + .collect::>(), + vec![(a, d("-70.00")), (b, d("-30.00"))] ); } @@ -1520,16 +1548,17 @@ fn entry_flow_scenarios() { 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 mut acct = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap(); + let bank = acct("1000", "Bank", AccountType::Asset); + let savings = acct("1010", "Savings", AccountType::Asset); + let equip = acct("1500", "Equipment", AccountType::Asset); + let card = acct("2050", "Business Card", AccountType::Liability); + let members = acct("2100", "Due to Members", AccountType::Liability); + let loan = acct("2500", "Loan", AccountType::Liability); + let accrued = acct("2200", "Accrued Expenses", AccountType::Liability); + let capital = acct("3000", "Capital", AccountType::Equity); + let exp = acct("6000", "Expenses", AccountType::Expense); + let money = money_accounts(&l, &[bank, savings], &[members, loan]); 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"); @@ -1619,6 +1648,13 @@ fn entry_flow_scenarios() { (card, C, "50.00"), ]); assert_eq!(f.net, dec("350.00")); + // 8 (defect 24). An accrual through a liability that isn't money + // moves none; its settlement is the payment, counted once. + let f = case(&[(exp, D, "40.00"), (accrued, C, "40.00")]); + assert_eq!(f.net, Decimal::ZERO); + let f = case(&[(accrued, D, "40.00"), (bank, C, "40.00")]); + assert_eq!(f.net, dec("40.00")); + assert_eq!(f.what_for, vec![(accrued, dec("40.00"))]); } // ------------------------------------------------------- party shares @@ -1729,25 +1765,33 @@ mod shares { .collect() } + /// The money accounts: the bank, and 2100 as the members' financing + /// (`books.financing_accounts`). + fn money(b: &Books) -> ledger_core::parties::MoneyAccounts { + money_accounts(&b.l, &[b.bank], &[b.due]) + } + fn resolved(b: &Books, e: EntryId) -> Result, String> { - let money = money_accounts(&b.l, &[b.bank]); - resolve( - b.l.get_entry(e).unwrap(), - b.l.entry_parties(e), - &money, - &b.members, - ) + resolve(&b.l, e, b.l.entry_parties(e), &money(b), &b.members) + .map(|r| r.parts.iter().map(|p| (p.party, p.amount)).collect()) } fn year() -> (chrono::NaiveDate, chrono::NaiveDate) { (date(2026, 1, 1), date(2026, 12, 31)) } + /// (paid, received) on basis cash. fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) { - let a = activity_with(&b.l, p, year(), &[b.bank], &b.members); + let a = activity_with(&b.l, p, year(), &money(b), &b.members); (a.paid, a.received) } + /// (paid, received) on basis all. + fn paid_received_all(b: &Books, p: PartyId) -> (Decimal, Decimal) { + activity_with(&b.l, p, year(), &money(b), &b.members) + .totals(ledger_core::parties::Basis::All) + } + /// SCENARIO 1: a reimbursement to both members, derived, 50/50. #[test] fn reimbursing_both_members_splits_by_member_share() { @@ -1793,8 +1837,11 @@ mod shares { assert_eq!(paid_received(&b, b.mo), (Decimal::ZERO, d("100.00"))); } - /// SCENARIO 3: property contributed moves no money; the shares are of - /// the total, and activity counts nothing. + /// SCENARIO 3 (defect 14, rules.md B-35): property contributed moves + /// no money; each member's part is what moved their capital, signed + /// as their own account shows it: received, in kind. Basis cash + /// counts nothing; basis all counts it. (Before P3 this read +500.00 + /// each, the sign of a payment.) #[test] fn property_contribution_resolves_against_the_total() { let mut b = books(); @@ -1810,14 +1857,52 @@ mod shares { .unwrap(); assert_eq!( resolved(&b, e).unwrap(), - vec![(b.tobin, d("500.00")), (b.mo, d("500.00"))] + vec![(b.tobin, d("-500.00")), (b.mo, d("-500.00"))] + ); + let r = resolve(&b.l, e, b.l.entry_parties(e), &money(&b), &b.members).unwrap(); + assert_eq!(r.target.direction, ledger_core::shares::Direction::Received); + assert!( + r.parts + .iter() + .all(|p| p.cash.is_zero() && p.in_kind == d("-500.00")) ); assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, Decimal::ZERO)); + assert_eq!(paid_received_all(&b, b.tobin), (Decimal::ZERO, d("500.00"))); + assert_eq!(paid_received_all(&b, b.mo), (Decimal::ZERO, d("500.00"))); } - /// Capital lines that don't account for the whole flow are refused. + /// INVARIANT 10: capital lines that don't account for the whole + /// movement of equity (here another equity account's line) are + /// refused, and the entry is listed as unresolved. #[test] - fn capital_lines_short_of_the_flow_are_refused() { + fn capital_lines_short_of_the_target_are_refused() { + let mut b = books(); + let other = + b.l.add_account("3500", "Opening Equity", AccountType::Equity, None, false) + .unwrap(); + let e = post( + &mut b.l, + &[ + (b.bank, Side::Debit, "300.00"), + (b.cap_s, Side::Credit, "200.00"), + (other, Side::Credit, "100.00"), + ], + ); + b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])) + .unwrap(); + assert!(resolved(&b, e).is_err()); + let a = activity_with(&b.l, b.tobin, year(), &money(&b), &b.members); + assert!(a.unresolved.contains(&e)); + assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO)); + } + + /// rules.md B-35 item 3 (capital first): a members' entry counts what + /// moved their capital, not other money on it: 300.00 in, 200.00 of it + /// Tobin's capital and 100.00 an expense refunded, is Tobin's 200.00 + /// received (cash) and nothing of Morgan's. (Before P3 the 300.00 net + /// was the target and the capital lines fell short of it: refused.) + #[test] + fn a_members_entry_counts_what_moved_their_capital() { let mut b = books(); let e = post( &mut b.l, @@ -1829,10 +1914,12 @@ mod shares { ); b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])) .unwrap(); - assert!(resolved(&b, e).is_err()); - let a = activity_with(&b.l, b.tobin, year(), &[b.bank], &b.members); - assert!(a.unresolved.contains(&e)); - assert_eq!((a.paid, a.received), (Decimal::ZERO, Decimal::ZERO)); + assert_eq!( + resolved(&b, e).unwrap(), + vec![(b.tobin, d("-200.00")), (b.mo, Decimal::ZERO)] + ); + assert_eq!(paid_received(&b, b.tobin), (Decimal::ZERO, d("200.00"))); + assert_eq!(paid_received(&b, b.mo), (Decimal::ZERO, Decimal::ZERO)); } /// SCENARIO 4: percent rounding, remainder to the first party. @@ -1907,8 +1994,7 @@ mod shares { // With no members passed (plain activity), two members can't derive. b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])) .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()); + assert!(resolve(&b.l, e, b.l.entry_parties(e), &money(&b), &[]).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"))]); @@ -2147,7 +2233,7 @@ mod shares { ], ); b.l.set_entry_party(e, Some(b.acme)).unwrap(); - let a = ledger_core::parties::activity(&b.l, b.acme, year(), &[b.bank]); + let a = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b)); assert_eq!(a.paid, d("12.34")); assert!(a.entries.contains(&e)); } @@ -2168,14 +2254,14 @@ mod shares { 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]); + let act = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b)); 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]); + let act = ledger_core::parties::activity(&b.l, b.acme, year(), &money(&b)); assert_eq!((act.paid, act.received), (Decimal::ZERO, Decimal::ZERO)); } @@ -2255,8 +2341,8 @@ mod shares { b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])).unwrap(); let r = resolved(&b, e).unwrap(); prop_assert_eq!(r, vec![(b.tobin, -a), (b.mo, -c)]); - let sa = activity_with(&b.l, b.tobin, year(), &[b.bank], &b.members); - let sk = activity_with(&b.l, b.mo, year(), &[b.bank], &b.members); + let sa = activity_with(&b.l, b.tobin, year(), &money(&b), &b.members); + let sk = activity_with(&b.l, b.mo, year(), &money(&b), &b.members); prop_assert_eq!(sa.received + sk.received, total); } } diff --git a/crates/ledger-core/tests/spec_party_totals.proptest-regressions b/crates/ledger-core/tests/spec_party_totals.proptest-regressions new file mode 100644 index 0000000..7ff00da --- /dev/null +++ b/crates/ledger-core/tests/spec_party_totals.proptest-regressions @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 816160434c103e96360af20c33f0f16533f84b4c6599ea517ce6b1569139411f # shrinks to lines = [(0, Debit, 1), (1, Credit, 1)], who = Vendor, owed = [] +cc 38876dbe146abc3025abf7a96f9addc1de590a95de893435df024c709cb0672e # shrinks to steps = [(true, 0, false)] diff --git a/crates/ledger-core/tests/spec_party_totals.rs b/crates/ledger-core/tests/spec_party_totals.rs new file mode 100644 index 0000000..e9e7878 --- /dev/null +++ b/crates/ledger-core/tests/spec_party_totals.rs @@ -0,0 +1,1767 @@ +//! P3 defects 14, 24 and 11: a party's part of an entry, a party's +//! totals, and the directory's TIN last four. +//! +//! Oracles: rules.md B-35 (a party's part, signed as a line on its own +//! account, with its direction and its money and property; party totals +//! on basis cash or all; a member's entry counts what moved their capital; +//! money is cash, cards, the members' financing and loans; a liability +//! credit never counts as paying that liability's own creditor), B-9 (a +//! correction is the opposite entry), B-13 and the K-1 worksheet (k1 +//! INVARIANT 3: what is property), B-26 (a TIN's last four), and the spec +//! blocks of `ledger_core::shares`, `ledger_core::parties` (`flow`, +//! `activity_with`, `Party::tin_last4`) and `ledger_core::kernel` +//! (`party_target`, `reverse_target`). Every expected value is worked by +//! hand from those texts and the fixture's lines; nothing is read back +//! from the code under test. +//! +//! The fixture is the shape of the first company's books at the 2026-10-16 +//! audit (findings F36, F37), anonymized, its names and every amount made +//! up: two members, Tobin Harlow and Morgan Ellis, 50/50, a bank account, a +//! card, the members' financing account, equipment, prepaid expenses. + +use std::str::FromStr; + +use chrono::NaiveDate; +use ledger_core::k1::k1_detail; +use ledger_core::kernel::{self, Direction, PartyTarget}; +use ledger_core::parties::{Basis, MoneyAccounts, activity_with, entry_flow, flow, money_accounts}; +use ledger_core::reports::{MemberCapital, members_equity}; +use ledger_core::shares::{Part, PartyShare, Share, Target, resolve, target}; +use ledger_core::{ + AccountId, AccountType, Currency, DocumentLine, EntryId, Ledger, LedgerError, Money, + PartyField, PartyId, PartyMember, Role, Side, TinFacts, TinType, W9Facts, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +fn d(s: &str) -> Decimal { + Decimal::from_str(s).expect("decimal literal") +} + +fn date(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).expect("a 2026 date") +} + +fn year() -> (NaiveDate, NaiveDate) { + (date(1, 1), date(12, 31)) +} + +/// Fernhill Labs LLC's chart and directory. +struct Books { + l: Ledger, + cash: AccountId, + prepaid: AccountId, + equip: AccountId, + card: AccountId, + due: AccountId, + accrued: AccountId, + deferred: AccountId, + note: AccountId, + /// Distributions declared and not yet paid: a liability that isn't + /// money (not a card, not a financing account). + dist_payable: AccountId, + /// Gift cards the company sold: deferred revenue, though its name says + /// "card". + gift: AccountId, + accum: AccountId, + cap_t: AccountId, + cap_m: AccountId, + dist_t: AccountId, + dist_m: AccountId, + revenue: AccountId, + org: AccountId, + hosting: AccountId, + fees: AccountId, + tobin: PartyId, + morgan: PartyId, + /// A vendor (it has a vendor record): Northwind Filings. + vendor: PartyId, + vendor_id: u64, + /// The card's issuer: Harbor Bank. + issuer: PartyId, + /// A customer (it has a customer record): Riley Chen. + customer: PartyId, + customer_id: u64, +} + +fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let mut add = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap(); + let cash = add("1000", "Checking", AccountType::Asset); + let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset); + let equip = add("1500", "Equipment", AccountType::Asset); + let card = add("2050", "Business Card", AccountType::Liability); + let due = add("2100", "Due to Members", AccountType::Liability); + let accrued = add("2200", "Accrued Expenses", AccountType::Liability); + let deferred = add("2400", "Deferred Revenue", AccountType::Liability); + let note = add("2500", "Note Payable", AccountType::Liability); + let dist_payable = add("2150", "Distributions Payable", AccountType::Liability); + let gift = add("2410", "Gift Card Liability", AccountType::Liability); + let accum = add( + "1510", + "Accumulated Depreciation — Equipment", + AccountType::Asset, + ); + let cap_t = add("3000", "Capital — Tobin Harlow", AccountType::Equity); + let cap_m = add("3001", "Capital — Morgan Ellis", AccountType::Equity); + let dist_t = add("3100", "Distributions — Tobin Harlow", AccountType::Equity); + let dist_m = add("3101", "Distributions — Morgan Ellis", AccountType::Equity); + let revenue = add("4000", "Revenue", AccountType::Revenue); + let org = add("6000", "Organizational Costs", AccountType::Expense); + let hosting = add("6050", "Domain & Hosting", AccountType::Expense); + let fees = add("6400", "Bank Fees", AccountType::Expense); + let tobin = l.add_party("Tobin Harlow", &[Role::Member]).unwrap(); + let morgan = l.add_party("Morgan Ellis", &[Role::Member]).unwrap(); + let vendor_id = l.add_vendor("Northwind Filings").unwrap(); + let vendor = l.party_of_vendor(vendor_id).unwrap(); + let issuer = l + .add_party("Harbor Bank", &[Role::FinancialInstitution]) + .unwrap(); + let customer_id = l.add_customer("Riley Chen").unwrap(); + let customer = l.party_of_customer(customer_id).unwrap(); + Books { + l, + cash, + prepaid, + equip, + card, + due, + accrued, + deferred, + note, + dist_payable, + gift, + accum, + cap_t, + cap_m, + dist_t, + dist_m, + revenue, + org, + hosting, + fees, + tobin, + morgan, + vendor, + vendor_id, + issuer, + customer, + customer_id, + } +} + +impl Books { + /// The money accounts: the bank, the card (by its name), and the + /// members' financing and the note (`books.financing_accounts`). + fn money(&self) -> MoneyAccounts { + money_accounts(&self.l, &[self.cash], &[self.due, self.note]) + } + + /// The company's members, as the settings give them. + fn members(&self) -> Vec { + [ + (self.tobin, self.cap_t, self.dist_t), + (self.morgan, self.cap_m, self.dist_m), + ] + .map(|(party, capital, distributions)| PartyMember { + party, + share: d("0.5"), + capital, + distributions: Some(distributions), + }) + .to_vec() + } + + /// The members as the statement of members' equity and the K-1 + /// worksheet take them. + fn capitals(&self) -> Vec { + [ + ("Tobin Harlow", self.cap_t, self.dist_t), + ("Morgan Ellis", self.cap_m, self.dist_m), + ] + .map(|(name, capital_account, distributions)| MemberCapital { + name: name.into(), + capital_account, + distribution_account: Some(distributions), + share: d("0.5"), + }) + .to_vec() + } + + /// Post `lines` on `day`, tagged with `shares`. + fn post( + &mut self, + day: NaiveDate, + lines: &[(AccountId, Side, &str)], + shares: &[PartyShare], + ) -> EntryId { + let e = self.l.try_create_entry(day, "x").unwrap(); + for (a, side, v) in lines { + self.l + .add_line(e, *a, *side, Money::new(d(v), Currency::USD)) + .unwrap(); + } + self.l.post_entry(e).unwrap(); + if !shares.is_empty() { + self.l.set_entry_parties(e, shares).unwrap(); + } + e + } + + fn reverse(&mut self, e: EntryId, day: NaiveDate) -> EntryId { + self.l.reverse_entry(e, day, "reversed").unwrap() + } + + /// Entry `e`'s target and parts, as books.get_entry resolves them. + fn resolved(&self, e: EntryId) -> (Target, Vec) { + let r = resolve( + &self.l, + e, + self.l.entry_parties(e), + &self.money(), + &self.members(), + ) + .unwrap_or_else(|why| panic!("entry {e}: {why}")); + (r.target, r.parts) + } + + /// `party`'s (paid, received) over 2026 on `basis`. + fn totals(&self, party: PartyId, basis: Basis) -> (Decimal, Decimal) { + activity_with(&self.l, party, year(), &self.money(), &self.members()).totals(basis) + } +} + +fn whole(p: PartyId) -> PartyShare { + PartyShare::whole(p) +} + +fn amounts(ps: &[(PartyId, &str)]) -> Vec { + ps.iter() + .map(|&(party, v)| PartyShare { + party, + share: Share::Amount(d(v)), + }) + .collect() +} + +/// A part: (party, amount, cash, in kind). +fn part(party: PartyId, amount: &str, cash: &str, in_kind: &str) -> Part { + Part { + party, + amount: d(amount), + cash: d(cash), + in_kind: d(in_kind), + } +} + +use Side::{Credit as Cr, Debit as Dr}; + +/// The audit's reproduction (F36, F37), anonymized, with what each row +/// should say (rules.md B-35): `resolved` signed as a line on the party's +/// own account, a direction, and what of it was money or property; a +/// reversal the exact opposite of what it reverses; totals on both bases, +/// labelled. +struct Audit { + b: Books, + /// (entry, the parts it should resolve to, its direction). + rows: Vec<(EntryId, Vec, Direction)>, +} + +fn audit() -> Audit { + let mut b = books(); + let (t, m, v) = (b.tobin, b.morgan, b.vendor); + let mut rows = Vec::new(); + // 1. A member pays the filing fee for the company (through 2100), + // tagged with the vendor: paid to the vendor, in money. + let e = b.post( + date(10, 4), + &[(b.org, Dr, "64.00"), (b.due, Cr, "64.00")], + &[whole(v)], + ); + rows.push((e, vec![part(v, "64.00", "64.00", "0")], Direction::Paid)); + // 4. Each member's cash contribution: received, money. + let e = b.post( + date(10, 5), + &[(b.cash, Dr, "400.00"), (b.cap_t, Cr, "400.00")], + &[whole(t)], + ); + rows.push(( + e, + vec![part(t, "-400.00", "-400.00", "0")], + Direction::Received, + )); + let e = b.post( + date(10, 5), + &[(b.cash, Dr, "400.00"), (b.cap_m, Cr, "400.00")], + &[whole(m)], + ); + rows.push(( + e, + vec![part(m, "-400.00", "-400.00", "0")], + Direction::Received, + )); + // 8. Reimbursing both members: paid, money. + let e = b.post( + date(10, 8), + &[(b.due, Dr, "186.50"), (b.cash, Cr, "186.50")], + &amounts(&[(t, "93.25"), (m, "93.25")]), + ); + rows.push(( + e, + vec![ + part(t, "93.25", "93.25", "0"), + part(m, "93.25", "93.25", "0"), + ], + Direction::Paid, + )); + // 9. Hosting a member paid for: paid to the vendor. + let e = b.post( + date(10, 9), + &[(b.hosting, Dr, "142.30"), (b.due, Cr, "142.30")], + &[whole(v)], + ); + rows.push((e, vec![part(v, "142.30", "142.30", "0")], Direction::Paid)); + // 11. Reimbursing it. + let e = b.post( + date(10, 11), + &[(b.due, Dr, "142.30"), (b.cash, Cr, "142.30")], + &amounts(&[(t, "71.15"), (m, "71.15")]), + ); + rows.push(( + e, + vec![ + part(t, "71.15", "71.15", "0"), + part(m, "71.15", "71.15", "0"), + ], + Direction::Paid, + )); + // 12. A laptop Tobin contributes: received, in kind (it read +1480.00, + // the sign of a payment, before P3). + let e12 = b.post( + date(10, 12), + &[(b.equip, Dr, "1480.00"), (b.cap_t, Cr, "1480.00")], + &[whole(t)], + ); + rows.push(( + e12, + vec![part(t, "-1480.00", "0", "-1480.00")], + Direction::Received, + )); + // 13. Its reversal: the exact opposite, paid, in kind (it read + // +1480.00, the same as entry 12, before P3). + let e13 = b.reverse(e12, date(10, 13)); + rows.push(( + e13, + vec![part(t, "1480.00", "0", "1480.00")], + Direction::Paid, + )); + // 14. The laptop again, half each by amount: −740.00 each. + let e = b.post( + date(10, 14), + &[ + (b.equip, Dr, "1480.00"), + (b.cap_t, Cr, "740.00"), + (b.cap_m, Cr, "740.00"), + ], + &amounts(&[(t, "740.00"), (m, "740.00")]), + ); + rows.push(( + e, + vec![ + part(t, "-740.00", "0", "-740.00"), + part(m, "-740.00", "0", "-740.00"), + ], + Direction::Received, + )); + // 15 and 18. A 960.00 device and its reversal. + let e15 = b.post( + date(10, 15), + &[ + (b.equip, Dr, "960.00"), + (b.cap_t, Cr, "480.00"), + (b.cap_m, Cr, "480.00"), + ], + &amounts(&[(t, "480.00"), (m, "480.00")]), + ); + rows.push(( + e15, + vec![ + part(t, "-480.00", "0", "-480.00"), + part(m, "-480.00", "0", "-480.00"), + ], + Direction::Received, + )); + // 16 and 17. Two more devices, derived from the capital lines. + let e = b.post( + date(10, 16), + &[ + (b.equip, Dr, "1530.00"), + (b.cap_t, Cr, "765.00"), + (b.cap_m, Cr, "765.00"), + ], + &[whole(t), whole(m)], + ); + rows.push(( + e, + vec![ + part(t, "-765.00", "0", "-765.00"), + part(m, "-765.00", "0", "-765.00"), + ], + Direction::Received, + )); + let e = b.post( + date(10, 17), + &[ + (b.equip, Dr, "690.00"), + (b.cap_t, Cr, "345.00"), + (b.cap_m, Cr, "345.00"), + ], + &[whole(t), whole(m)], + ); + rows.push(( + e, + vec![ + part(t, "-345.00", "0", "-345.00"), + part(m, "-345.00", "0", "-345.00"), + ], + Direction::Received, + )); + let e18 = b.reverse(e15, date(10, 18)); + rows.push(( + e18, + vec![ + part(t, "480.00", "0", "480.00"), + part(m, "480.00", "0", "480.00"), + ], + Direction::Paid, + )); + // 19. A device with its service contract (prepaid: property, k1 + // INVARIANT 3), derived. + let e = b.post( + date(10, 19), + &[ + (b.equip, Dr, "1010.00"), + (b.prepaid, Dr, "30.00"), + (b.cap_t, Cr, "520.00"), + (b.cap_m, Cr, "520.00"), + ], + &[whole(t), whole(m)], + ); + rows.push(( + e, + vec![ + part(t, "-520.00", "0", "-520.00"), + part(m, "-520.00", "0", "-520.00"), + ], + Direction::Received, + )); + // A bill (R1): Cr payables, the vendor's own account: −80.00, neither; + // its reversal +80.00 (both read +80.00 before P3). + let bill = + b.l.create_bill( + b.vendor_id, + date(10, 20), + date(11, 19), + vec![DocumentLine { + description: "filing".into(), + amount: d("80.00"), + }], + ) + .unwrap(); + let e = b.l.get_bill(bill).unwrap().entry_id; + rows.push((e, vec![part(v, "-80.00", "0", "0")], Direction::Neither)); + let r = b.reverse(e, date(10, 21)); + rows.push((r, vec![part(v, "80.00", "0", "0")], Direction::Neither)); + // An invoice: Dr receivables, the customer's own account: +120.00, + // neither. + let inv = + b.l.create_invoice( + b.customer_id, + date(10, 20), + date(11, 19), + vec![DocumentLine { + description: "consulting".into(), + amount: d("120.00"), + }], + ) + .unwrap(); + let e = b.l.get_invoice(inv).unwrap().entry_id; + rows.push(( + e, + vec![part(b.customer, "120.00", "0", "0")], + Direction::Neither, + )); + Audit { b, rows } +} + +/// Defect 14, the audit's table row by row: each part signed as a line on +/// the party's own account, with its direction and its money and +/// property; every reversal the exact opposite of what it reverses. +#[test] +fn defect14_shares_take_the_partys_side_and_reversals_flip() { + let Audit { b, rows } = audit(); + for (e, want, direction) in &rows { + let (t, parts) = b.resolved(*e); + assert_eq!(&parts, want, "entry {e}"); + assert_eq!(t.direction, *direction, "entry {e}"); + let sum: Decimal = parts.iter().map(|p| p.amount).sum(); + assert_eq!(t.amount, sum, "entry {e}: the parts sum to the target"); + // A reversal counts on its original's side. + let original = b.l.get_entry(*e).unwrap().reversal_of(); + let expect_side = match original { + Some(_) => direction.flip(), + None => *direction, + }; + assert_eq!(t.counts_as, expect_side, "entry {e}"); + // Never a negative zero (INVARIANT 6). + for p in &parts { + for v in [p.amount, p.cash, p.in_kind] { + assert!(!(v.is_zero() && v.is_sign_negative()), "entry {e}: -0"); + } + } + } + // Totals: the members' money is unchanged by P3 (paid 93.25 + 71.15, + // received 400.00 each); basis all adds their property: 740.00 + + // 765.00 + 345.00 + 520.00 = 2,370.00, the 1,480.00 and 480.00 + // contributions reversed in the year net to nothing. + for who in [b.tobin, b.morgan] { + assert_eq!(b.totals(who, Basis::Cash), (d("164.40"), d("400.00"))); + assert_eq!(b.totals(who, Basis::All), (d("164.40"), d("2770.00"))); + } + // The vendor: the two payments members made for the company; the bill + // and its reversal count nothing on either basis. + assert_eq!( + b.totals(b.vendor, Basis::Cash), + (d("206.30"), Decimal::ZERO) + ); + assert_eq!(b.totals(b.vendor, Basis::All), (d("206.30"), Decimal::ZERO)); + // The customer: invoiced, nothing received yet. + assert_eq!( + b.totals(b.customer, Basis::All), + (Decimal::ZERO, Decimal::ZERO) + ); +} + +/// B-35 item 4: on the member-only entries, basis all is the statement of +/// members' equity (contributions and distributions, the same dates), and +/// basis cash is the K-1 worksheet's cash contributions. +#[test] +fn basis_all_is_the_members_equity_statement_and_cash_the_k1s() { + let Audit { b, .. } = audit(); + let (start, end) = year(); + let me = members_equity(&b.l, start, end, &b.capitals()).unwrap(); + let k1 = k1_detail(&b.l, start, end, &b.capitals(), &[b.cash]).unwrap(); + for (i, who) in [b.tobin, b.morgan].into_iter().enumerate() { + // The reimbursements (164.40 paid) are money the member was paid + // back, not equity: they are the only entries naming the member + // that don't move their capital. + let (paid_all, received_all) = b.totals(who, Basis::All); + assert_eq!(received_all, me.members[i].contributions); + assert_eq!(paid_all - d("164.40"), me.members[i].distributions); + let (_, received_cash) = b.totals(who, Basis::Cash); + assert_eq!(received_cash, k1.rows[i].contributions_cash); + assert_eq!( + received_all - received_cash, + k1.rows[i].contributions_property + ); + assert_eq!(me.members[i].contributions, d("2770.00")); + assert_eq!(k1.rows[i].contributions_cash, d("400.00")); + } +} + +/// Defect 14, second bug (brief §1.4): a member pays the card from their +/// own funds (Dr 2050 / Cr 2100), tagged with the issuer: paid 100.00. Its +/// reversal read as another payment (paid 200.00); now it nets to 0.00, +/// and reversing the reversal brings 100.00 back. +#[test] +fn defect14_a_reversed_member_card_payment_nets_to_zero() { + let mut b = books(); + let issuer = b.issuer; + let e1 = b.post( + date(6, 1), + &[(b.card, Dr, "100.00"), (b.due, Cr, "100.00")], + &[whole(issuer)], + ); + assert_eq!(b.totals(issuer, Basis::Cash), (d("100.00"), Decimal::ZERO)); + let e2 = b.reverse(e1, date(6, 2)); + assert_eq!( + b.totals(issuer, Basis::Cash), + (Decimal::ZERO, Decimal::ZERO) + ); + // Why parties::flow exists: entry_flow reads the reversal's own lines + // as another payment (its `across` term is the same either way). + let money = b.money(); + assert_eq!( + entry_flow(b.l.get_entry(e2).unwrap(), &money).unwrap().net, + d("100.00") + ); + let f1 = flow(&b.l, e1, &[whole(issuer)], &money).unwrap(); + let f2 = flow(&b.l, e2, &[whole(issuer)], &money).unwrap(); + assert_eq!( + (f1.net, f1.what_for.clone()), + (d("100.00"), vec![(b.card, d("100.00"))]) + ); + assert_eq!( + (f2.net, f2.what_for), + (d("-100.00"), vec![(b.card, d("-100.00"))]) + ); + let e3 = b.reverse(e2, date(6, 3)); + assert_eq!(b.totals(issuer, Basis::Cash), (d("100.00"), Decimal::ZERO)); + assert_eq!(flow(&b.l, e3, &[whole(issuer)], &money).unwrap(), f1); +} + +/// Defect 24: an accrual through a liability that is not money (accrued +/// expenses, deferred revenue) counts once, when it is settled, not also +/// when it is booked. Before P3 every liability but payables and payroll +/// was money: the vendor read paid 600.00 and the customer received +/// 1,300.00. +#[test] +fn defect24_an_accrual_through_another_liability_counts_once() { + let mut b = books(); + let (v, c) = (b.vendor, b.customer); + let accrue = b.post( + date(3, 31), + &[(b.hosting, Dr, "300.00"), (b.accrued, Cr, "300.00")], + &[whole(v)], + ); + let (t, _) = b.resolved(accrue); + assert_eq!((t.direction, t.cash), (Direction::Neither, Decimal::ZERO)); + b.post( + date(4, 15), + &[(b.accrued, Dr, "300.00"), (b.cash, Cr, "300.00")], + &[whole(v)], + ); + assert_eq!(b.totals(v, Basis::All), (d("300.00"), Decimal::ZERO)); + // Deferred revenue: 1,200.00 received ahead, 100.00 earned. + b.post( + date(1, 2), + &[(b.cash, Dr, "1200.00"), (b.deferred, Cr, "1200.00")], + &[whole(c)], + ); + let earn = b.post( + date(1, 31), + &[(b.deferred, Dr, "100.00"), (b.revenue, Cr, "100.00")], + &[whole(c)], + ); + assert_eq!(b.resolved(earn).0.direction, Direction::Neither); + assert_eq!(b.totals(c, Basis::All), (Decimal::ZERO, d("1200.00"))); +} + +/// Defect 24 and B-35 item 1: a credit to what the company owes a party +/// itself never counts as paying it. A fee on the issuer's own card is a +/// bill from the issuer (counted when the card is paid); a distribution +/// still owed to a member counts when it is paid; equipment bought on the +/// seller's own note is not paid for until the note is (Helvering v. +/// Price); property contributed subject to a loan is the member's +/// property net of the loan, all in kind (Treas. Reg. +/// §1.704-1(b)(2)(iv)(b)(2)). +#[test] +fn defect24_a_credit_to_a_partys_own_liability_never_pays_it() { + let mut b = books(); + let (issuer, t, m) = (b.issuer, b.tobin, b.morgan); + // The card fee, and the card paid off: until the books know the card + // is the issuer's, the fee reads as paid twice (150.00). + let fee = b.post( + date(5, 1), + &[(b.fees, Dr, "25.00"), (b.card, Cr, "25.00")], + &[whole(issuer)], + ); + b.post( + date(5, 20), + &[(b.card, Dr, "125.00"), (b.cash, Cr, "125.00")], + &[whole(issuer)], + ); + assert_eq!(b.totals(issuer, Basis::Cash), (d("150.00"), Decimal::ZERO)); + b.l.set_party_creditor(issuer, b.card, true).unwrap(); + assert_eq!(b.totals(issuer, Basis::Cash), (d("125.00"), Decimal::ZERO)); + assert_eq!(b.totals(issuer, Basis::All), (d("125.00"), Decimal::ZERO)); + let (ft, fp) = b.resolved(fee); + assert_eq!( + (ft.direction, fp[0].amount), + (Direction::Neither, d("-25.00")) + ); + + // A distribution declared through 2100, the members' own: owed, so it + // counts as nothing until it is paid; then 200.00, once. + b.l.set_party_creditor(t, b.due, true).unwrap(); + b.l.set_party_creditor(m, b.due, true).unwrap(); + let declared = b.post( + date(6, 30), + &[(b.cap_t, Dr, "200.00"), (b.due, Cr, "200.00")], + &[whole(t)], + ); + let (dt, dp) = b.resolved(declared); + assert_eq!(dt.direction, Direction::Neither); + assert_eq!(dp, vec![part(t, "200.00", "0", "0")]); + assert_eq!(b.totals(t, Basis::All), (Decimal::ZERO, Decimal::ZERO)); + b.post( + date(7, 15), + &[(b.due, Dr, "200.00"), (b.cash, Cr, "200.00")], + &[whole(t)], + ); + assert_eq!(b.totals(t, Basis::Cash), (d("200.00"), Decimal::ZERO)); + assert_eq!(b.totals(t, Basis::All), (d("200.00"), Decimal::ZERO)); + + // Equipment on the seller's own note: nothing paid until the note is. + let seller = b.l.add_party("Lakeside Supply", &[]).unwrap(); + b.l.set_party_creditor(seller, b.note, true).unwrap(); + let bought = b.post( + date(8, 1), + &[(b.equip, Dr, "900.00"), (b.note, Cr, "900.00")], + &[whole(seller)], + ); + assert_eq!( + b.resolved(bought).1, + vec![part(seller, "-900.00", "0", "0")] + ); + b.post( + date(9, 1), + &[(b.note, Dr, "300.00"), (b.cash, Cr, "300.00")], + &[whole(seller)], + ); + assert_eq!(b.totals(seller, Basis::All), (d("300.00"), Decimal::ZERO)); + + // Property worth 1,000.00 contributed subject to a 200.00 loan: 800.00 + // received, in kind (before P3: "paid 200.00"). + let lender = b.l.add_party("Harbor Bank Loans", &[]).unwrap(); + b.l.set_party_creditor(lender, b.note, true).unwrap(); + let e = b.post( + date(9, 15), + &[ + (b.equip, Dr, "1000.00"), + (b.note, Cr, "200.00"), + (b.cap_m, Cr, "800.00"), + ], + &[whole(m)], + ); + let (pt, pp) = b.resolved(e); + assert_eq!(pt.direction, Direction::Received); + assert_eq!(pp, vec![part(m, "-800.00", "0", "-800.00")]); + assert_eq!(b.totals(m, Basis::Cash), (Decimal::ZERO, Decimal::ZERO)); + assert_eq!(b.totals(m, Basis::All), (Decimal::ZERO, d("800.00"))); +} + +/// The known limit B-35 states (and the tool text says): when the +/// members aren't named the creditors of their financing account, a +/// distribution declared through it counts when declared and again when +/// paid. Pinned so a change to it is seen. +#[test] +fn a_distribution_owed_counts_twice_until_the_members_are_named_its_creditors() { + let mut b = books(); + let t = b.tobin; + b.post( + date(6, 30), + &[(b.cap_t, Dr, "200.00"), (b.due, Cr, "200.00")], + &[whole(t)], + ); + b.post( + date(7, 15), + &[(b.due, Dr, "200.00"), (b.cash, Cr, "200.00")], + &[whole(t)], + ); + assert_eq!(b.totals(t, Basis::Cash), (d("400.00"), Decimal::ZERO)); + b.l.set_party_creditor(t, b.due, true).unwrap(); + assert_eq!(b.totals(t, Basis::Cash), (d("200.00"), Decimal::ZERO)); +} + +/// Defect 24 in a member's entry (found in review, 2026-10-21): a +/// distribution declared to a liability that isn't money (distributions +/// payable) is a promise. It counted as 200.00 paid when declared and +/// again when paid (400.00); now it counts nothing until it is paid, then +/// 200.00, once, on both bases (Helvering v. Price; party_target +/// INVARIANT 2). The statement of members' equity counts it when declared: +/// party totals catch up when it is paid (B-35 item 4's conditions). +#[test] +fn defect24_a_distribution_declared_to_a_payable_counts_once_when_paid() { + let mut b = books(); + let t = b.tobin; + let declared = b.post( + date(6, 30), + &[(b.dist_t, Dr, "200.00"), (b.dist_payable, Cr, "200.00")], + &[whole(t)], + ); + let (dt, dp) = b.resolved(declared); + assert_eq!(dt.direction, Direction::Neither); + assert_eq!(dp, vec![part(t, "200.00", "0", "0")]); + for basis in Basis::ALL { + assert_eq!(b.totals(t, basis), (Decimal::ZERO, Decimal::ZERO)); + } + b.post( + date(7, 15), + &[(b.dist_payable, Dr, "200.00"), (b.cash, Cr, "200.00")], + &[whole(t)], + ); + for basis in Basis::ALL { + assert_eq!(b.totals(t, basis), (d("200.00"), Decimal::ZERO)); + } + // Declared and half paid at once: the money counts now, the rest when + // paid. + let half = b.post( + date(8, 31), + &[ + (b.dist_t, Dr, "300.00"), + (b.cash, Cr, "100.00"), + (b.dist_payable, Cr, "200.00"), + ], + &[whole(t)], + ); + assert_eq!( + b.resolved(half), + ( + Target { + amount: d("300.00"), + direction: Direction::Paid, + cash: d("100.00"), + in_kind: Decimal::ZERO, + counts_as: Direction::Paid, + }, + vec![part(t, "300.00", "100.00", "0")] + ) + ); + assert_eq!(b.totals(t, Basis::Cash), (d("300.00"), Decimal::ZERO)); +} + +/// B-35 item 3: a distribution's cash is the money it paid and its in +/// kind the property it gave. Equipment carried at 1,000.00 less 300.00 +/// depreciated, and 100.00 in cash, distributed: 100.00 cash and 700.00 +/// in kind (it read 800.00 in kind and no cash, the equipment's cost +/// standing in for the money). Basis all is the statement's 800.00. +#[test] +fn a_distribution_of_money_and_depreciated_property_splits_by_what_moved() { + let mut b = books(); + let t = b.tobin; + let e = b.post( + date(9, 1), + &[ + (b.dist_t, Dr, "800.00"), + (b.accum, Dr, "300.00"), + (b.equip, Cr, "1000.00"), + (b.cash, Cr, "100.00"), + ], + &[whole(t)], + ); + assert_eq!(b.resolved(e).1, vec![part(t, "800.00", "100.00", "700.00")]); + assert_eq!(b.totals(t, Basis::Cash), (d("100.00"), Decimal::ZERO)); + assert_eq!(b.totals(t, Basis::All), (d("800.00"), Decimal::ZERO)); + let (start, end) = year(); + let me = members_equity(&b.l, start, end, &b.capitals()).unwrap(); + assert_eq!(me.members[0].distributions, d("800.00")); + // At fair market value with a gain on it, and no money: all in kind. + let gain = b.post( + date(9, 2), + &[ + (b.dist_t, Dr, "900.00"), + (b.accum, Dr, "300.00"), + (b.equip, Cr, "1000.00"), + (b.revenue, Cr, "200.00"), + ], + &[whole(t)], + ); + assert_eq!(b.resolved(gain).1, vec![part(t, "900.00", "0", "900.00")]); +} + +/// Defect 24 and the card rule (B-35 item 5): a gift card the company +/// sold is deferred revenue, a promise, though its account's name says +/// "card". It counted as received when sold and again when redeemed +/// (200.00); now once, when the money came in. A card the company pays +/// with is still a card. +#[test] +fn defect24_a_gift_card_sold_is_received_once() { + let mut b = books(); + let c = b.customer; + assert_eq!(ledger_core::parties::card_accounts(&b.l), vec![b.card]); + b.post( + date(3, 1), + &[(b.cash, Dr, "100.00"), (b.gift, Cr, "100.00")], + &[whole(c)], + ); + let redeemed = b.post( + date(3, 5), + &[(b.gift, Dr, "100.00"), (b.revenue, Cr, "100.00")], + &[whole(c)], + ); + assert_eq!(b.resolved(redeemed).0.direction, Direction::Neither); + assert_eq!(b.totals(c, Basis::All), (Decimal::ZERO, d("100.00"))); +} + +/// B-35 item 3, case 3: E is the movement of equity other than retained +/// earnings, so a members' entry reads as the statement of members' equity +/// reads it. Retained earnings given up for equipment moves no member's +/// capital: neither, as the statement shows no distribution. Retained +/// earnings moved into the members' capital outside a close is, on the +/// statement and the K-1 worksheet, a contribution (credits to capital), +/// and party totals agree: received, cash (an open question for the +/// owner: a CPA would call it neither). +#[test] +fn retained_earnings_is_not_a_members_capital() { + let mut b = books(); + let (t, m) = (b.tobin, b.morgan); + let re = + b.l.add_account( + "3999", + "Retained Earnings", + AccountType::Equity, + None, + false, + ) + .unwrap(); + assert_eq!(b.l.role_account(&ledger_core::RETAINED_EARNINGS), Some(re)); + let written_off = b.post( + date(4, 1), + &[(re, Dr, "100.00"), (b.equip, Cr, "100.00")], + &[whole(t)], + ); + assert_eq!(b.resolved(written_off).0.direction, Direction::Neither); + let allocated = b.post( + date(4, 2), + &[ + (re, Dr, "300.00"), + (b.cap_t, Cr, "150.00"), + (b.cap_m, Cr, "150.00"), + ], + &[whole(t), whole(m)], + ); + assert_eq!( + b.resolved(allocated).1, + vec![ + part(t, "-150.00", "-150.00", "0"), + part(m, "-150.00", "-150.00", "0") + ] + ); + let (start, end) = year(); + let me = members_equity(&b.l, start, end, &b.capitals()).unwrap(); + let k1 = k1_detail(&b.l, start, end, &b.capitals(), &[b.cash]).unwrap(); + for (i, who) in [t, m].into_iter().enumerate() { + let (paid, received) = b.totals(who, Basis::All); + assert_eq!( + (received, paid), + (me.members[i].contributions, me.members[i].distributions) + ); + assert_eq!(b.totals(who, Basis::Cash).1, k1.rows[i].contributions_cash); + } +} + +/// Activity's paid is net of reversals of payments; a refund recorded as +/// its own entry (money back from the vendor) is received from the vendor, +/// as it is: Paid out 100.00, Received in 20.00, net 80.00. +#[test] +fn a_refund_is_received_from_the_vendor() { + let mut b = books(); + let v = b.vendor; + b.post( + date(3, 1), + &[(b.hosting, Dr, "100.00"), (b.cash, Cr, "100.00")], + &[whole(v)], + ); + b.post( + date(3, 5), + &[(b.cash, Dr, "20.00"), (b.hosting, Cr, "20.00")], + &[whole(v)], + ); + assert_eq!(b.totals(v, Basis::Cash), (d("100.00"), d("20.00"))); +} + +/// B-35 item 3 (capital first): a mixed contribution is split into money +/// and property exactly as the K-1 worksheet splits it (its SCENARIO 3), +/// whichever member the shares list first; an expense a member pays +/// straight to capital is cash, as on the K-1 (k1 INVARIANT 3); a debt +/// converted to capital is cash. +#[test] +fn a_members_mixed_entry_splits_as_the_k1_does() { + let mut b = books(); + let (t, m) = (b.tobin, b.morgan); + let lines = [ + (b.cash, Dr, "600.00"), + (b.equip, Dr, "400.00"), + (b.cap_t, Cr, "500.00"), + (b.cap_m, Cr, "500.00"), + ]; + let e = b.post(date(2, 1), &lines, &[whole(t), whole(m)]); + assert_eq!( + b.resolved(e).1, + vec![ + part(t, "-500.00", "-300.00", "-200.00"), + part(m, "-500.00", "-300.00", "-200.00") + ] + ); + // Listed the other way round: the same split. + b.l.set_entry_parties(e, &[whole(m), whole(t)]).unwrap(); + assert_eq!( + b.resolved(e).1, + vec![ + part(m, "-500.00", "-300.00", "-200.00"), + part(t, "-500.00", "-300.00", "-200.00") + ] + ); + // A tied odd cent goes to the member listed first in the company's + // members (Tobin), as on the K-1 worksheet, whatever the shares' order. + let odd = b.post( + date(2, 2), + &[ + (b.cash, Dr, "0.99"), + (b.equip, Dr, "0.01"), + (b.cap_t, Cr, "0.50"), + (b.cap_m, Cr, "0.50"), + ], + &[whole(m), whole(t)], + ); + assert_eq!( + b.resolved(odd).1, + vec![ + part(m, "-0.50", "-0.50", "0"), + part(t, "-0.50", "-0.49", "-0.01") + ] + ); + let expense = b.post( + date(2, 3), + &[(b.hosting, Dr, "50.00"), (b.cap_m, Cr, "50.00")], + &[whole(m)], + ); + assert_eq!( + b.resolved(expense).1, + vec![part(m, "-50.00", "-50.00", "0")] + ); + let converted = b.post( + date(2, 4), + &[(b.due, Dr, "70.00"), (b.cap_t, Cr, "70.00")], + &[whole(t)], + ); + assert_eq!( + b.resolved(converted).1, + vec![part(t, "-70.00", "-70.00", "0")] + ); + // Against the K-1 worksheet, member by member. + let (start, end) = year(); + let k1 = k1_detail(&b.l, start, end, &b.capitals(), &[b.cash]).unwrap(); + for (i, who) in [t, m].into_iter().enumerate() { + let (_, cash) = b.totals(who, Basis::Cash); + let (_, all) = b.totals(who, Basis::All); + assert_eq!(cash, k1.rows[i].contributions_cash, "member {i}"); + assert_eq!(all - cash, k1.rows[i].contributions_property, "member {i}"); + } + // Tobin: 300 + 0.49 + 70 cash, 200 + 0.01 property; Morgan: 300 + + // 0.50 + 50 cash, 200 property. + assert_eq!(b.totals(t, Basis::Cash), (Decimal::ZERO, d("370.49"))); + assert_eq!(b.totals(m, Basis::Cash), (Decimal::ZERO, d("350.50"))); + assert_eq!(b.totals(t, Basis::All), (Decimal::ZERO, d("570.50"))); +} + +/// R2 of the design review: a member's entry is read from journaled facts +/// (the member role, the chart), not today's settings: it keeps its sign, +/// direction and property after the member leaves the company's members +/// and is archived; and a one-member company's property contribution +/// reads received, in kind, with no members in the settings at all. +#[test] +fn a_members_entry_keeps_its_reading_after_they_leave() { + let mut b = books(); + let t = b.tobin; + let e = b.post( + date(3, 1), + &[(b.equip, Dr, "400.00"), (b.cap_t, Cr, "400.00")], + &[whole(t)], + ); + let before = b.resolved(e); + b.l.update_party(t, PartyField::Archived(true)).unwrap(); + let after = resolve(&b.l, e, b.l.entry_parties(e), &b.money(), &[]).unwrap(); + assert_eq!((after.target, after.parts), before); + assert_eq!(before.0.direction, Direction::Received); + // One member, no members in the settings. + let mut l = Ledger::new(Currency::USD); + let equip = l + .add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let cash = l + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let capital = l + .add_account("3000", "Owner's Capital", AccountType::Equity, None, false) + .unwrap(); + let owner = l.add_party("Morgan Ellis", &[Role::Member]).unwrap(); + let e = l.try_create_entry(date(3, 1), "laptop").unwrap(); + l.add_line(e, equip, Dr, Money::new(d("900.00"), Currency::USD)) + .unwrap(); + l.add_line(e, capital, Cr, Money::new(d("900.00"), Currency::USD)) + .unwrap(); + l.post_entry(e).unwrap(); + l.set_entry_party(e, Some(owner)).unwrap(); + let money = money_accounts(&l, &[cash], &[]); + let r = resolve(&l, e, l.entry_parties(e), &money, &[]).unwrap(); + assert_eq!(r.target.direction, Direction::Received); + assert_eq!(r.parts, vec![part(owner, "-900.00", "0", "-900.00")]); + assert_eq!( + activity_with(&l, owner, year(), &money, &[]).totals(Basis::All), + (Decimal::ZERO, d("900.00")) + ); +} + +/// B-35 item 3, case 1: a period's closing entry moves nothing with +/// anyone: direction neither, its members' part their income allocated +/// (credited to their capital: negative), counted in no total. +#[test] +fn a_close_tagged_with_its_members_is_neither() { + let mut b = books(); + let (t, m) = (b.tobin, b.morgan); + b.post( + date(1, 10), + &[(b.cash, Dr, "100.00"), (b.revenue, Cr, "100.00")], + &[whole(b.customer)], + ); + let jan = b.l.create_period("Jan", date(1, 1), date(1, 31)).unwrap(); + let close = + b.l.close_period_to( + jan, + &ledger_core::CloseTo::Members(vec![ + ledger_core::MemberClose { + capital: b.cap_t, + share: d("0.5"), + distributions: None, + }, + ledger_core::MemberClose { + capital: b.cap_m, + share: d("0.5"), + distributions: None, + }, + ]), + ) + .unwrap() + .unwrap(); + b.l.set_entry_parties(close, &[whole(t), whole(m)]).unwrap(); + let (ct, cp) = b.resolved(close); + assert_eq!(ct.direction, Direction::Neither); + assert_eq!(ct.counts_as, Direction::Neither); + assert_eq!( + cp, + vec![part(t, "-50.00", "0", "0"), part(m, "-50.00", "0", "0")] + ); + assert_eq!(b.totals(t, Basis::All), (Decimal::ZERO, Decimal::ZERO)); +} + +/// Basis::parse and key: "cash" and "all", nothing else (surrounding space +/// ignored); cash is the default. +#[test] +fn a_basis_is_cash_or_all() { + assert_eq!(Basis::parse("cash"), Some(Basis::Cash)); + assert_eq!(Basis::parse(" all "), Some(Basis::All)); + for bad in ["", "accrual", "Cash", "ALL", "both"] { + assert_eq!(Basis::parse(bad), None, "{bad:?}"); + } + assert_eq!(Basis::default(), Basis::Cash); + for b in Basis::ALL { + assert_eq!(Basis::parse(b.key()), Some(b)); + } +} + +/// Ledger::set_party_creditor SCENARIOS: only a liability that takes +/// postings and has one creditor; a refusal changes nothing; naming twice +/// or stopping one never named changes nothing; an account may have +/// several creditors (both members, for what the members advanced). +#[test] +fn a_creditor_is_named_only_on_a_liability_with_one_owner() { + let mut b = books(); + let (t, m, issuer) = (b.tobin, b.morgan, b.issuer); + let header = + b.l.add_account( + "2900", + "Other Liabilities", + AccountType::Liability, + None, + true, + ) + .unwrap(); + let payables = + b.l.add_account( + "2000", + "Accounts Payable", + AccountType::Liability, + None, + false, + ) + .unwrap(); + assert_eq!(b.l.role_account(&ledger_core::PAYABLES), Some(payables)); + let federal = + b.l.add_account( + "2110", + "Federal Withholding Payable", + AccountType::Liability, + None, + false, + ) + .unwrap(); + let before = b.l.clone(); + for (what, party, account) in [ + ("an asset", issuer, b.cash), + ("equity", issuer, b.cap_t), + ("an unknown party", 999, b.card), + ("an unknown account", issuer, 999), + ("a placeholder", issuer, header), + ("payables", issuer, payables), + ("a payroll withholding", issuer, federal), + ] { + assert!( + b.l.set_party_creditor(party, account, true).is_err(), + "{what}" + ); + assert!(b.l == before, "{what}: a refusal changed the ledger"); + } + b.l.set_party_creditor(issuer, b.card, true).unwrap(); + let once = b.l.clone(); + b.l.set_party_creditor(issuer, b.card, true).unwrap(); + assert!(b.l == once, "naming twice changes nothing"); + assert_eq!(b.l.creditors_of(b.card), vec![issuer]); + b.l.set_party_creditor(t, b.due, true).unwrap(); + b.l.set_party_creditor(m, b.due, true).unwrap(); + assert_eq!(b.l.creditors_of(b.due), vec![t, m]); + b.l.set_party_creditor(m, b.due, false).unwrap(); + let stopped = b.l.clone(); + b.l.set_party_creditor(m, b.due, false).unwrap(); + assert!(b.l == stopped, "stopping one never named changes nothing"); + assert_eq!(b.l.creditors_of(b.due), vec![t]); + assert!(b.l.get_party(t).unwrap().creditor_of.contains(&b.due)); +} + +/// Defect 11: Party::tin_last4 SCENARIOS 1-5. +#[test] +fn defect11_a_partys_tin_last4_is_its_tin_facts_else_its_w9s() { + let mut l = Ledger::new(Currency::USD); + let member = l.add_party("Tobin Harlow", &[Role::Member]).unwrap(); + let contractor = l.add_party("Riley Chen", &[Role::Contractor]).unwrap(); + let both = l.add_party("Morgan Ellis", &[Role::Contractor]).unwrap(); + let neither = l.add_party("Lakeside Supply", &[]).unwrap(); + let tin = |last4: &str| TinFacts { + tin_type: TinType::Ssn, + last4: last4.into(), + vault_record: Some("a".repeat(64)), + purged: None, + }; + let w9 = |last4: &str| W9Facts { + received: date(10, 15), + entity_type: "individual/sole proprietor".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: last4.into(), + kept_at: "wanderbooks vault".into(), + vault_record: None, + }; + l.set_party_tin(member, Some(tin("4321"))).unwrap(); + l.set_party_w9(contractor, Some(w9("8765"))).unwrap(); + l.set_party_tin(both, Some(tin("1111"))).unwrap(); + l.set_party_w9(both, Some(w9("1111"))).unwrap(); + let last4 = |l: &Ledger, p| l.get_party(p).unwrap().tin_last4().map(str::to_string); + assert_eq!(last4(&l, member).as_deref(), Some("4321")); + assert_eq!(last4(&l, contractor).as_deref(), Some("8765")); + assert_eq!(last4(&l, both).as_deref(), Some("1111")); + assert_eq!(last4(&l, neither), None); + // Purged from the vault: the facts keep the last four. + let mut purged = tin("4321"); + purged.vault_record = None; + purged.purged = Some("purged on 2026-10-20: retention ended".into()); + l.set_party_tin(member, Some(purged)).unwrap(); + assert_eq!(last4(&l, member).as_deref(), Some("4321")); + // SCENARIO 6: never more than four digits, however a party was built + // (here by hand, past the checking setters). + let mut by_hand = l.get_party(member).unwrap().clone(); + by_hand.tin.as_mut().unwrap().last4 = "123456789".into(); + assert_eq!(by_hand.tin_last4(), None); + let mut by_hand = l.get_party(both).unwrap().clone(); + by_hand.tin.as_mut().unwrap().last4 = "11111".into(); + assert_eq!(by_hand.tin_last4(), Some("1111"), "the W-9's four"); + by_hand.w9.as_mut().unwrap().tin_last4 = "12-3456789".into(); + assert_eq!(by_hand.tin_last4(), None); +} + +/// The kernel's B-35 cases, by hand (party_target INVARIANTS 1-6) and +/// reverse_target an involution. +#[test] +fn party_target_cases_by_hand() { + let t = |amount, direction, cash, in_kind| PartyTarget { + amount, + direction, + cash, + in_kind, + }; + // A contribution of 800, 1000 of property given (more than |e|): all + // in kind, whatever money the entry moved. + assert_eq!( + kernel::party_target(Some((-800i64, 1000)), 200, 0, 1000), + Some(t(-800, Direction::Received, 0, -800)) + ); + // A contribution: the property in it, the rest cash. + assert_eq!( + kernel::party_target(Some((-500i64, 200)), -500, 0, 500), + Some(t(-500, Direction::Received, -300, -200)) + ); + // A contribution with no money at all (an expense booked straight to + // capital): cash, as the K-1 worksheet counts it. + assert_eq!( + kernel::party_target(Some((-50i64, 0)), 0, 0, 50), + Some(t(-50, Direction::Received, -50, 0)) + ); + // A distribution of 150: 20 paid in money, 100 in property, 30 still + // owed. + assert_eq!( + kernel::party_target(Some((150i64, 100)), 20, 0, 150), + Some(t(150, Direction::Paid, 20, 100)) + ); + // Money first, then property, each at most what is left. + assert_eq!( + kernel::party_target(Some((800i64, 1000)), 100, 0, 1100), + Some(t(800, Direction::Paid, 100, 700)) + ); + assert_eq!( + kernel::party_target(Some((100i64, 0)), 150, 0, 150), + Some(t(100, Direction::Paid, 100, 0)) + ); + // A distribution all owed (declared, no money, no property): nothing + // counts, neither. Money received on it doesn't count either. + assert_eq!( + kernel::party_target(Some((200i64, 0)), 0, 200, 200), + Some(t(200, Direction::Neither, 0, 0)) + ); + assert_eq!( + kernel::party_target(Some((200i64, 0)), -50, 0, 250), + Some(t(200, Direction::Neither, 0, 0)) + ); + // Not every party a member, or no equity moved: money, then own, then + // the debits. + assert_eq!( + kernel::party_target(Some((0i64, 0)), -70, 0, 70), + Some(t(-70, Direction::Received, -70, 0)) + ); + assert_eq!( + kernel::party_target(None, 0i64, -80, 80), + Some(t(-80, Direction::Neither, 0, 0)) + ); + assert_eq!( + kernel::party_target(None, 0i64, 0, 80), + Some(t(80, Direction::Neither, 0, 0)) + ); + let x = t(-800i64, Direction::Received, -300, -500); + let r = kernel::reverse_target(x).unwrap(); + assert_eq!(r, t(800, Direction::Paid, 300, 500)); + assert_eq!(kernel::reverse_target(r), Some(x)); + // Past the range: refused, never a panic. + assert_eq!( + kernel::reverse_target(t(i64::MIN, Direction::Received, 0, 0)), + None + ); + assert_eq!(kernel::party_target(Some((i64::MIN, 0)), 0, 0, 0), None); + // Never a negative zero on Decimal: a contribution all in kind leaves + // a plain zero of cash (a distribution all in kind read "-0" before + // the review), and so does a reversal of zero. + let all_in_kind = kernel::party_target( + Some((d("-800.00"), d("1000.00"))), + d("0"), + d("0"), + d("1000.00"), + ) + .unwrap(); + assert_eq!(all_in_kind.cash, Decimal::ZERO); + assert!(!all_in_kind.cash.is_sign_negative()); + let given = kernel::party_target( + Some((d("800.00"), d("1000.00"))), + d("0"), + d("0"), + d("1000.00"), + ) + .unwrap(); + assert_eq!((given.cash, given.in_kind), (Decimal::ZERO, d("800.00"))); + assert!(!given.cash.is_sign_negative()); + let z = kernel::reverse_target(PartyTarget { + amount: Decimal::ZERO, + direction: Direction::Neither, + cash: Decimal::ZERO, + in_kind: Decimal::ZERO, + }) + .unwrap(); + assert!(!z.amount.is_sign_negative() && !z.cash.is_sign_negative()); +} + +// ---------------------------------------------------------- properties + +fn cases() -> u32 { + std::env::var("PROPTEST_CASES") + .ok() + .and_then(|n| n.parse().ok()) + .unwrap_or(256) +} + +/// Who an entry names, in the properties below. +#[derive(Clone, Copy, Debug)] +enum Who { + Vendor, + Issuer, + Tobin, + Members, + MembersByPercent, + VendorAndTobinByPercent, +} + +fn arb_who() -> impl Strategy { + prop_oneof![ + Just(Who::Vendor), + Just(Who::Issuer), + Just(Who::Tobin), + Just(Who::Members), + Just(Who::MembersByPercent), + Just(Who::VendorAndTobinByPercent), + ] +} + +fn shares_for(b: &Books, who: Who) -> Vec { + let pct = |ps: &[(PartyId, &str)]| -> Vec { + ps.iter() + .map(|&(party, p)| PartyShare { + party, + share: Share::Percent(d(p)), + }) + .collect() + }; + match who { + Who::Vendor => vec![whole(b.vendor)], + Who::Issuer => vec![whole(b.issuer)], + Who::Tobin => vec![whole(b.tobin)], + Who::Members => vec![whole(b.tobin), whole(b.morgan)], + Who::MembersByPercent => pct(&[(b.tobin, "60"), (b.morgan, "40")]), + Who::VendorAndTobinByPercent => pct(&[(b.vendor, "70"), (b.tobin, "30")]), + } +} + +/// A balanced entry over the fixture's accounts (by index into +/// `Books::pool`): one or two debits, one or two credits, the last credit +/// plugging the debits' total. +fn arb_lines() -> impl Strategy> { + ( + prop::collection::vec((0usize..POOL, 1i64..500_000), 1..3), + prop::collection::vec((0usize..POOL, 1i64..500_000), 0..2), + 0usize..POOL, + ) + .prop_filter_map("a positive plug", |(debits, credits, last)| { + let total: i64 = debits.iter().map(|(_, c)| c).sum(); + let rest: i64 = credits.iter().map(|(_, c)| c).sum(); + (total > rest).then(|| { + let mut out: Vec<(usize, Side, i64)> = + debits.into_iter().map(|(a, c)| (a, Dr, c)).collect(); + out.extend(credits.into_iter().map(|(a, c)| (a, Cr, c))); + out.push((last, Cr, total - rest)); + out + }) + }) +} + +const POOL: usize = 11; + +impl Books { + fn pool(&self) -> [AccountId; POOL] { + [ + self.cash, + self.card, + self.due, + self.note, + self.accrued, + self.equip, + self.prepaid, + self.cap_t, + self.cap_m, + self.hosting, + self.revenue, + ] + } + + fn post_cents(&mut self, day: NaiveDate, lines: &[(usize, Side, i64)]) -> EntryId { + let pool = self.pool(); + let e = self.l.try_create_entry(day, "x").unwrap(); + for &(a, side, c) in lines { + self.l + .add_line( + e, + pool[a], + side, + Money::new(Decimal::new(c, 2), Currency::USD), + ) + .unwrap(); + } + self.l.post_entry(e).unwrap(); + e + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(cases()))] + + /// Properties 1, 2 and 6 of the brief (shares INVARIANTS 6, 11, 12; + /// parties::flow INVARIANT 2): for any posted entry and any parties, + /// with any of them named its creditors, the parts sum to the target + /// (and their cash and in_kind to its), each has the target's sign and + /// never exceeds its part; a reversal's target and parts are its + /// original's negated, the direction flipped, on the same side; and + /// its flow is the original's negated. + #[test] + fn a_reversal_is_the_exact_opposite( + lines in arb_lines(), + who in arb_who(), + owed in prop::collection::vec((0usize..3, 0usize..4), 0..3), + ) { + let mut b = books(); + let liabilities = [b.card, b.due, b.note, b.accrued]; + for (p, a) in owed { + let party = [b.issuer, b.tobin, b.vendor][p]; + b.l.set_party_creditor(party, liabilities[a], true).unwrap(); + } + let e = b.post_cents(date(4, 1), &lines); + let shares = shares_for(&b, who); + b.l.set_entry_parties(e, &shares).unwrap(); + let r = b.reverse(e, date(4, 2)); + let (money, members) = (b.money(), b.members()); + let orig = resolve(&b.l, e, &shares, &money, &members); + let rev = resolve(&b.l, r, &shares, &money, &members); + prop_assert_eq!(orig.is_ok(), rev.is_ok()); + if let (Ok(o), Ok(v)) = (orig, rev) { + let neg = |x: Decimal| if x.is_zero() { Decimal::ZERO } else { -x }; + prop_assert_eq!(v.target.amount, neg(o.target.amount)); + prop_assert_eq!(v.target.cash, neg(o.target.cash)); + prop_assert_eq!(v.target.in_kind, neg(o.target.in_kind)); + prop_assert_eq!(v.target.direction, o.target.direction.flip()); + prop_assert_eq!(v.target.counts_as, o.target.counts_as); + prop_assert_eq!(o.target.counts_as, o.target.direction); + for (po, pv) in o.parts.iter().zip(&v.parts) { + prop_assert_eq!(pv.party, po.party); + prop_assert_eq!(pv.amount, neg(po.amount)); + prop_assert_eq!(pv.cash, neg(po.cash)); + prop_assert_eq!(pv.in_kind, neg(po.in_kind)); + } + for res in [&o, &v] { + let t = res.target; + for x in [t.amount, t.cash, t.in_kind] { + prop_assert!(!(x.is_zero() && x.is_sign_negative()), "the target: {:?}", t); + } + let sum = |f: fn(&Part) -> Decimal| res.parts.iter().map(f).sum::(); + prop_assert_eq!(sum(|p| p.amount), t.amount); + prop_assert_eq!(sum(|p| p.cash), t.cash); + prop_assert_eq!(sum(|p| p.in_kind), t.in_kind); + prop_assert_eq!( + t.direction == Direction::Neither, + t.cash.is_zero() && t.in_kind.is_zero() + ); + for p in &res.parts { + for x in [p.amount, p.cash, p.in_kind] { + prop_assert!(x.is_zero() || (x < Decimal::ZERO) == (t.amount < Decimal::ZERO)); + prop_assert!(!(x.is_zero() && x.is_sign_negative())); + } + prop_assert!(p.cash.abs() + p.in_kind.abs() <= p.amount.abs()); + } + } + } + let fo = flow(&b.l, e, &shares, &money).unwrap(); + let fr = flow(&b.l, r, &shares, &money).unwrap(); + prop_assert_eq!(fr.net, -fo.net); + prop_assert_eq!( + fr.what_for, + fo.what_for.iter().map(|(a, x)| (*a, -*x)).collect::>() + ); + } + + /// Property 3 of the brief: for an entry naming one party who isn't a + /// member, whose lines move money only through cash, cards and the + /// financing accounts, the party's part is entry_flow's net exactly, as + /// before P3: no money figure changed. + #[test] + fn a_sole_partys_money_part_is_the_flow_as_before(lines in arb_lines()) { + let mut b = books(); + let e = b.post_cents(date(4, 1), &lines); + b.l.set_entry_party(e, Some(b.vendor)).unwrap(); + let money = b.money(); + let net = entry_flow(b.l.get_entry(e).unwrap(), &money).unwrap().net; + let (t, parts) = b.resolved(e); + if !net.is_zero() { + prop_assert_eq!(t.amount, net); + prop_assert_eq!(parts[0].cash, net); + prop_assert_eq!(t.direction, if net > Decimal::ZERO { Direction::Paid } else { Direction::Received }); + } else { + prop_assert_eq!(t.direction, Direction::Neither); + } + } + + /// Property 5 of the brief (metamorphic, member-only books): members + /// contribute and take out money and property, alone and jointly, some + /// of it subject to a loan, some through the members' financing + /// account converted to capital, some through their distributions + /// accounts, some declared to distributions payable and paid later, + /// and reverse some of it later in the year. For each member, over + /// every span from the year's start to any date (B-35 item 4's + /// conditions hold: a reversal in such a span always has its original + /// in it): basis all is the statement of members' equity's + /// contributions and distributions, but for a distribution declared + /// and not yet paid, which party totals count when it is paid; and + /// basis cash's received is the K-1 worksheet's cash contributions. + /// Over any span at all, received less paid on basis all is the + /// statement's contributions less distributions (the same exception). + #[test] + fn member_totals_are_the_members_equity_statement( + ops in prop::collection::vec( + (0u8..11, 0usize..2, 1i64..200_000, 1i64..200_000, any::(), 1u32..30), + 1..12, + ), + ) { + let mut b = books(); + let caps = [b.cap_t, b.cap_m]; + let dists = [b.dist_t, b.dist_m]; + let who = [b.tobin, b.morgan]; + // (member, declared on, paid on, amount): distributions declared + // to distributions payable. + let mut declared: Vec<(usize, NaiveDate, NaiveDate, Decimal)> = Vec::new(); + let mut dates = std::collections::BTreeSet::new(); + for (i, &(op, mem, x, y, undo, later)) in ops.iter().enumerate() { + let day = date(1 + (i as u32 % 11), 1 + (i as u32 % 27)); + let undo_on = day + chrono::Days::new(u64::from(later)); + let (cap, dist, p) = (caps[mem], dists[mem], who[mem]); + let c = |v: i64| Decimal::new(v, 2).to_string(); + let (sx, sy, sxy, s2y) = (c(x), c(y), c(x + y), c(2 * y)); + let lines: Vec<(AccountId, Side, String)> = match op { + // Cash in. + 0 => vec![(b.cash, Dr, sx.clone()), (cap, Cr, sx.clone())], + // Property in. + 1 => vec![(b.equip, Dr, sx.clone()), (cap, Cr, sx.clone())], + // Cash and property in, one entry. + 2 => vec![(b.cash, Dr, sx.clone()), (b.equip, Dr, sy.clone()), (cap, Cr, sxy.clone())], + // Property subject to a loan: x + y of it, y the loan. + 3 => vec![(b.equip, Dr, sxy.clone()), (b.note, Cr, sy.clone()), (cap, Cr, sx.clone())], + // Cash out. + 4 => vec![(cap, Dr, sx.clone()), (b.cash, Cr, sx.clone())], + // Property out. + 5 => vec![(cap, Dr, sx.clone()), (b.equip, Cr, sx.clone())], + // What the member advanced (tagged with the vendor), then + // converted to capital. + 6 => { + b.post(day, &[(b.hosting, Dr, &sx), (b.due, Cr, &sx)], &[whole(b.vendor)]); + vec![(b.due, Dr, sx.clone()), (cap, Cr, sx.clone())] + } + // A joint contribution of cash and property. + 7 => vec![ + (b.cash, Dr, sx.clone()), + (b.prepaid, Dr, sy.clone()), + (b.cap_t, Cr, sx.clone()), + (b.cap_m, Cr, sy.clone()), + ], + // Money and depreciated property out, through the + // distributions account: x of cash, and equipment of cost + // 2y with y depreciated (y carried). + 8 => vec![ + (dist, Dr, sxy.clone()), + (b.accum, Dr, sy.clone()), + (b.equip, Cr, s2y.clone()), + (b.cash, Cr, sx.clone()), + ], + // Declared to distributions payable, paid at the year's + // end. + 9 => { + let paid_on = date(12, 31); + declared.push((mem, day, paid_on, Decimal::new(x, 2))); + let pay = b.post(paid_on, &[(b.dist_payable, Dr, &sx), (b.cash, Cr, &sx)], &[whole(p)]); + dates.insert(b.l.get_entry(pay).unwrap().date()); + vec![(dist, Dr, sx.clone()), (b.dist_payable, Cr, sx.clone())] + } + // An expense paid straight to capital. + _ => vec![(b.hosting, Dr, sx.clone()), (cap, Cr, sx.clone())], + }; + let shares = if op == 7 { vec![whole(b.tobin), whole(b.morgan)] } else { vec![whole(p)] }; + let lines: Vec<(AccountId, Side, &str)> = + lines.iter().map(|(a, s, v)| (*a, *s, v.as_str())).collect(); + let e = b.post(day, &lines, &shares); + dates.insert(day); + if undo && op != 9 { + b.reverse(e, undo_on); + dates.insert(undo_on); + } + } + let (start, end) = year(); + dates.insert(end); + let check = |span: (NaiveDate, NaiveDate), prefix: bool| -> Result<(), TestCaseError> { + let me = members_equity(&b.l, span.0, span.1, &b.capitals()).unwrap(); + let k1 = k1_detail(&b.l, span.0, span.1, &b.capitals(), &[b.cash]).unwrap(); + let within = |d: NaiveDate| d >= span.0 && d <= span.1; + for (i, p) in [b.tobin, b.morgan].into_iter().enumerate() { + let (paid_all, received_all) = activity_with(&b.l, p, span, &b.money(), &b.members()).totals(Basis::All); + let (_, received_cash) = activity_with(&b.l, p, span, &b.money(), &b.members()).totals(Basis::Cash); + // Declared in the span: in the statement, not yet in party + // totals; paid in the span: in party totals only. + let owed: Decimal = declared.iter().filter(|x| x.0 == i && within(x.1)).map(|x| x.3).sum(); + let paid: Decimal = declared.iter().filter(|x| x.0 == i && within(x.2)).map(|x| x.3).sum(); + let col = &me.members[i]; + prop_assert_eq!( + received_all - paid_all, + col.contributions - col.distributions + owed - paid, + "member {} over {:?}", i, span + ); + if prefix { + prop_assert_eq!(received_all, col.contributions, "member {} through {}", i, span.1); + prop_assert_eq!(paid_all, col.distributions - owed + paid, "member {} through {}", i, span.1); + prop_assert_eq!(received_cash, k1.rows[i].contributions_cash, "member {} through {}", i, span.1); + } + } + Ok(()) + }; + for &through in &dates { + check((start, through), true)?; + } + let ds: Vec = dates.iter().copied().collect(); + for (n, &from) in ds.iter().enumerate() { + for &to in &ds[n..] { + check((from, to), false)?; + } + } + } + + /// Defect 11 property: for a party built by any sequence of TIN and + /// W-9 facts the ledger accepts, tin_last4 is every source present. + #[test] + fn tin_last4_is_every_source_present( + steps in prop::collection::vec((any::(), 0u16..3, any::()), 0..8), + ) { + let mut l = Ledger::new(Currency::USD); + let p = l.add_party("Riley Chen", &[Role::Contractor]).unwrap(); + let fours = ["1234", "5678", "9012"]; + for (tin, which, clear) in steps { + let last4 = fours[usize::from(which)].to_string(); + let _ = if tin { + l.set_party_tin(p, (!clear).then_some(TinFacts { + tin_type: TinType::Ssn, + last4, + vault_record: None, + purged: None, + })) + } else { + l.set_party_w9(p, (!clear).then_some(W9Facts { + received: date(10, 15), + entity_type: "individual/sole proprietor".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: last4, + kept_at: "CPA portal".into(), + vault_record: None, + })) + }; + let party = l.get_party(p).unwrap(); + let sources: Vec<&str> = party + .tin + .iter() + .map(|t| t.last4.as_str()) + .chain(party.w9.iter().map(|w| w.tin_last4.as_str())) + .collect(); + match party.tin_last4() { + None => prop_assert!(sources.is_empty()), + Some(l4) => prop_assert!(!sources.is_empty() && sources.iter().all(|s| *s == l4)), + } + } + } +} + +/// shares::target refuses an unknown entry; target on a draft works (a +/// preview). +#[test] +fn a_target_needs_an_entry() { + let b = books(); + assert_eq!( + target(&b.l, 999, &[], &b.money()), + Err(LedgerError::UnknownEntry(999)) + ); +} diff --git a/crates/ledger-core/tests/spec_replayed_extremes.rs b/crates/ledger-core/tests/spec_replayed_extremes.rs index 7e2440b..44b14ff 100644 --- a/crates/ledger-core/tests/spec_replayed_extremes.rs +++ b/crates/ledger-core/tests/spec_replayed_extremes.rs @@ -199,7 +199,16 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() { let q = l .add_party("Beta", &[ledger_core::Role::Contractor]) .unwrap(); - let money = ledger_core::parties::money_accounts(&l, &[cash]); + let capital = l + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let member = l + .add_party("Tobin Harlow", &[ledger_core::Role::Member]) + .unwrap(); + let money = ledger_core::parties::money_accounts(&l, &[cash], &[]); + let resolve = |l: &Ledger, e, shares: &[PartyShare]| { + ledger_core::shares::resolve(l, e, shares, &money, &[]) + }; for (dr, cr) in [(rent, cash), (rent, card), (card, cash), (cash, card)] { let e = l.create_entry(d(2026, 3, 1), "huge"); for side in [Side::Debit, Side::Debit] { @@ -211,12 +220,31 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() { .unwrap(); let entry = l.get_entry(e).unwrap(); assert!(too_large(ledger_core::parties::entry_flow(entry, &money))); - assert!(too_large(ledger_core::shares::share_target(entry, &money))); - let why = - ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap_err(); + assert!(too_large(ledger_core::shares::target( + &l, + e, + &[PartyShare::whole(p)], + &money + ))); + let why = resolve(&l, e, &[PartyShare::whole(p)]).unwrap_err(); assert!(why.contains("too large to compute"), "{why}"); assert_eq!(l.post_entry(e).unwrap_err(), LedgerError::AmountOutOfRange); } + // A member's capital moved past exact arithmetic (rules.md B-35, case + // 3, B-28): the target is refused, never a panic. + let e = l.create_entry(d(2026, 3, 1), "huge capital"); + for _ in 0..2 { + l.replay_add_line(e, cash, Side::Debit, usd(Decimal::MAX)) + .unwrap(); + l.replay_add_line(e, capital, Side::Credit, usd(Decimal::MAX)) + .unwrap(); + } + assert!(too_large(ledger_core::shares::target( + &l, + e, + &[PartyShare::whole(member)], + &money + ))); // One line of Decimal's largest flows, and its share resolves whole. let e = l.create_entry(d(2026, 3, 1), "one huge"); l.replay_add_line(e, rent, Side::Debit, usd(Decimal::MAX)) @@ -228,16 +256,17 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() { ledger_core::parties::entry_flow(entry, &money).unwrap().net, Decimal::MAX ); + let r = resolve(&l, e, &[PartyShare::whole(p)]).unwrap(); assert_eq!( - ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap(), - [(p, Decimal::MAX)] + (r.parts[0].party, r.parts[0].amount, r.parts[0].cash), + (p, Decimal::MAX, Decimal::MAX) ); // A percent of it: the product is past exact arithmetic. let halves = [p, q].map(|party| PartyShare { party, share: ledger_core::shares::Share::Percent(Decimal::from(50)), }); - let why = ledger_core::shares::resolve(entry, &halves, &money, &[]).unwrap_err(); + let why = resolve(&l, e, &halves).unwrap_err(); assert!(why.contains("too large to compute"), "{why}"); } diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 0f5a016..a2cb487 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -67,13 +67,16 @@ pub struct EntryInfo { /// One party an entry names, with its share. /// /// ```text -/// SOURCE: ledger_core::shares (resolve), with Books::money_cash_accounts -/// and Books::members, as the server's books.get_entry resolves -/// them. +/// SOURCE: ledger_core::shares (resolve), with Books::money and +/// Books::members, as the server's books.get_entry resolves them. /// INVARIANTS: -/// 1. resolved: the party's part from ledger_core::shares::resolve, None -/// when the entry's shares don't resolve; over an entry's parties the -/// resolved parts sum to what it moved exactly. +/// 1. resolved: the party's part from ledger_core::shares::resolve +/// (signed as a line on its own account: positive paid to it, +/// negative received from it), None when the entry's shares don't +/// resolve; over an entry's parties the resolved parts sum to the +/// target exactly. +/// 2. direction, cash, in_kind: the target's direction and the part's +/// money and property, None exactly when resolved is. /// ``` #[derive(Clone, Debug, PartialEq)] pub struct EntryParty { @@ -81,6 +84,9 @@ pub struct EntryParty { pub label: String, pub share: ledger_core::Share, pub resolved: Option, + pub direction: Option, + pub cash: Option, + pub in_kind: Option, } /// One fixed asset's trail through the journal and the entries. @@ -133,13 +139,14 @@ impl AssetTrail { /// year 1099s are counted in, and books.party's year: a posted entry, /// invoice, or bill dated later this year counts). /// Money: Books::activity (ledger_core::parties::activity_with with -/// Books::money_cash_accounts and Books::members: a party's share of -/// an entry naming several; the same numbers books.party reports). +/// Books::money and Books::members: a party's share of an entry naming +/// several; the same numbers books.party reports). /// INVARIANTS: /// 1. entries: every entry that is the party's, drafts included, in /// Books::entries order (newest first). -/// 2. paid_ytd = activity's paid − received over the year, exactly. -/// Positive: paid to them, net; negative: received from them, net. +/// 2. paid_ytd = activity's paid − received over the year, exactly, on +/// basis cash (books.party's default). Positive: paid to them, net; +/// negative: received from them, net. /// 3. invoiced_ytd = Σ Invoice::total() over its customer record's /// invoices dated in the year; billed_ytd the same for its vendor /// record's bills. @@ -242,6 +249,11 @@ pub struct Books { pub members: Vec, /// `[books] financing_accounts`, resolved (empty when not set). pub financing_accounts: Vec, + /// The money accounts every party number reads + /// (ledger_core::parties::money_accounts over money_cash_accounts and + /// financing_accounts, with the cards), exactly as the server's + /// books.party and books.get_entry read them. + pub money: ledger_core::parties::MoneyAccounts, /// The cash accounts every number the server also reports reads /// money with: `[books] cash_accounts`, resolved; when that is empty, /// core's fallback (ledger_core::parties::money_accounts: every asset @@ -301,26 +313,26 @@ pub enum PartyPeriod { /// ```text /// GOAL: answer "how much did we spend with them, and how much did we /// get back?" (owner, 2026-10-15) with the books' own numbers. -/// SOURCE: ledger_core::parties::activity_with (paid, received) and -/// ledger_core::parties::entry_flow (what for), with -/// Books::money_cash_accounts and Books::members, as PartyInfo -/// uses them. +/// SOURCE: ledger_core::parties::activity_with (paid, received, in kind) +/// and ledger_core::parties::flow (what for), with Books::money +/// and Books::members, as PartyInfo uses them; rules.md B-35. /// INVARIANTS: -/// 1. paid, received: Books::activity(party, span)'s, exactly. +/// 1. paid, received: Books::activity(party, span)'s, exactly: basis +/// cash (money), books.party's default. paid_in_kind and +/// received_in_kind: its property given and taken in kind, so basis +/// all is paid + paid_in_kind and received + received_in_kind. /// 2. net() = paid − received. /// 3. what_for: over the party's posted entries dated in the span, the -/// sum by account of each entry_flow's what_for (a liability paid -/// down, like a reimbursement, appears as what the money paid for), -/// for an entry naming several parties this party's part of it: a -/// member's own capital and distributions lines when they come to -/// its resolved share exactly, else the what_for apportioned to the -/// share (share_of_what_for; nothing when the shares don't resolve), -/// the accounts whose sum is zero left out, largest magnitude first -/// (then by account id). Positive: what money paid for; negative: -/// where money received came from (or, on payables and -/// receivables, what is still owed). -/// 4. Σ what_for = net(), exactly: each entry_flow's what_for sums to -/// its net (a reversal reverses both). +/// sum by account of each entry's breakdown for the party +/// (Books::party_what_for: parties::flow's what_for, its share of it +/// for an entry naming several, made to sum to the party's cash part +/// of the entry), the accounts whose sum is zero left out, largest +/// magnitude first (then by account id). Positive: what money paid +/// for; negative: where money received came from (or, on payables +/// and receivables, what is still owed). +/// 4. Σ what_for = net(), exactly: each entry's breakdown sums to the +/// party's cash part of it, and activity sums those parts (a +/// reversal's are its original's negated). /// 5. entries: how many of the party's entries are posted and dated /// in the span. /// ``` @@ -328,6 +340,8 @@ pub enum PartyPeriod { pub struct PartyFlow { pub paid: Decimal, pub received: Decimal, + pub paid_in_kind: Decimal, + pub received_in_kind: Decimal, pub what_for: Vec<(AccountId, Decimal)>, pub entries: usize, } @@ -571,17 +585,22 @@ impl Books { let money_cash_accounts = { let configured = codes(&settings.cash_accounts); if configured.is_empty() { - let mut all: Vec = ledger_core::parties::money_accounts(&ledger, &[]) - .cash - .into_iter() - .collect(); + let mut all: Vec = + ledger_core::parties::money_accounts(&ledger, &[], &[]) + .cash + .into_iter() + .collect(); all.sort_unstable(); all } else { configured } }; - let share_money = ledger_core::parties::money_accounts(&ledger, &money_cash_accounts); + let share_money = ledger_core::parties::money_accounts( + &ledger, + &money_cash_accounts, + &financing_accounts, + ); let investing_accounts = codes(&settings.investing_accounts); let card_accounts = ledger_store::reconcilable::card_accounts(&ledger); @@ -675,7 +694,8 @@ impl Books { // vendor tags, bills, invoices, their payments, reversals). let party_id = ledger.entry_party(id); let shares = ledger.entry_parties(id); - let resolved = ledger_core::shares::resolve(e, shares, &share_money, &members).ok(); + let resolved = + ledger_core::shares::resolve(&ledger, id, shares, &share_money, &members).ok(); let parties: Vec = shares .iter() .enumerate() @@ -686,7 +706,10 @@ impl Books { .map(|p| p.label().to_string()) .unwrap_or_else(|| format!("party {}", s.party)), share: s.share, - resolved: resolved.as_ref().map(|r| r[i].1), + resolved: resolved.as_ref().map(|r| r.parts[i].amount), + direction: resolved.as_ref().map(|r| r.target.direction), + cash: resolved.as_ref().map(|r| r.parts[i].cash), + in_kind: resolved.as_ref().map(|r| r.parts[i].in_kind), }) .collect(); let party = if parties.is_empty() { @@ -766,6 +789,7 @@ impl Books { cash_accounts, members, financing_accounts, + money: share_money, money_cash_accounts, investing_accounts, card_accounts, @@ -1505,17 +1529,14 @@ impl Books { .collect(); let mut what_for = Vec::new(); if with_breakdown { - let money = ledger_core::parties::money_accounts(l, &self.cash_accounts); let mut by: BTreeMap = BTreeMap::new(); for e in &posted { - // A posted entry's flow computes (its lines are in a line's - // range); one that doesn't, activity counts as unresolved - // and counts nothing, so neither does what it was for. - let Ok(flow) = ledger_core::parties::entry_flow(e, &money) else { + // An entry activity counts as unresolved counts nothing, so + // neither does what it was for. + if a.unresolved.contains(&e.id()) { continue; - }; - let parts = self.party_what_for(e, party, &flow); - for (account, amount) in parts { + } + for (account, amount) in self.party_what_for(e, party) { *by.entry(account).or_default() += amount; } } @@ -1525,26 +1546,22 @@ impl Books { PartyFlow { paid: a.paid, received: a.received, + paid_in_kind: a.paid_in_kind, + received_in_kind: a.received_in_kind, what_for, entries: posted.len(), } } - /// ledger_core::parties::activity_with over `span`, with - /// Books::money_cash_accounts and the members: the one source of a - /// party's money, the server's own (books.party, books.parties). + /// ledger_core::parties::activity_with over `span`, with Books::money + /// and the members: the one source of a party's money, the server's own + /// (books.party, books.parties). pub fn activity( &self, party: PartyId, span: (NaiveDate, NaiveDate), ) -> ledger_core::parties::Activity { - ledger_core::parties::activity_with( - &self.ledger, - party, - span, - &self.money_cash_accounts, - &self.members, - ) + ledger_core::parties::activity_with(&self.ledger, party, span, &self.money, &self.members) } /// The books dir: the folder holding `books-data/` (and `vault/`). @@ -1629,51 +1646,86 @@ impl Books { } /// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT - /// 3): its fraction of the entry's entry_flow what_for, summing to its - /// money share exactly (zero when the entry moves no money, like a - /// bill or a contribution of property). + /// 3): its share of the entry's money breakdown (ledger_core::parties:: + /// flow's what_for: a reversal's is its original's negated), summing + /// to its cash part of the entry exactly (nothing counted when the + /// entry's shares don't resolve). /// /// ```text - /// The fraction: 1 for an entry with one party; else its resolved - /// share over what the shares split (the net, or the entry's total - /// debits when the net is zero). - /// A member's own capital and distribution lines are theirs in full; - /// another member's are never theirs. Every other line is scaled by - /// the fraction (to the cent, toward zero), and the first scaled line - /// takes the leftover so the parts sum to the money share. + /// The fraction: the party's resolved part over the target (the sum of + /// every party's part): 1 for an entry with one party. + /// A member's own capital and distribution lines are theirs in full + /// when the entry names several; another member's are never theirs. + /// Every other line is scaled by the fraction (to the cent, toward + /// zero), and the first scaled line takes what is left over so the + /// breakdown sums to the party's cash part: a bill (no cash part) + /// sums to zero, and a member's entry that counts only what moved + /// their capital (a reimbursement with a distribution) leaves out the + /// money that wasn't theirs. + /// What moved in kind isn't what money paid for: when the party's part + /// has property in it (rules.md B-35), the property lines (asset + /// accounts neither cash nor receivables, the K-1 worksheet's test) + /// stay out, so a property contribution, and its reversal, show + /// nothing, and a distribution of money and equipment shows the + /// money alone. /// ``` pub fn party_what_for( &self, e: &ledger_core::JournalEntry, party: PartyId, - flow: &ledger_core::parties::EntryFlow, ) -> Vec<(AccountId, Decimal)> { use rust_decimal::RoundingStrategy::ToZero; - let parties = self.ledger.entry_parties(e.id()).len(); - if parties <= 1 { - return flow.what_for.clone(); - } - let resolved = self.party_share(e, party); - // Posted lines are in a line's range, so these sums and products - // compute; one past exact arithmetic counts nothing, as an entry - // whose shares don't resolve. - let target = if flow.net.is_zero() { - match ledger_config::ReceiptPolicy::entry_debits(e) { - Ok(t) => t, - Err(_) => return Vec::new(), - } - } else { - flow.net - }; - let Some(fraction) = resolved.checked_div(target) else { + let Some(info) = self + .entry_index + .get(&e.id()) + .and_then(|&i| self.entries.get(i)) + else { return Vec::new(); }; - let money_share = if flow.net.is_zero() { - Decimal::ZERO + let Some(me) = info.parties.iter().find(|p| p.id == party) else { + return Vec::new(); + }; + let (Some(resolved), Some(cash)) = (me.resolved, me.cash) else { + return Vec::new(); + }; + let in_kind = me.in_kind.is_some_and(|k| !k.is_zero()); + let receivables = self.ledger.role_account(&ledger_core::RECEIVABLES); + let property = |a: AccountId| { + !self.money.cash.contains(&a) + && Some(a) != receivables + && self + .ledger + .chart() + .get(a) + .is_some_and(|x| x.account_type == AccountType::Asset) + }; + let shares = self.ledger.entry_parties(e.id()); + // A posted entry's flow computes (its lines are in a line's range); + // one past exact arithmetic counts nothing. + let Ok(flow) = ledger_core::parties::flow(&self.ledger, e.id(), shares, &self.money) else { + return Vec::new(); + }; + let Some(target) = info + .parties + .iter() + .try_fold(Decimal::ZERO, |t, p| t.checked_add(p.resolved?)) + else { + return Vec::new(); + }; + let fraction = if info.parties.len() <= 1 { + Decimal::ONE } else { - resolved + match resolved.checked_div(target) { + Some(f) => f, + None => return Vec::new(), + } + }; + let several = info.parties.len() > 1; + let me = if several { + self.member(party).copied() + } else { + None }; - let me = self.member(party).copied(); let member_line = |a: AccountId| { self.members .iter() @@ -1682,9 +1734,12 @@ impl Books { let mut own: Vec<(AccountId, Decimal)> = Vec::new(); let mut scaled: Vec<(AccountId, Decimal)> = Vec::new(); for &(a, v) in &flow.what_for { + if in_kind && property(a) { + continue; + } if me.is_some_and(|m| m.capital == a || m.distributions == Some(a)) { own.push((a, v)); - } else if !member_line(a) { + } else if !several || !member_line(a) { let Some(part) = v.checked_mul(fraction) else { return Vec::new(); }; @@ -1695,12 +1750,12 @@ impl Books { .iter() .chain(&scaled) .try_fold(Decimal::ZERO, |t, (_, v)| t.checked_add(*v)) - .and_then(|have| money_share.checked_sub(have)) + .and_then(|have| cash.checked_sub(have)) else { return Vec::new(); }; if !left.is_zero() { - match scaled.first_mut() { + match scaled.first_mut().or(own.first_mut()) { Some(first) => first.1 += left, None => own.push((e.lines()[0].account, left)), } @@ -1711,17 +1766,6 @@ impl Books { .collect() } - /// A party's resolved share of an entry's flow (its entry_flow net - /// for a sole party; zero when the shares don't resolve or it isn't - /// named). - pub fn party_share(&self, e: &ledger_core::JournalEntry, party: PartyId) -> Decimal { - self.entries - .get(self.entry_index.get(&e.id()).copied().unwrap_or(usize::MAX)) - .and_then(|info| info.parties.iter().find(|p| p.id == party)) - .and_then(|p| p.resolved) - .unwrap_or_default() - } - /// A member's capital statement over `span` (see /// [`CapitalStatement`]); None when the party isn't a member. pub fn capital_statement( diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index ef0ad00..c7d0aca 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -94,8 +94,9 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { }; ui.add_space(4.0); let mut text = format!("{} {}", super::parties::party_icon(party), party.label()); - if several { - text.push_str(&format!(" · {}", share_text(share))); + let what = share_text(share, several, e.reversal_of); + if !what.is_empty() { + text.push_str(&format!(" · {what}")); } if widgets::link(ui, RichText::new(text).color(p.accent)) .on_hover_text(format!( @@ -511,23 +512,71 @@ pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, wid } } -/// One party's share of an entry, in words: "$50.00 (derived)", "$70.00", -/// "$33.34 (33.333%)", or the share alone when it doesn't resolve. -pub fn share_text(s: &crate::books::EntryParty) -> String { - let how = match s.share { - ledger_core::Share::Amount(_) => None, - ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())), - ledger_core::Share::Derived => Some("derived".to_string()), +/// A party's part of an entry in words (rules.md B-35): which way it +/// went and how much ("paid $64.00", "received $740.00, in kind"), what of +/// it is still owed, the share it came from when the entry names several +/// ("(50%)", "(derived)"), and a reversal said as one ("paid $1,480.00 +/// (reversal of #12)"), so a property contribution and its reversal never +/// read alike. A part that moved nothing with the party (a bill, a close) +/// says so. Empty for a sole party whose part doesn't resolve. +pub fn share_text( + s: &crate::books::EntryParty, + several: bool, + reversal_of: Option, +) -> String { + use ledger_core::shares::Direction; + let how = if several { + match s.share { + ledger_core::Share::Amount(_) => None, + ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())), + ledger_core::Share::Derived => Some("derived".to_string()), + } + } else { + None }; - match (s.resolved, how) { - (Some(v), Some(h)) => format!("{} ({h})", fmt::dollars(v.abs())), - (Some(v), None) => fmt::dollars(v.abs()), - (None, Some(h)) => h, - (None, None) => match s.share { + let mut t = match (s.resolved, s.direction) { + (Some(v), Some(d)) => { + let amount = fmt::dollars(v.abs()); + let mut t = match d { + Direction::Paid => format!("paid {amount}"), + Direction::Received => format!("received {amount}"), + Direction::Neither => format!("{amount}, no money moved"), + }; + let cash = s.cash.unwrap_or_default(); + let in_kind = s.in_kind.unwrap_or_default(); + if !in_kind.is_zero() { + if in_kind == v { + t.push_str(", in kind"); + } else { + t.push_str(&format!(", {} in kind", fmt::dollars(in_kind.abs()))); + } + } + let owed = v - cash - in_kind; + if d != Direction::Neither && !owed.is_zero() { + t.push_str(&format!(", {} still owed", fmt::dollars(owed.abs()))); + } + t + } + _ if several => match s.share { ledger_core::Share::Amount(a) => fmt::dollars(a), _ => String::new(), }, + _ => String::new(), + }; + let mut notes: Vec = how.into_iter().collect(); + if s.resolved.is_some() + && let Some(o) = reversal_of + { + notes.push(format!("reversal of #{o}")); } + if !notes.is_empty() { + if t.is_empty() { + t = notes.join(", "); + } else { + t.push_str(&format!(" ({})", notes.join(", "))); + } + } + t } #[cfg(test)] diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index ccb6b75..8803225 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -483,6 +483,21 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { }; ev(icon::USER_GEAR, text, pid.map(Route::Party)) } + "party.creditor_set" => { + let pid = p.get("id").and_then(|v| v.as_u64()); + let name = party_name(b, pid); + let account = p + .get("account_id") + .and_then(|v| v.as_u64()) + .map(|a| b.account_name(a)) + .unwrap_or_else(|| "an account".to_string()); + let text = if p.get("on").and_then(|v| v.as_bool()).unwrap_or(true) { + format!("{name} is now owed on {account}") + } else { + format!("{name} is no longer owed on {account}") + }; + ev(icon::USER_GEAR, text, pid.map(Route::Party)) + } "party.w9_set" => { let pid = p.get("id").and_then(|v| v.as_u64()); let name = party_name(b, pid); diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index c0dc3fa..86a74a9 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -564,7 +564,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ui.add( egui::Label::new( RichText::new(format!( - "{period_label}: Paid out is what each party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company; Received in is what came back in; Net is paid out less received (a plus means more came in). Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's." + "{period_label}, cash basis: Paid out is the money each party's posted entries paid it, from cash, a card, or a member or lender paying for the company; Received in is the money that came in from it; Net is paid out less received (a plus means more came in). Property contributed or distributed in kind is on each party's page. Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's." )) .font(theme::regular(11.5)) .color(p.faint), @@ -577,6 +577,21 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { }); } +/// The party's totals with property in kind (basis all, rules.md B-35 +/// item 4), when it gave or took any in the period: "With property +/// contributed or distributed: paid $X, received $Y". None when nothing +/// moved in kind (the cash totals are then the whole story). +pub fn in_kind_text(flow: &crate::books::PartyFlow) -> Option { + if flow.paid_in_kind.is_zero() && flow.received_in_kind.is_zero() { + return None; + } + Some(format!( + "With property contributed or distributed: paid {}, received {}", + fmt::dollars(flow.paid + flow.paid_in_kind), + fmt::dollars(flow.received + flow.received_in_kind) + )) +} + /// A number tile on a party's page: label, amount, how it's computed, /// and the line under it. /// (label, value, how, sub); the value None when it can't be computed @@ -673,14 +688,14 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { tiles.push(( "Paid out".into(), Some(flow.paid), - format!("{label}, {span_words}: what this party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company (net of refunds and reversals of payments)."), - Some((label.clone(), p.muted)), + format!("{label}, {span_words}, cash basis: the money this party's posted entries paid it, from cash, a card, or a member or lender paying for the company (net of reversals of payments; a refund is received in). A bill counts once it is paid."), + Some((format!("{label} · cash basis"), p.muted)), )); tiles.push(( "Received in".into(), Some(flow.received), - format!("{label}, {span_words}: what this party's posted entries brought into cash and those accounts (net of reversals)."), - Some((label.clone(), p.muted)), + format!("{label}, {span_words}, cash basis: the money this party's posted entries brought in (net of reversals)."), + Some((format!("{label} · cash basis"), p.muted)), )); let net = flow.net(); tiles.push(( @@ -771,6 +786,21 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string())); } }); + // Basis all (rules.md B-35 item 4): property in kind, when any. + if let Some(text) = in_kind_text(&flow) { + ui.add_space(6.0); + ui.add( + egui::Label::new( + RichText::new(text) + .font(theme::regular(12.5)) + .color(p.muted), + ) + .wrap(), + ) + .on_hover_text( + "Basis all (books.party basis \"all\"): the money above plus property contributed or distributed in kind, as the statement of members' equity counts it.", + ); + } // A member's page is their capital account statement too. if let Some(st) = b.capital_statement(id, span) { @@ -939,6 +969,23 @@ fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option) { } } ui.end_row(); + if !party.creditor_of.is_empty() { + k(ui, "Owed on"); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + for &a in &party.creditor_of { + if widgets::link(ui, RichText::new(b.account_name(a)).color(p.accent)) + .on_hover_text( + "What the company owes this party itself on (its card, its loan, what the members advanced): a charge or fee credited to it never counts as paying them.", + ) + .clicked() + { + *go = Some(Route::Account(a)); + } + } + }); + ui.end_row(); + } if let Some(v) = party.vendor { k(ui, "Vendor record"); if widgets::link(ui, RichText::new(format!("vendor {v}")).color(p.accent)) @@ -1229,7 +1276,7 @@ fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option .collect(); sub = format!( "Their share, {} of {}, with {} · {sub}", - super::entry::share_text(mine), + super::entry::share_text(mine, true, e.reversal_of), fmt::dollars_or(e.amount), others.join(" & ") ); diff --git a/crates/ledger-gui/tests/edges.rs b/crates/ledger-gui/tests/edges.rs index 3256405..b0f2c8c 100644 --- a/crates/ledger-gui/tests/edges.rs +++ b/crates/ledger-gui/tests/edges.rs @@ -172,17 +172,16 @@ fn the_last_months_of_a_span() { fn a_partys_part_of_a_split_entry_is_its_fraction_of_what_was_bought() { let f = fixture("what-for"); let e = f.books.ledger.get_entry(f.entry).unwrap(); - let money = money_accounts(&f.books.ledger, &[f.cash]); + let money = money_accounts(&f.books.ledger, &[f.cash], &[]); let flow = entry_flow(e, &money).unwrap(); let dec = |c: i64| Decimal::new(c, 2); - assert_eq!( - f.books.party_what_for(e, f.sam, &flow), - vec![(f.rent, dec(4_200)), (f.fees, dec(1_800))] - ); - assert_eq!( - f.books.party_what_for(e, f.riley, &flow), - vec![(f.rent, dec(2_800)), (f.fees, dec(1_200))] - ); + let sam = f.books.party_what_for(e, f.sam); + let riley = f.books.party_what_for(e, f.riley); + assert_eq!(sam, vec![(f.rent, dec(4_200)), (f.fees, dec(1_800))]); + assert_eq!(riley, vec![(f.rent, dec(2_800)), (f.fees, dec(1_200))]); + // Together, the entry's whole breakdown. + let total: Decimal = sam.iter().chain(&riley).map(|(_, v)| *v).sum(); + assert_eq!(total, flow.net); } /// share_of_what_for doc: the whole what-for for the whole net; nothing diff --git a/crates/ledger-gui/tests/parties.rs b/crates/ledger-gui/tests/parties.rs index 6ad7800..8d23ec7 100644 --- a/crates/ledger-gui/tests/parties.rs +++ b/crates/ledger-gui/tests/parties.rs @@ -8,7 +8,7 @@ use egui_kittest::Harness; use ledger_core::arap::DocumentLine; use ledger_core::{ AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side, - TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report, + TinFacts, TinType, W4Facts, W9Facts, }; use ledger_gui::books::Books; use ledger_gui::nav::{Route, Section}; @@ -258,6 +258,9 @@ fn fixture(tag: &str) -> Fixture { let e = pay(&mut s, d(2026, 1, 20), "Old Co", cash, 12_300); s.set_entry_party(e, Some(old)).unwrap(); s.update_party(old, PartyField::Archived(true)).unwrap(); + // What the members advanced is money (rules.md B-35 item 5): Due to + // Shareholders is a financing account. + journal_settings(&mut s, "[books]\nfinancing_accounts = [\"2100\"]\n"); drop(s); let oracle = Store::open(&data).unwrap().ledger().clone(); Fixture { @@ -297,8 +300,20 @@ fn each_partys_totals_are_the_books_numbers() { assert_eq!(sam.open_bills, bill.balance().ok()); assert_eq!(sam.open_bills, Some(cents(30_000))); assert_eq!(sam.bills, vec![1]); - // The 1099 amount is ledger-core's worksheet line, exactly. - let r = vendor_1099_report(o, 2026, None).unwrap(); + // The 1099 amount is ledger-core's worksheet line, exactly, over the + // viewer's payment accounts (every asset account, no cash accounts + // being set; the financing account). + let owed = o.chart().find_by_code("2100").unwrap().id; + let r = ledger_core::vendor_1099_report_with( + o, + 2026, + None, + &ledger_core::PaymentAccounts { + cash: vec![], + financing: vec![owed], + }, + ) + .unwrap(); let line = r .vendors .iter() @@ -306,8 +321,9 @@ fn each_partys_totals_are_the_books_numbers() { .unwrap(); assert_eq!(sam.form_1099.as_ref(), Some(line)); assert_eq!(sam.threshold_1099, Some(r.threshold)); - // (Cash only: card and owner-paid aren't in it.) - assert_eq!(line.paid, cents(150_000)); + // (Cash and what the owner paid for the company: the card isn't in + // it.) + assert_eq!(line.paid, cents(180_000)); // Every entry tagged, drafts included, newest first. assert!(sam.entries.contains(&f.bill_entry)); assert!(sam.entries.contains(&f.reversal)); @@ -561,6 +577,57 @@ fn journal_text_for_party_events() { } } +/// party.creditor_set and the journal feature it declares read as words +/// in the journal, and the party's page lists what it is owed on. +#[test] +fn journal_text_for_a_creditor() { + use egui_kittest::kittest::Queryable; + let f = fixture("creditor"); + let mut s = Store::open(&f.dir.0.join("books-data")).unwrap(); + let bank = s + .add_party("Harbor Bank", &[Role::FinancialInstitution]) + .unwrap(); + let card = s.ledger().chart().find_by_code("2050").unwrap().id; + s.set_party_creditor(bank, card, true).unwrap(); + s.set_party_creditor(bank, card, false).unwrap(); + s.set_party_creditor(bank, card, true).unwrap(); + drop(s); + let b = load(&f.dir.0); + let texts: Vec<(String, Option)> = b + .journal + .iter() + .filter(|l| l.line_type == "party.creditor_set" || l.line_type == "journal.format_set") + .filter_map(|l| describe(&b, l)) + .map(|e| (e.text, e.route)) + .collect(); + assert!( + texts.contains(&( + "Harbor Bank is now owed on Business Card".to_string(), + Some(Route::Party(bank)) + )), + "{texts:#?}" + ); + assert!( + texts.contains(&( + "Harbor Bank is no longer owed on Business Card".to_string(), + Some(Route::Party(bank)) + )), + "{texts:#?}" + ); + assert!( + texts + .iter() + .any(|(t, _)| t == "Journal format 2 declared, with party_creditors"), + "{texts:#?}" + ); + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 1400.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(bank))); + h.run_steps(3); + h.get_by_label("Owed on"); + h.get_by_label("Business Card"); +} + #[test] fn w9_text_never_shows_more_than_the_last_four() { let f = fixture("w9text"); @@ -787,7 +854,7 @@ fn party_money_by_period_and_what_for() { for period in b.party_periods(Some(p.id)) { let span = b.party_span(period); let fl = b.party_flow(p.id, span, true); - let a = ledger_core::parties::activity(o, p.id, span, &b.cash_accounts); + let a = ledger_core::parties::activity(o, p.id, span, &b.money); assert_eq!((fl.paid, fl.received), (a.paid, a.received)); assert_eq!( fl.what_for.iter().map(|x| x.1).sum::(), @@ -824,7 +891,7 @@ fn party_money_by_period_and_what_for() { let (line, _) = b.party_1099(f.sam, 2025).unwrap(); assert_eq!(line.paid, cents(50_000)); let (line, _) = b.party_1099(f.sam, 2026).unwrap(); - assert_eq!(line.paid, cents(150_000)); + assert_eq!(line.paid, cents(180_000)); } #[test] @@ -1156,9 +1223,19 @@ fn entries_list_every_party_with_its_resolved_share() { vec![(m.tobin, Some(cents(5_000))), (m.mo, Some(cents(5_000)))] ); assert_eq!(e.party.as_deref(), Some("Tobin Harlow & Morgan Ellis")); - // Property: net zero, shares of the total. + // Property (rules.md B-35): what moved each member's capital, signed + // as their own account shows it: received, in kind (before P3 it read + // +600.00, the sign of a payment). let e = b.entry(m.property).unwrap(); - assert_eq!(e.parties[1].resolved, Some(cents(60_000))); + assert_eq!(e.parties[1].resolved, Some(cents(-60_000))); + assert_eq!( + e.parties[1].direction, + Some(ledger_core::shares::Direction::Received) + ); + assert_eq!( + (e.parties[1].cash, e.parties[1].in_kind), + (Some(Decimal::ZERO), Some(cents(-60_000))) + ); // The journal says who, for parties_set. let line = b .journal @@ -1257,6 +1334,185 @@ fn a_members_page_is_their_capital_statement() { h.get_by_label("Contributions of property (at fair market value)"); } +/// member_books with the laptop's contribution reversed and made again +/// (after the closed first half), so the pages have a reversal to say: +/// the books and the reversal's id. +fn reversed_laptop(tag: &str) -> (MemberBooks, EntryId) { + let m = member_books(tag); + let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); + let undo = s + .reverse_entry(m.property, d(2026, 7, 2), "Reversed") + .unwrap(); + drop(s); + let again = post_more( + &m, + d(2026, 7, 3), + &[ + ("1500", Side::Debit, 120_000), + ("3000", Side::Credit, 60_000), + ("3001", Side::Credit, 60_000), + ], + ); + let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); + s.set_entry_parties( + again, + &[ + ledger_core::PartyShare::whole(m.tobin), + ledger_core::PartyShare::whole(m.mo), + ], + ) + .unwrap(); + drop(s); + (m, undo) +} + +/// A harness showing `route` over the books in `dir`. +fn show(dir: &Path, route: Route) -> Harness<'static, App> { + let b = load(dir); + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 1400.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, route)); + h.run_steps(3); + h +} + +/// The viewer shows the tools' numbers (rules.md B-35): a member's party +/// flow is books.party's activity on both bases, labelled cash basis, with +/// the in-kind line under it; the entry page says each part in words, a +/// property contribution "received $600.00, in kind" and its reversal +/// "paid $600.00, in kind (reversal of #n)". +#[test] +fn the_party_page_shows_the_basis_and_in_kind() { + use egui_kittest::kittest::Queryable; + let (m, undo) = reversed_laptop("basis"); + let b = load(&m.dir.0); + let span = b.party_span(ledger_gui::books::PartyPeriod::ThisYear); + for who in [m.tobin, m.mo] { + let flow = b.party_flow(who, span, true); + let server = + ledger_core::parties::activity_with(&b.ledger, who, span, &b.money, &b.members); + assert_eq!( + (flow.paid, flow.received), + server.totals(ledger_core::parties::Basis::Cash) + ); + assert_eq!( + ( + flow.paid + flow.paid_in_kind, + flow.received + flow.received_in_kind + ), + server.totals(ledger_core::parties::Basis::All) + ); + // Cash: half the reimbursement and their distribution; in kind: + // the laptop (600.00 − 600.00 + 600.00). + assert_eq!((flow.paid, flow.received), (cents(25_000), Decimal::ZERO)); + assert_eq!( + (flow.paid_in_kind, flow.received_in_kind), + (Decimal::ZERO, cents(60_000)) + ); + assert_eq!( + flow.what_for.iter().map(|x| x.1).sum::(), + flow.net() + ); + // What for is what the money paid for: the laptop, contributed in + // kind, reversed and contributed again, isn't in it (it read + // "Equipment 600.00" and "+400.00" of capital). + let equip = b.ledger.chart().find_by_code("1500").unwrap().id; + let due = b.ledger.chart().find_by_code("2100").unwrap().id; + let capital = b.member(who).unwrap().capital; + assert_eq!( + flow.what_for, + vec![(capital, cents(20_000)), (due, cents(5_000))], + "no {equip} line" + ); + } + let flow = b.party_flow(m.tobin, span, true); + assert_eq!( + ledger_gui::pages::parties::in_kind_text(&flow).as_deref(), + Some("With property contributed or distributed: paid $250.00, received $600.00") + ); + let h = show(&m.dir.0, Route::Party(m.tobin)); + h.get_by_label("With property contributed or distributed: paid $250.00, received $600.00"); + assert!(h.query_all_by_label_contains("cash basis").next().is_some()); + let h = show(&m.dir.0, Route::Entry(m.property)); + h.get_by_label_contains("Tobin Harlow · received $600.00, in kind (derived)"); + let h = show(&m.dir.0, Route::Entry(undo)); + h.get_by_label_contains(&format!( + "Morgan Ellis · paid $600.00, in kind (derived, reversal of #{})", + m.property + )); +} + +/// The viewer's what-for reads a reversal as parties::flow does, the +/// opposite of its original (rules.md B-35 item 3; design §1.4): a member +/// paying the card from their own funds (Dr card / Cr 2100), tagged with +/// the issuer, then reversed, is nothing on the issuer's page, paid or +/// what for. Its lines alone read as a second payment. +#[test] +fn a_reversed_member_card_payment_is_nothing_on_the_issuers_page() { + let m = member_books("card-reversed"); + let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); + s.add_account("2050", "Business Card", AccountType::Liability, None, false) + .unwrap(); + let issuer = s + .add_party("Harbor Bank", &[Role::FinancialInstitution]) + .unwrap(); + drop(s); + member_settings( + &m.dir.0, + "cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\n", + ); + let paid = post_more( + &m, + d(2026, 7, 10), + &[ + ("2050", Side::Debit, 10_000), + ("2100", Side::Credit, 10_000), + ], + ); + let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); + s.set_entry_party(paid, Some(issuer)).unwrap(); + drop(s); + let b = load(&m.dir.0); + let span = (d(2026, 1, 1), d(2026, 12, 31)); + let flow = b.party_flow(issuer, span, true); + let card = b.ledger.chart().find_by_code("2050").unwrap().id; + assert_eq!((flow.paid, flow.received), (cents(10_000), Decimal::ZERO)); + assert_eq!(flow.what_for, vec![(card, cents(10_000))]); + let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); + s.reverse_entry(paid, d(2026, 7, 11), "Reversed").unwrap(); + drop(s); + let b = load(&m.dir.0); + let flow = b.party_flow(issuer, span, true); + assert_eq!((flow.paid, flow.received), (Decimal::ZERO, Decimal::ZERO)); + assert_eq!(flow.what_for, vec![], "the breakdown nets to nothing"); +} + +/// Screenshots of the party pages and the entry pages B-35 changed, for +/// eyes: set `SHOT_DIR`. +#[test] +fn party_basis_screenshots() { + let Ok(out) = std::env::var("SHOT_DIR") else { + return; + }; + let out = PathBuf::from(out); + std::fs::create_dir_all(&out).unwrap(); + let (m, undo) = reversed_laptop("basis-shots"); + for (name, route) in [ + ("party-member", Route::Party(m.tobin)), + ("parties", Route::Parties), + ("entry-property", Route::Entry(m.property)), + ("entry-reversal", Route::Entry(undo)), + ("entry-reimbursement", Route::Entry(m.reimburse)), + ] { + let mut h = show(&m.dir.0, route); + h.run_steps(3); + h.render() + .unwrap() + .save(out.join(format!("{name}.png"))) + .unwrap(); + } +} + /// The viewer's statement is the K-1 worksheet's row, field for field, /// over any span (CapitalStatement INVARIANT 1): ledger_core::k1 called /// here with the members and cash accounts as the CPA package builds them. @@ -1286,7 +1542,7 @@ fn the_capital_statement_is_the_k1_worksheet_row() { } }); let cash: Vec<_> = if fallback { - let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[]) + let mut all: Vec<_> = ledger_core::parties::money_accounts(&b.ledger, &[], &[]) .cash .into_iter() .collect(); @@ -1521,7 +1777,7 @@ fn party_money_is_the_servers_without_cash_accounts_and_after_today() { &oracle, apple, (d(2026, 1, 1), d(2026, 12, 31)), - &[], + &ledger_core::parties::money_accounts(&oracle, &[], &[]), &[], ); assert_eq!( diff --git a/crates/ledger-mcp/handbook/parties-and-tins.md b/crates/ledger-mcp/handbook/parties-and-tins.md index 40255e0..ed6196d 100644 --- a/crates/ledger-mcp/handbook/parties-and-tins.md +++ b/crates/ledger-mcp/handbook/parties-and-tins.md @@ -4,8 +4,12 @@ - Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too). - An entry with several counterparties names them all with parties ([{party, amount?|percent?}] on books.draft_entry or books.set_entry_party): there is no "joint" party. A joint member transaction (paid from or reimbursed to the members' joint funds through a payable to members, a contribution from joint funds, a distribution to both) names both members with no shares, and their shares derive (from their capital or distribution lines on the entry, else their member shares in the settings); when the split is uneven, or a party isn't a member, give each party an exact amount (or a percent). - The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming. +- A share's resolved amount is signed as the party's own account would show it: positive, the company paid it or gave it value; negative, received from it. Each part says its direction (paid, received, or neither: a bill, an invoice, a fee on its own card, a distribution still owed) and what of it was money (cash) and what property (in_kind); a reversal is exactly the opposite of what it reverses. A members' entry that moves their capital counts what moved it: a property contribution is received, in kind. +- Party totals (books.party, books.parties) take a basis: cash, the default, counts money; all adds property contributed or distributed in kind (for a member, the statement of members' equity). Say which basis you report. It is not the books' accounting method. +- Money is the cash accounts, the cards (a card account's name says "card"; a gift card the company sold is deferred revenue, not a card) and the financing accounts setting ([books] financing_accounts: what the members advanced, loans): list the members' payable and every loan there, and any card whose account name doesn't say "card". Any other liability (an accrual, deferred revenue, distributions payable) counts once, when it is settled. +- Name whose each card, loan and advance is: books.update_party creditor_of, the issuer for its card, the lender for its loan, each member for what the members advanced. Then a fee on the card, interest on the loan, or a distribution credited to what the members are owed never reads as a payment to them; it counts when paid. A distribution declared to distributions payable counts when it is paid, too. Record a reimbursement and a distribution as two entries, and a member's cash either as a loan (through the members' financing, tagged with the payee it paid) or as capital, not both. - Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it. - Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. - Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9), or w9_content_base64 with w9_sha256 for one of at most 256 KiB (never books.file_document or books.attach_receipt: the form shows the TIN). - A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. -- books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history. +- books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history. diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 73753cb..f9ff483 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -2026,7 +2026,7 @@ fn party_roles(p: &ledger_core::Party) -> Vec { } /// A party's facts for a tool's output. -fn party_out(p: &ledger_core::Party) -> PartyOut { +fn party_out(ledger: &Ledger, p: &ledger_core::Party) -> PartyOut { PartyOut { party_id: p.id, name: p.name.clone(), @@ -2059,6 +2059,11 @@ fn party_out(p: &ledger_core::Party) -> PartyOut { in_vault: t.vault_record.is_some(), purged: t.purged.clone(), }), + creditor_of: p + .creditor_of + .iter() + .filter_map(|a| ledger.chart().get(*a).map(|a| a.code.clone())) + .collect(), } } @@ -2122,34 +2127,45 @@ fn checked_sum( struct PartyActivity { /// The party's entries, drafts included. entries: std::collections::BTreeSet, - /// Paid to it in the year. + /// Paid to it in the year, on the basis asked. paid: Decimal, - /// Received from it in the year. + /// Received from it in the year, on the basis asked. received: Decimal, } -/// The party's entries and the year's money paid and received +/// A party-totals `basis` from a tool: "cash" (the default) or "all" +/// (rules.md B-35 item 4); any other word is refused. +fn parse_basis(basis: Option<&str>) -> Result { + match basis { + None => Ok(ledger_core::parties::Basis::default()), + Some(b) => ledger_core::parties::Basis::parse(b) + .ok_or_else(|| format!("basis is \"cash\" or \"all\", not {b:?}")), + } +} + +/// The party's entries and the year's paid and received on `basis` /// (`PartyTotalsOut`): ledger_core::parties::activity_with over the -/// calendar year, with the `[books] cash_accounts` and the members (so an -/// entry naming several parties counts this one's share). +/// calendar year, with the money accounts of [`share_context`] and the +/// members (so an entry naming several parties counts this one's part). fn party_activity( store: &Store, party: &ledger_core::Party, settings: &Settings, year: i32, + basis: ledger_core::parties::Basis, ) -> Result { let ledger = store.ledger(); - let cash = cash_ids(ledger, &settings.roles.cash); let span = ( NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?, NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?, ); - let members = settings.company.party_members(ledger); - let a = ledger_core::parties::activity_with(ledger, party.id, span, &cash, &members); + let (money, members) = share_context(ledger, settings); + let a = ledger_core::parties::activity_with(ledger, party.id, span, &money, &members); + let (paid, received) = a.totals(basis); Ok(PartyActivity { entries: a.entries, - paid: a.paid, - received: a.received, + paid, + received, }) } @@ -2175,7 +2191,8 @@ fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec { } /// What party shares resolve with ([`ledger_core::shares::resolve`]): the -/// money accounts over the `[books] cash_accounts`, and the members. +/// money accounts over the `[books] cash_accounts` and +/// `financing_accounts` (with the cards), and the members. fn share_context( ledger: &Ledger, settings: &Settings, @@ -2184,7 +2201,11 @@ fn share_context( Vec, ) { ( - ledger_core::parties::money_accounts(ledger, &cash_ids(ledger, &settings.roles.cash)), + ledger_core::parties::money_accounts( + ledger, + &cash_ids(ledger, &settings.roles.cash), + &cash_ids(ledger, &settings.roles.financing), + ), settings.company.party_members(ledger), ) } @@ -2252,7 +2273,7 @@ fn check_shares_resolve( return Ok(()); } let (money, members) = share_context(ledger, settings); - ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ()) + ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members).map(|_| ()) } /// Fix a posted entry's derived split among several parties: journal the @@ -2289,17 +2310,19 @@ fn fix_derived_shares(store: &mut Store, settings: &Settings, entry_id: u64) -> return Ok(()); } let (money, members) = share_context(ledger, settings); - let Ok(parts) = ledger_core::shares::resolve(entry, shares, &money, &members) else { + let Ok(resolved) = ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members) + else { return Ok(()); }; - if parts.iter().any(|(_, v)| v.is_zero()) { + if resolved.parts.iter().any(|p| p.amount.is_zero()) { return Ok(()); } - let fixed: Vec = parts - .into_iter() - .map(|(party, v)| ledger_core::PartyShare { - party, - share: ledger_core::Share::Amount(v.abs()), + let fixed: Vec = resolved + .parts + .iter() + .map(|p| ledger_core::PartyShare { + party: p.party, + share: ledger_core::Share::Amount(p.amount.abs()), }) .collect(); store @@ -2317,10 +2340,11 @@ fn entry_parties_out( let ledger = store.ledger(); let shares = ledger.entry_parties(entry.id()); let (accounts, members) = share_context(ledger, settings); - let (resolved, error) = match ledger_core::shares::resolve(entry, shares, &accounts, &members) { - Ok(r) => (Some(r), None), - Err(e) => (None, Some(e)), - }; + let (resolved, error) = + match ledger_core::shares::resolve(ledger, entry.id(), shares, &accounts, &members) { + Ok(r) => (Some(r), None), + Err(e) => (None, Some(e)), + }; let out = shares .iter() .enumerate() @@ -2341,7 +2365,12 @@ fn entry_parties_out( share: kind.to_string(), amount, percent, - resolved: resolved.as_ref().map(|r| money(r[i].1)), + resolved: resolved.as_ref().map(|r| money(r.parts[i].amount)), + direction: resolved + .as_ref() + .map(|r| r.target.direction.key().to_string()), + cash: resolved.as_ref().map(|r| money(r.parts[i].cash)), + in_kind: resolved.as_ref().map(|r| money(r.parts[i].in_kind)), } }) .collect(); @@ -6906,13 +6935,13 @@ impl BooksServer { .add_party(&p.name, &roles) .map_err(|e| e.to_string())?; let party = store.ledger().get_party(id).expect("just added"); - Ok(Json(party_out(party))) + Ok(Json(party_out(store.ledger(), party))) } #[tool( name = "books.update_party", title = "Update a party", - description = "Change a party's facts: name, display_name, website, address, notes, archived. Give only the fields to change; an empty string clears display_name, website, address, or notes. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters) changes nothing; each is journaled as its own party.updated. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.", + description = "Change a party's facts: name, display_name, website, address, notes, archived, creditor_of. Give only the fields to change; \"\" clears display_name, website, address or notes. creditor_of replaces the list of liabilities the company owes the party itself (its card, its loan, what the members advanced): a credit to one never counts as paying it. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters, not a liability) changes nothing; each is journaled as party.updated or party.creditor_set. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.", annotations( read_only_hint = false, destructive_hint = false, @@ -6946,24 +6975,46 @@ impl BooksServer { if let Some(a) = p.archived { fields.push(F::Archived(a)); } - if fields.is_empty() { + // creditor_of replaces the list: each account added or dropped is + // one change. + let mut creditor: Vec<(AccountId, bool)> = Vec::new(); + if let Some(codes) = &p.creditor_of { + let want: std::collections::BTreeSet = codes + .iter() + .map(|c| resolve_account(&store, c.trim())) + .collect::>()?; + let has = &store.ledger().get_party(id).expect("resolved").creditor_of; + creditor.extend(want.difference(has).map(|a| (*a, true))); + creditor.extend(has.difference(&want).map(|a| (*a, false))); + } + if fields.is_empty() && p.creditor_of.is_none() { return Err( - "give at least one field to change: name, display_name, website, address, notes, archived" + "give at least one field to change: name, display_name, website, address, notes, archived, creditor_of" .into(), ); } - // Every field, in order, before any is written (INVARIANT 2). + // Every change, in order, before any is written (INVARIANT 2). let mut rehearsal = store.ledger().clone(); for f in &fields { rehearsal .update_party(id, f.clone()) .map_err(|e| e.to_string())?; } + for (a, on) in &creditor { + rehearsal + .set_party_creditor(id, *a, *on) + .map_err(|e| e.to_string())?; + } for f in fields { store.update_party(id, f).map_err(|e| e.to_string())?; } + for (a, on) in creditor { + store + .set_party_creditor(id, a, on) + .map_err(|e| e.to_string())?; + } let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(party))) + Ok(Json(party_out(store.ledger(), party))) } #[tool( @@ -7030,7 +7081,7 @@ impl BooksServer { store.set_party_role(id, r, on).map_err(|e| e.to_string())?; } let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(party))) + Ok(Json(party_out(store.ledger(), party))) } /// ```text @@ -7272,7 +7323,7 @@ impl BooksServer { .map_err(|e| e.to_string())?; } let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(party))) + Ok(Json(party_out(store.ledger(), party))) } /// ```text @@ -7359,7 +7410,7 @@ impl BooksServer { .map_err(|e| e.to_string())?; } let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(party))) + Ok(Json(party_out(store.ledger(), party))) } /// ```text @@ -7494,11 +7545,7 @@ impl BooksServer { name: party.name.clone(), tin_in_vault: held_tin, w9_file_in_vault: held_w9, - tin_last4: party - .w9 - .as_ref() - .map(|f| f.tin_last4.clone()) - .or_else(|| party.tin.as_ref().map(|t| t.last4.clone())), + tin_last4: party.tin_last4().map(str::to_string), journaled: false, note: format!( "Confirming deletes {}{}; backups made before keep their copies. Make sure the CPA has what they need first.", @@ -7578,14 +7625,14 @@ impl BooksServer { let id = resolve_party(&store, &p.party)?; store.set_party_w4(id, facts).map_err(|e| e.to_string())?; let party = store.ledger().get_party(id).expect("resolved"); - Ok(Json(party_out(party))) + Ok(Json(party_out(store.ledger(), party))) } #[tool( name = "books.parties", title = "List parties", description = described( - "The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 facts (with the TIN's last four) and W-4 facts are on file, archived, and what was paid to and received from it in the year (defined as in books.party). Filter by role; archived parties only with include_archived.", + "The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 and W-4 facts are on file, the TIN's last four when the books know it (TIN or W-9 facts), archived, and what was paid to and received from it in the year on the basis asked: cash by default (money), or all (adds property contributed or distributed in kind); defined as in books.party, the basis echoed. Filter by role; archived parties only with include_archived.", ledger_core::calendar::PeriodKind::CalendarYear ), annotations(read_only_hint = true, open_world_hint = false) @@ -7601,6 +7648,7 @@ impl BooksServer { .map(|r| parse_roles(&[r.to_string()]).map(|v| v[0])) .transpose()?; let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); + let basis = parse_basis(p.basis.as_deref())?; let settings = self.settings(); let store = self.lock()?; let mut parties = Vec::new(); @@ -7611,7 +7659,7 @@ impl BooksServer { if role.is_some_and(|r| !party.all_roles().contains(&r)) { continue; } - let act = party_activity(&store, party, &settings, year)?; + let act = party_activity(&store, party, &settings, year, basis)?; parties.push(PartySummaryOut { party_id: party.id, name: party.name.clone(), @@ -7620,21 +7668,25 @@ impl BooksServer { vendor_id: party.vendor, customer_id: party.customer, w9_on_file: party.w9.is_some(), - tin_last4: party.w9.as_ref().map(|w| w.tin_last4.clone()), + tin_last4: party.tin_last4().map(str::to_string), w4_on_file: party.w4.is_some(), archived: party.archived, paid: money(act.paid), received: money(act.received), }); } - Ok(Json(ListPartiesOut { year, parties })) + Ok(Json(ListPartiesOut { + year, + basis: basis.key().to_string(), + parties, + })) } #[tool( name = "books.party", title = "Get a party", description = described( - "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it, received from it, invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", + "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", ledger_core::calendar::PeriodKind::CalendarYear ), annotations(read_only_hint = true, open_world_hint = false) @@ -7642,12 +7694,13 @@ impl BooksServer { async fn party(&self, params: Parameters) -> Result, String> { let p = params.0; let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); + let basis = parse_basis(p.basis.as_deref())?; let settings = self.settings(); let store = self.lock()?; let ledger = store.ledger(); let id = resolve_party(&store, &p.party)?; let party = ledger.get_party(id).expect("resolved"); - let act = party_activity(&store, party, &settings, year)?; + let act = party_activity(&store, party, &settings, year, basis)?; let in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year; let mut entries: Vec = act .entries @@ -7704,6 +7757,7 @@ impl BooksServer { } } let totals = PartyTotalsOut { + basis: basis.key().to_string(), paid: money(act.paid), received: money(act.received), invoiced: money(checked_sum( @@ -7734,7 +7788,7 @@ impl BooksServer { net_pay: money(net), }; Ok(Json(PartyDetailOut { - party: party_out(party), + party: party_out(ledger, party), year, totals, entries, @@ -7769,7 +7823,7 @@ impl BooksServer { #[tool( name = "books.set_entry_party", title = "Tag an entry's parties", - description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved.", + description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved, with its direction and what of it was money or property in kind.", annotations( read_only_hint = false, destructive_hint = false, diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index ac14469..9a3ff6e 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -1256,11 +1256,33 @@ pub struct EntryPartyOut { pub amount: Option, /// The percent given, for a percent share, decimal string. pub percent: Option, - /// Its exact part of what the entry moved (positive: paid to it; - /// negative: received from it; for an entry moving no money, its part - /// of the total), decimal string. Null when the shares don't resolve - /// (see `parties_error`). + /// Its exact part of what the entry moved, signed as a line on the + /// party's own account would be (debit-positive): positive, the + /// company paid it or gave it value; negative, received from it. With + /// direction "neither", its part of the entry's movement of its own + /// accounts (a bill is negative, owed to it; an invoice positive), else + /// of the entry's total. A reversal's is exactly its original's + /// negated. A member's entry is what moved their capital (a property + /// contribution is negative, received, in kind). Decimal string. Null + /// when the shares don't resolve (see `parties_error`). pub resolved: Option, + /// "paid", "received" or "neither": neither is a promise not yet + /// settled (a bill, an invoice, a fee charged on its own card, a + /// distribution declared and not yet paid) or an entry that moved + /// nothing with anyone (a close), counted in no party total. Null + /// exactly when `resolved` is. + pub direction: Option, + /// What of `resolved` moved as money (cash, a card, the members' + /// financing, a loan; on a member's contribution, what the K-1 + /// worksheet counts as cash; of a distribution, the money it paid): + /// what the party's totals count on basis "cash". "0.00" for direction + /// neither. Decimal string, null when `resolved` is. + pub cash: Option, + /// What of `resolved` moved as property (contributed or distributed in + /// kind): basis "all" adds it. resolved − cash − in_kind is a promise + /// not yet paid, counted on neither basis. Decimal string, null when + /// `resolved` is. + pub in_kind: Option, } /// Result of `books.draft_entry`. @@ -3000,6 +3022,14 @@ pub struct UpdatePartyParams { /// resolves by name, and its name is free for another party). False: /// dealt with again (refused while another party has its name). pub archived: Option, + /// The liability accounts (codes or ids) the company owes this party + /// itself on: the card it issued, the loan it made, what the members + /// advanced (name each member). Replaces its list; [] clears it. A + /// credit to one never counts as paying the party (a fee on its card, + /// interest on its loan, a distribution still owed: a promise, counted + /// when paid). Liabilities only, not placeholders, payables or payroll + /// withholdings. + pub creditor_of: Option>, } /// Parameters for `books.set_party_roles`. Give at least one role to add @@ -3243,6 +3273,10 @@ pub struct ListPartiesParams { /// so year to date). #[schemars(description = YEAR_DOC)] pub year: Option, + /// What `paid` and `received` count: "cash" (the default: money) or + /// "all" (money and property contributed or distributed in kind). Not + /// the books' accounting method. See `books.party` totals. + pub basis: Option, } /// Parameters for `books.party`. @@ -3254,6 +3288,10 @@ pub struct PartyParams { /// date). #[schemars(description = YEAR_DOC)] pub year: Option, + /// What `paid` and `received` count: "cash" (the default: money) or + /// "all" (money and property contributed or distributed in kind). Not + /// the books' accounting method. See the totals' schema. + pub basis: Option, } /// Parameters for `books.set_entry_party`. @@ -3345,6 +3383,10 @@ pub struct PartyOut { pub w4: Option, /// Taxpayer-ID facts (any role), if on file: type and last four. pub tin: Option, + /// The liability accounts the company owes this party itself on (its + /// card, its loan, what the members advanced), by code: a credit to + /// one never counts as paying it. + pub creditor_of: Vec, } /// One party in `books.parties`. @@ -3364,16 +3406,19 @@ pub struct PartySummaryOut { pub customer_id: Option, /// W-9 facts are on file. pub w9_on_file: bool, - /// The W-9's TIN last four digits, when on file. + /// The TIN's last four digits when the books know it: from its TIN + /// facts (`books.set_tin`; a member's SSN for the K-1) or its W-9 + /// facts, which never disagree. Null when neither is on file. Whether + /// the vault still holds the full number: `books.party` `tin.in_vault`. pub tin_last4: Option, /// W-4 facts are on file. pub w4_on_file: bool, /// No longer dealt with. pub archived: bool, - /// Paid to the party in the year, decimal string (as `books.party` - /// totals). + /// Paid to the party in the year on `basis`, decimal string (as + /// `books.party` totals). pub paid: String, - /// Received from the party in the year, decimal string (as + /// Received from the party in the year on `basis`, decimal string (as /// `books.party` totals). pub received: String, } @@ -3383,6 +3428,8 @@ pub struct PartySummaryOut { pub struct ListPartiesOut { /// The year `paid` and `received` cover. pub year: i32, + /// What `paid` and `received` count: "cash" or "all". + pub basis: String, /// Parties, by id. pub parties: Vec, } @@ -3394,30 +3441,59 @@ pub struct ListPartiesOut { /// the payments received on them, and reversals of any of these. Only /// posted entries dated in the calendar year count. /// -/// Each entry's money is `ledger_core::parties::entry_flow`: what left or -/// reached the `[books] cash_accounts` (every asset account but -/// receivables when none are set), plus what the company paid through a -/// liability (a card, a member paying for it, a loan; not payables or -/// payroll withholdings), with cash that pays a liability down counting -/// once, as paid to that liability's creditor (a reimbursement to the -/// members, a card payment to the issuer), a liability paid down by -/// another counting the same way (a member paying the card from their own -/// funds, a loan paying it off), and cash raised by a liability counting -/// as received from the lender. Positive is paid to the party, negative -/// received from it; a reversal counts against its root original's side -/// (reversing the reversal counts on that side again). A transfer between the company's own cash accounts nets to zero. -/// A bill is not paid until its payment is. -/// An entry naming several parties counts each one's share of that money -/// (its amount, its percent, or its derived share: see -/// `books.set_entry_party` `parties`); an entry whose shares don't resolve -/// counts for none of them. +/// `paid` and `received` add up each entry's part for the party, as +/// `books.get_entry` shows it (`resolved`, `direction`, `cash`, +/// `in_kind`): on `basis` "cash" (the default) what moved as money, on +/// "all" that plus property contributed or distributed in kind. A +/// reversal counts on its original's side and nets it to zero. (Not the +/// books' accounting method.) +/// +/// Money is what left or reached the `[books] cash_accounts` (every asset +/// account but receivables when none are set), the cards (liabilities +/// whose names say "card", not "gift card"), and the `[books] +/// financing_accounts` (what the members +/// advanced, loans): a card charge or a member paying for the company is +/// paid to the payee then; cash that pays one of these down is paid to +/// its creditor (a reimbursement to the members, a card payment to the +/// issuer); cash it raises is received from the lender. Any other +/// liability is a promise (a bill, an accrual, deferred revenue): counted +/// when it is settled, never also when it is booked. A credit to what the +/// company owes a party itself (`books.party` `creditor_of`: a fee on its +/// own card, interest on its own loan, a distribution still owed to a +/// member) never counts as paying it. +/// +/// An entry naming only members that moves equity counts what moved their +/// capital: a contribution received, the property in it in kind (asset +/// accounts that are neither cash nor receivables) and the rest cash, as +/// on the K-1 worksheet; a distribution paid, the money it paid as cash +/// and the property it gave in kind, the rest (declared to distributions +/// payable, or credited to what the members advanced once they are named +/// its creditors) still owed, counted when it is paid. So for a member, +/// basis "all" over those entries and those payments is the statement of +/// members' equity's contributions and distributions, and basis "cash" +/// its cash contributions as the K-1 worksheet has them, but for a +/// distribution declared and not yet paid, which is in the statement only. Known limits (record them apart): an entry +/// that both repays a member and distributes to them counts the +/// distribution only; a member's money that goes through the members' +/// financing account both ways under their own name (lent, then converted +/// to capital) counts both times, as does a distribution declared through +/// it before the members are named its creditors; an expense a member paid +/// booked straight to capital and tagged with the vendor isn't paid to the +/// vendor; capital moved between members counts for neither. +/// +/// An entry naming several parties counts each one's share (its amount, +/// its percent, or its derived share: see `books.set_entry_party` +/// `parties`); an entry whose shares don't resolve counts for none of +/// them. A transfer between the company's own cash accounts nets to zero. /// (`ledger_core::parties::activity_with`, with the `[[company.members]]`, /// computes it; the viewer shows the same numbers.) #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct PartyTotalsOut { - /// Paid to the party, decimal string. + /// What `paid` and `received` count: "cash" or "all". + pub basis: String, + /// Paid to the party on `basis`, decimal string. pub paid: String, - /// Received from the party, decimal string. + /// Received from the party on `basis`, decimal string. pub received: String, /// Its customer record's invoices dated in the year, total, decimal /// string. diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-mcp/tests/spec_parties.rs index a46e291..2f9dbb2 100644 --- a/crates/ledger-mcp/tests/spec_parties.rs +++ b/crates/ledger-mcp/tests/spec_parties.rs @@ -1027,14 +1027,31 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() { assert_eq!(t["received"], "2000.00", "a contribution"); assert_eq!(t["paid"], "500.00", "a distribution"); - // A prepaid bought with a member's card: an asset, paid by a liability. - m.add_party("Insurer", &[]); - m.post( - "2026-04-01", - json!({"party": "Insurer"}), - &[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")], + // A prepaid bought with a member's card: an asset, paid through what + // the members advanced. That is money when it is a financing account + // (rules.md B-35 item 5, P3 defect 24); unlisted, Due to Members is a + // promise like any other liability, counted when the member is paid + // back. + let prepaid = |m: &mut Mcp| { + m.add_party("Insurer", &[]); + m.post( + "2026-04-01", + json!({"party": "Insurer"}), + &[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")], + ); + m.party("Insurer", 2026)["totals"].clone() + }; + let t = prepaid(&mut m); + assert_eq!( + (t["paid"].clone(), t["received"].clone()), + (json!("0.00"), json!("0.00")) ); - let t = m.party("Insurer", 2026)["totals"].clone(); + let dir = test_dir("totals-financing"); + let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n"); + let mut f = Mcp::start(&dir, &toml); + f.accounts(CHART); + f.books_dir = Some(dir); + let t = prepaid(&mut f); assert_eq!(t["paid"], "1200.00"); assert_eq!(t["received"], "0.00"); } diff --git a/crates/ledger-mcp/tests/spec_party_totals.rs b/crates/ledger-mcp/tests/spec_party_totals.rs new file mode 100644 index 0000000..b849f18 --- /dev/null +++ b/crates/ledger-mcp/tests/spec_party_totals.rs @@ -0,0 +1,713 @@ +//! P3 defects 14 and 24 through the MCP, over stdio against the real +//! binary: each party's part of an entry (`books.get_entry` `resolved`, +//! `direction`, `cash`, `in_kind`), party totals on a basis +//! (`books.party`, `books.parties` `basis`), and a party named the +//! creditor of a liability (`books.update_party` `creditor_of`). +//! +//! Expected behavior comes only from the tool descriptions and parameter / +//! result doc comments in `src/server.rs` and `src/types.rs` (as +//! `tools/list` serves them) and rules.md B-35. The harness is the minimal +//! subset of `tests/spec_mcp.rs` (copied from `tests/spec_parties.rs`): +//! every success validates against the tool's `outputSchema`, and every +//! refusal leaves `journal.jsonl` byte-identical. + +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(180); + +// ---------------------------------------------------------------- harness + +#[derive(Debug, Clone)] +enum Out { + Ok(Value), + ToolErr(String), + RpcErr(Value), +} + +impl Out { + fn refused(&self) -> bool { + !matches!(self, Out::Ok(_)) + } + fn err_text(&self) -> String { + match self { + Out::Ok(v) => panic!("expected a refusal, got {v}"), + Out::ToolErr(t) => t.clone(), + Out::RpcErr(e) => e.to_string(), + } + } +} + +struct Mcp { + stdin: ChildStdin, + rx: Receiver, + child: Child, + next_id: u64, + data: PathBuf, + validators: HashMap, + /// The books dir when the server owns it: removed after the server + /// stops (fields drop after `Drop::drop`). + books_dir: Option, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +static DIR_SEQ: AtomicU64 = AtomicU64::new(0); + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ledger-mcp-spec-party-totals-{}-{name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The +/// receipt threshold is high so ordinary test entries need no receipt. +fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { + let path = dir.join("wanderbooks.toml"); + std::fs::write( + &path, + format!( + "[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}" + ), + ) + .unwrap(); + path +} + +impl Mcp { + fn start(dir: &Path, toml: &Path) -> Self { + let run = dir.join("run"); + std::fs::create_dir_all(&run).unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) + .arg("--data-dir") + .arg(dir.join("books-data")) + .arg("--books-toml") + .arg(toml) + .env("XDG_RUNTIME_DIR", &run) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ledger-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in stdout.lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + let mut mcp = Self { + books_dir: None, + stdin, + rx, + child, + next_id: 1, + data: dir.join("books-data"), + validators: HashMap::new(), + }; + let init = mcp.rpc( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "spec-party-totals", "version": "0.1.0"}, + }), + ); + assert!(init.get("result").is_some(), "initialize failed: {init}"); + mcp.send_line( + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(), + ); + let list = mcp.rpc("tools/list", json!({})); + for tool in list["result"]["tools"].as_array().expect("tools") { + let name = tool["name"].as_str().unwrap().to_string(); + let validator = jsonschema::validator_for(&tool["outputSchema"]) + .unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}")); + mcp.validators.insert(name, validator); + } + mcp + } + + fn journal(&self) -> Vec { + std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default() + } + + /// The journal's line types, in order. + fn journal_types(&self) -> Vec { + String::from_utf8(self.journal()) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let v: Value = serde_json::from_str(l).unwrap(); + v["type"] + .as_str() + .or_else(|| v["line_type"].as_str()) + .unwrap_or_default() + .to_string() + }) + .collect() + } + + fn send_line(&mut self, line: &str) { + writeln!(self.stdin, "{line}").unwrap(); + self.stdin.flush().unwrap(); + } + + fn recv(&mut self) -> Option { + loop { + match self.rx.recv_timeout(TIMEOUT) { + Ok(line) => { + let v: Value = serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); + if v.get("id").is_none() && v.get("method").is_some() { + continue; + } + return Some(v); + } + Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, + } + } + } + + fn rpc(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + self.send_line(&msg.to_string()); + let resp = self + .recv() + .unwrap_or_else(|| panic!("no response to {method} (id {id})")); + assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}"); + resp + } + + /// Call a tool; a refusal must leave the journal unchanged, a success + /// must match the outputSchema. + fn tool(&mut self, name: &str, args: Value) -> Out { + let before = self.journal(); + let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let out = if let Some(err) = resp.get("error") { + Out::RpcErr(err.clone()) + } else { + let result = &resp["result"]; + if result.get("isError") == Some(&json!(true)) { + Out::ToolErr( + result["content"] + .as_array() + .and_then(|c| c.iter().find_map(|b| b["text"].as_str())) + .unwrap_or_default() + .to_string(), + ) + } else { + let sc = result["structuredContent"].clone(); + let v = self + .validators + .get(name) + .unwrap_or_else(|| panic!("{name} is not in tools/list")); + let errors: Vec = v.iter_errors(&sc).map(|e| e.to_string()).collect(); + assert!( + errors.is_empty(), + "{name} violates its outputSchema: {errors:?}\n{sc}" + ); + Out::Ok(sc) + } + }; + if out.refused() { + assert!( + self.journal() == before, + "refused {name} {args} changed the journal ({out:?})" + ); + } + out + } + + fn ok(&mut self, name: &str, args: Value) -> Value { + let a = args.to_string(); + match self.tool(name, args) { + Out::Ok(v) => v, + other => panic!("{name} {a}: expected success, got {other:?}"), + } + } + + /// `tool`, expecting a refusal that says why. + fn refuse(&mut self, name: &str, args: Value) -> String { + let a = args.to_string(); + let out = self.tool(name, args); + assert!(out.refused(), "{name} {a} should be refused, got {out:?}"); + let why = out.err_text(); + assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why"); + why + } + + /// `ok`, and the journal must not change (read-only tools). + fn read_only(&mut self, name: &str, args: Value) -> Value { + let before = self.journal(); + let v = self.ok(name, args); + assert!( + self.journal() == before, + "read-only {name} changed the journal" + ); + v + } + + fn accounts(&mut self, accts: &[(&str, &str, &str)]) { + for (code, name, ty) in accts { + self.ok( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + ); + } + } + + /// Draft an entry with `args` extra (party, vendor) and lines; the id. + fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 { + let mut args = json!({"date": date, "description": format!("entry {date}")}); + for (k, v) in extra.as_object().unwrap() { + args[k] = v.clone(); + } + let id = self.ok("books.draft_entry", args)["entry_id"] + .as_u64() + .unwrap(); + for (account, side, amount) in lines { + self.ok( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + ); + } + id + } + + /// `draft`, then post it (high receipt threshold: no receipt needed). + fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 { + let id = self.draft(date, extra, lines); + self.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + id + } + + fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 { + self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"] + .as_u64() + .unwrap() + } + + /// How many journal lines have this type. + fn count(&self, line_type: &str) -> usize { + self.journal_types() + .iter() + .filter(|t| t.as_str() == line_type) + .count() + } +} + +const CHART: &[(&str, &str, &str)] = &[ + ("1000", "Checking", "asset"), + ("1300", "Prepaid Expenses", "asset"), + ("1500", "Equipment", "asset"), + ("2050", "Business Card", "liability"), + ("2100", "Due to Members", "liability"), + ("3000", "Capital — Tobin Harlow", "equity"), + ("3001", "Capital — Morgan Ellis", "equity"), + ("6050", "Domain & Hosting", "expense"), + ("6400", "Bank Fees", "expense"), +]; + +/// Fernhill Labs LLC: two members 50/50, cash 1000, the members' +/// financing 2100; the chart; both members and the card's issuer in the +/// directory. +fn books(name: &str) -> Mcp { + let dir = test_dir(name); + let toml = books_toml( + &dir, + "Fernhill Labs LLC", + "financing_accounts = [\"2100\"]\n\ + [[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"Morgan Ellis\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", + ); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m.add_party("Tobin Harlow", &["member"]); + m.add_party("Morgan Ellis", &["member"]); + m.add_party("Harbor Bank", &["financial_institution"]); + m.books_dir = Some(dir); + m +} + +/// A party's part of an entry as books.get_entry gives it: (resolved, +/// direction, cash, in_kind). +fn part(m: &mut Mcp, entry: u64, party: &str) -> (Value, Value, Value, Value) { + let e = m.read_only("books.get_entry", json!({"entry_id": entry})); + let p = e["parties"] + .as_array() + .unwrap() + .iter() + .find(|p| p["party"] == party) + .unwrap_or_else(|| panic!("{party} on entry {entry}: {e}")) + .clone(); + ( + p["resolved"].clone(), + p["direction"].clone(), + p["cash"].clone(), + p["in_kind"].clone(), + ) +} + +/// (paid, received, basis) from books.party. +fn totals(m: &mut Mcp, party: &str, basis: Option<&str>) -> (Value, Value, Value) { + let mut args = json!({"party": party, "year": 2026}); + if let Some(b) = basis { + args["basis"] = json!(b); + } + let p = m.read_only("books.party", args); + let t = &p["totals"]; + (t["paid"].clone(), t["received"].clone(), t["basis"].clone()) +} + +fn reverse(m: &mut Mcp, entry: u64, date: &str) -> u64 { + m.ok( + "books.reverse_entry", + json!({"entry_id": entry, "date": date, "description": "reversed", "confirmed": true}), + )["reversal_id"] + .as_u64() + .unwrap_or_else(|| panic!("a reversal of {entry}")) +} + +/// Defect 14 over the MCP (rules.md B-35): `resolved` is signed as a line +/// on the party's own account, with its direction and its money and +/// property; a reversal is the exact opposite; party totals take a basis, +/// cash by default and echoed, all adding property in kind, so that a +/// member's basis-all received is the statement of members' equity's +/// contributions; any other basis is refused. +#[test] +fn defect14_party_totals_take_a_basis() { + let mut m = books("defect14"); + let tobin = json!({"parties": [{"party": "Tobin Harlow"}]}); + let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]}); + // Cash in, 400.00 each. + let cash_in = m.post( + "2026-10-05", + tobin.clone(), + &[("1000", "debit", "400.00"), ("3000", "credit", "400.00")], + ); + m.post( + "2026-10-05", + json!({"parties": [{"party": "Morgan Ellis"}]}), + &[("1000", "debit", "400.00"), ("3001", "credit", "400.00")], + ); + // A laptop Tobin contributes, then its reversal. + let laptop = m.post( + "2026-10-12", + tobin.clone(), + &[("1500", "debit", "1480.00"), ("3000", "credit", "1480.00")], + ); + let undo = reverse(&mut m, laptop, "2026-10-13"); + // Devices from both, derived from the capital lines; one with its + // service contract (prepaid: property). + m.post( + "2026-10-14", + both.clone(), + &[ + ("1500", "debit", "1480.00"), + ("3000", "credit", "740.00"), + ("3001", "credit", "740.00"), + ], + ); + m.post( + "2026-10-19", + both.clone(), + &[ + ("1500", "debit", "1010.00"), + ("1300", "debit", "30.00"), + ("3000", "credit", "520.00"), + ("3001", "credit", "520.00"), + ], + ); + // A reimbursement to both (money, paid). + let reimburse = m.post( + "2026-10-20", + json!({"parties": [{"party": "Tobin Harlow", "amount": "93.25"}, {"party": "Morgan Ellis", "amount": "93.25"}]}), + &[("2100", "debit", "186.50"), ("1000", "credit", "186.50")], + ); + assert_eq!( + part(&mut m, cash_in, "Tobin Harlow"), + ( + json!("-400.00"), + json!("received"), + json!("-400.00"), + json!("0.00") + ) + ); + assert_eq!( + part(&mut m, laptop, "Tobin Harlow"), + ( + json!("-1480.00"), + json!("received"), + json!("0.00"), + json!("-1480.00") + ) + ); + assert_eq!( + part(&mut m, undo, "Tobin Harlow"), + ( + json!("1480.00"), + json!("paid"), + json!("0.00"), + json!("1480.00") + ) + ); + assert_eq!( + part(&mut m, reimburse, "Morgan Ellis"), + (json!("93.25"), json!("paid"), json!("93.25"), json!("0.00")) + ); + // Totals: cash by default, labelled; all adds 740.00 + 520.00. + let cash = (json!("93.25"), json!("400.00"), json!("cash")); + assert_eq!(totals(&mut m, "Tobin Harlow", None), cash); + assert_eq!(totals(&mut m, "Tobin Harlow", Some("cash")), cash); + let all = totals(&mut m, "Tobin Harlow", Some("all")); + assert_eq!(all, (json!("93.25"), json!("1660.00"), json!("all"))); + // The statement of members' equity, the same dates: contributions + // 1,660.00 each (the laptop and its reversal cancel), no + // distributions; basis all's received is exactly that. + let me = m.read_only( + "books.export", + json!({"report": "members_equity", "format": "json", "start": "2026-01-01", "end": "2026-12-31"}), + ); + let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); + for who in ["Tobin Harlow", "Morgan Ellis"] { + let col = me["members"] + .as_array() + .unwrap() + .iter() + .find(|c| c["name"] == who) + .unwrap_or_else(|| panic!("{who} in {me}")) + .clone(); + assert_eq!(col["contributions"], totals(&mut m, who, Some("all")).1); + assert_eq!(col["contributions"], "1660.00"); + assert_eq!(col["distributions"], "0.00"); + } + // books.parties echoes the basis. + let list = m.read_only("books.parties", json!({"year": 2026, "basis": "all"})); + assert_eq!(list["basis"], "all"); + let row = list["parties"] + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == "Morgan Ellis") + .unwrap() + .clone(); + assert_eq!( + (row["paid"].clone(), row["received"].clone()), + (json!("93.25"), json!("1660.00")) + ); + assert_eq!(m.read_only("books.parties", json!({}))["basis"], "cash"); + // Any other basis is refused, by both tools. + for (tool, args) in [ + ( + "books.party", + json!({"party": "Tobin Harlow", "basis": "accrual"}), + ), + ("books.parties", json!({"basis": "accrual"})), + ] { + let why = m.refuse(tool, args); + assert!(why.contains("\"cash\" or \"all\""), "{tool}: {why}"); + } +} + +/// Defect 24 over the MCP: `books.update_party` `creditor_of` names the +/// liabilities the company owes a party itself on (journaled as +/// party.creditor_set, the journal's feature declared first), replaces +/// the list, refuses what isn't a liability with nothing journaled; and a +/// fee on the issuer's own card then reads neither, so the issuer is paid +/// what the card payment paid, once. +#[test] +fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() { + let mut m = books("defect24"); + let issuer = json!({"parties": [{"party": "Harbor Bank"}]}); + let fee = m.post( + "2026-05-01", + issuer.clone(), + &[("6400", "debit", "25.00"), ("2050", "credit", "25.00")], + ); + m.post( + "2026-05-20", + issuer.clone(), + &[("2050", "debit", "125.00"), ("1000", "credit", "125.00")], + ); + assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("150.00")); + // Not a liability: refused, nothing journaled (the tool checks). + let why = m.refuse( + "books.update_party", + json!({"party": "Harbor Bank", "creditor_of": ["1000"]}), + ); + assert!(why.contains("liability"), "{why}"); + assert_eq!(m.count("party.creditor_set"), 0); + let before = m.count("journal.format_set"); + let p = m.ok( + "books.update_party", + json!({"party": "Harbor Bank", "creditor_of": ["2050"]}), + ); + assert_eq!(p["creditor_of"], json!(["2050"])); + assert_eq!(m.count("party.creditor_set"), 1); + assert_eq!( + m.count("journal.format_set"), + before + 1, + "the feature declared once" + ); + // The same list again changes nothing. + let journal = m.journal(); + m.ok( + "books.update_party", + json!({"party": "Harbor Bank", "creditor_of": ["2050"]}), + ); + assert!(m.journal() == journal); + assert_eq!( + part(&mut m, fee, "Harbor Bank"), + ( + json!("-25.00"), + json!("neither"), + json!("0.00"), + json!("0.00") + ) + ); + assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("125.00")); + assert_eq!( + totals(&mut m, "Harbor Bank", Some("all")).0, + json!("125.00") + ); + // Both members are owed what the members advanced; then one isn't. + for who in ["Tobin Harlow", "Morgan Ellis"] { + let p = m.ok( + "books.update_party", + json!({"party": who, "creditor_of": ["2100"]}), + ); + assert_eq!(p["creditor_of"], json!(["2100"])); + } + let p = m.ok( + "books.update_party", + json!({"party": "Morgan Ellis", "creditor_of": []}), + ); + assert_eq!(p["creditor_of"], json!([])); + assert_eq!(m.count("party.creditor_set"), 4); + assert_eq!(m.count("journal.format_set"), before + 1); + let p = m.read_only("books.party", json!({"party": "Tobin Harlow"})); + assert_eq!(p["party"]["creditor_of"], json!(["2100"])); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +/// What `books.post_entry` previews is what it posts: each party's +/// `resolved`, `direction`, `cash` and `in_kind` in the preview are +/// `books.get_entry`'s once confirmed (a derived split is then journaled as +/// the amounts it resolved to, the same parts), for a joint contribution +/// of money and property and for a distribution partly still owed. +#[test] +fn the_post_preview_shows_the_parts_it_posts() { + let mut m = books("preview"); + m.accounts(&[("2150", "Distributions Payable", "liability")]); + let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]}); + let tobin = json!({"parties": [{"party": "Tobin Harlow"}]}); + let parts = |v: &Value| -> Vec { + v["parties"] + .as_array() + .unwrap() + .iter() + .map(|p| { + json!([ + p["party"], + p["resolved"], + p["direction"], + p["cash"], + p["in_kind"] + ]) + }) + .collect() + }; + let joint = m.draft( + "2026-03-02", + both, + &[ + ("1000", "debit", "610.00"), + ("1500", "debit", "390.00"), + ("3000", "credit", "500.00"), + ("3001", "credit", "500.00"), + ], + ); + let owed = m.draft( + "2026-03-03", + tobin, + &[ + ("3000", "debit", "300.00"), + ("1000", "credit", "120.00"), + ("2150", "credit", "180.00"), + ], + ); + for (id, want) in [ + ( + joint, + vec![ + json!(["Tobin Harlow", "-500.00", "received", "-305.00", "-195.00"]), + json!(["Morgan Ellis", "-500.00", "received", "-305.00", "-195.00"]), + ], + ), + ( + owed, + vec![json!(["Tobin Harlow", "300.00", "paid", "120.00", "0.00"])], + ), + ] { + let preview = m.read_only("books.post_entry", json!({"entry_id": id})); + assert_eq!(preview["status"], "preview"); + assert_eq!(parts(&preview), want, "entry {id}'s preview"); + m.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + let posted = m.read_only("books.get_entry", json!({"entry_id": id})); + assert_eq!(parts(&posted), want, "entry {id} posted"); + } + let (paid, received, _) = totals(&mut m, "Tobin Harlow", None); + assert_eq!((paid, received), (json!("120.00"), json!("305.00"))); +} diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-mcp/tests/spec_vault.rs index ad2d3e6..1ef76fa 100644 --- a/crates/ledger-mcp/tests/spec_vault.rs +++ b/crates/ledger-mcp/tests/spec_vault.rs @@ -2219,3 +2219,61 @@ fn idempotent_tools_change_nothing_when_repeated() { .collect(); assert!(wrong.is_empty(), "{wrong:#?}"); } + +// ------------------------------------------------- defect 11 (P3) + +/// Defect 11 (audit F28): `books.parties` showed a member's TIN as +/// missing (`tin_last4: null`) because it read W-9 facts only, while +/// `books.party` showed the TIN facts' last four. The directory's last +/// four is the TIN facts', else the W-9 facts' (they never disagree: +/// parties INVARIANT 2a), null with neither, and kept after a purge +/// (the facts keep the last four). +#[test] +fn defect11_the_directory_shows_a_members_tin_last4() { + let mut m = books("defect11"); + m.add_party("Mo", &["member"]); + m.add_party("Nobody In Particular", &[]); + m.ok( + "books.set_tin", + json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}), + ); + assert!( + !m.w9( + "Acme Design", + json!({"tin_last4": "6789", "kept_at": "binder"}) + ) + .refused() + ); + let row = |m: &mut Mcp, name: &str| -> Value { + let list = m.read_only("books.parties", json!({"year": 2026})); + list["parties"] + .as_array() + .unwrap() + .iter() + .find(|p| p["name"] == name) + .unwrap_or_else(|| panic!("{name} in {list}")) + .clone() + }; + let mo = row(&mut m, "Mo"); + assert_eq!( + (mo["tin_last4"].clone(), mo["w9_on_file"].clone()), + (json!("4915"), json!(false)) + ); + assert_eq!( + m.party("Mo", 2026)["party"]["tin"]["last4"], + mo["tin_last4"] + ); + let acme = row(&mut m, "Acme Design"); + assert_eq!( + (acme["tin_last4"].clone(), acme["w9_on_file"].clone()), + (json!("6789"), json!(true)) + ); + assert!(row(&mut m, "Nobody In Particular")["tin_last4"].is_null()); + m.ok( + "books.purge_tin", + json!({"party": "Mo", "reason": "left the company", "confirmed": true}), + ); + assert_eq!(row(&mut m, "Mo")["tin_last4"], "4915"); + let list = m.read_only("books.parties", json!({})); + assert!(!holds_tin(list.to_string().as_bytes(), MEMBER_SSN_DIGITS)); +} diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index 72d1104..2feac58 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -263,7 +263,11 @@ pub fn build_package( } None => { cells.push( - facts.as_ref().map(|f| f.last4.clone()).unwrap_or_default(), + party + .and_then(|id| ledger.get_party(id)) + .and_then(|p| p.tin_last4()) + .unwrap_or_default() + .to_string(), ); cells.push( facts @@ -396,11 +400,10 @@ pub fn build_package( // The payee's full TIN from the source (INVARIANT 2). if let Some(tins) = tins { let party = ledger.party_of_vendor(v.vendor_id); - let last4 = party.and_then(|id| ledger.get_party(id)).and_then(|p| { - p.w9.as_ref() - .map(|f| f.tin_last4.clone()) - .or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) - }); + let last4 = party + .and_then(|id| ledger.get_party(id)) + .and_then(|p| p.tin_last4()) + .map(str::to_string); let got = match party { None => Err("TIN not in the vault: no party for this vendor".into()), Some(id) => tins(id), diff --git a/crates/ledger-package/src/vendor_1099.rs b/crates/ledger-package/src/vendor_1099.rs index 71fec6a..c2d0d6c 100644 --- a/crates/ledger-package/src/vendor_1099.rs +++ b/crates/ledger-package/src/vendor_1099.rs @@ -173,11 +173,10 @@ pub fn vendor_1099_worksheet( let mut lines = Vec::new(); for v in r.vendors { let party_id = ledger.party_of_vendor(v.vendor_id); - let tin_last4 = party_id.and_then(|id| ledger.get_party(id)).and_then(|p| { - p.w9.as_ref() - .map(|f| f.tin_last4.clone()) - .or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) - }); + let tin_last4 = party_id + .and_then(|id| ledger.get_party(id)) + .and_then(|p| p.tin_last4()) + .map(str::to_string); let mut line = Vendor1099WorksheetLine { vendor_id: v.vendor_id, name: v.name, diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index c0b207d..59124d3 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -43,6 +43,7 @@ pub const KNOWN_FEATURES: &[&str] = &[ MEMBERS_CUMULATIVE, FEATURE_ACCOUNT_RENAMED, FEATURE_REQUIRE_TAX_CATEGORY, + PARTY_CREDITORS, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -70,6 +71,10 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> { } } +/// The feature `party.creditor_set` lines need: a party named the +/// creditor of a liability (rules.md B-35, P3 defect 24). +pub const PARTY_CREDITORS: &str = "party_creditors"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -119,6 +124,7 @@ pub const EVENT_TYPES: &[&str] = &[ "open_item.raised", "open_item.resolved", "party.added", + "party.creditor_set", "party.role_set", "party.tin_set", "party.updated", diff --git a/crates/ledger-store/src/reconcilable.rs b/crates/ledger-store/src/reconcilable.rs index ce7e3a0..73aadc7 100644 --- a/crates/ledger-store/src/reconcilable.rs +++ b/crates/ledger-store/src/reconcilable.rs @@ -55,16 +55,11 @@ pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec { .collect() } -/// The card accounts: liabilities whose names contain "card". +/// The card accounts: liabilities whose names contain "card" +/// (`ledger_core::parties::card_accounts`, the same rule party money +/// reads, so the two never disagree on what a card is). pub fn card_accounts(ledger: &Ledger) -> Vec { - ledger - .chart() - .all() - .filter(|a| { - a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card") - }) - .map(|a| a.id) - .collect() + ledger_core::parties::card_accounts(ledger) } /// The accounts the books reconcile monthly (INVARIANT 1). diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 2921ccd..f0f8db1 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1238,6 +1238,30 @@ impl Store { Ok(()) } + /// Name a party the creditor of a liability account the company owes + /// it itself on, or stop (`on` false): ledger_core's + /// `Ledger::set_party_creditor`. Journaled as `party.creditor_set + /// {id, account_id, on}`, after the journal declares the feature it + /// needs ([`crate::format::PARTY_CREDITORS`]) the first time, so an + /// older wanderbooks stops there and says it is too old instead of + /// misreading the books. + pub fn set_party_creditor( + &mut self, + id: ledger_core::PartyId, + account: AccountId, + on: bool, + ) -> Result<(), StoreError> { + self.ledger.check_set_party_creditor(id, account, on)?; + self.ensure_feature(crate::format::PARTY_CREDITORS)?; + let line = self.append_journal( + "party.creditor_set", + json!({"id": id, "account_id": account, "on": on}), + )?; + self.ledger.set_party_creditor(id, account, on)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Record or clear a party's W-9 facts. Journaled as `party.w9_set`. pub fn set_party_w9( &mut self, @@ -4844,6 +4868,13 @@ pub(crate) fn replay_line_into_ledger( .ok_or_else(|| StoreError::JournalCorrupt("party.role_set on".into()))?; ledger.set_party_role(get_u64(p, "id")?, role, on)?; } + "party.creditor_set" => { + let on = p + .get("on") + .and_then(|v| v.as_bool()) + .ok_or_else(|| StoreError::JournalCorrupt("party.creditor_set on".into()))?; + ledger.set_party_creditor(get_u64(p, "id")?, get_u64(p, "account_id")?, on)?; + } "party.w9_set" => { let facts: Option = serde_json::from_value(p["facts"].clone()) .map_err(|_| StoreError::JournalCorrupt("party.w9_set facts".into()))?; @@ -5569,8 +5600,8 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr )?; } "role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set" - | "party.w4_set" | "party.tin_set" | "entry.party_set" | "entry.parties_set" - | "entry.no_party" => { + | "party.w4_set" | "party.tin_set" | "party.creditor_set" | "entry.party_set" + | "entry.parties_set" | "entry.no_party" => { // Control roles and the party directory live in the ledger // (rebuilt by replay); the events mirror below keeps the // history. diff --git a/crates/ledger-store/tests/party_creditors.rs b/crates/ledger-store/tests/party_creditors.rs new file mode 100644 index 0000000..1eebc1a --- /dev/null +++ b/crates/ledger-store/tests/party_creditors.rs @@ -0,0 +1,234 @@ +//! A party named the creditor of a liability (rules.md B-35, P3 defect +//! 24), in the store: journaled as `party.creditor_set {id, account_id, +//! on}`, after the journal declares the feature it needs +//! (`party_creditors`) the first time, and never otherwise. +//! +//! Oracles: `Store::set_party_creditor`'s doc, `format::KNOWN_FEATURES` +//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (nothing journaled +//! when the feature is in force; books that never use it never change), +//! and `Ledger::set_party_creditor`'s SCENARIOS. Expected values by hand. + +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS}; +use ledger_store::journal::read_and_verify_journal; +use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; + +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + let dir = std::env::temp_dir().join(format!("wb-creditors-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +fn journal(dir: &Path) -> Vec<(String, Value)> { + read_and_verify_journal(&dir.join(JOURNAL_FILE)) + .unwrap() + .into_iter() + .map(|l| (l.line_type, l.payload)) + .collect() +} + +fn bytes(dir: &Path) -> Vec { + std::fs::read(dir.join(JOURNAL_FILE)).unwrap() +} + +/// Books with a card, the members' financing account, a member, and a +/// posted entry. +fn books(dir: &Path) -> (Store, u64, u64, u64, u64) { + let mut s = Store::create(dir, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let card = s + .add_account("2050", "Business Card", AccountType::Liability, None, false) + .unwrap(); + let due = s + .add_account( + "2100", + "Due to Members", + AccountType::Liability, + None, + false, + ) + .unwrap(); + let capital = s + .add_account( + "3000", + "Capital — Tobin Harlow", + AccountType::Equity, + None, + false, + ) + .unwrap(); + let e = s + .create_entry(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), "Contribution") + .unwrap(); + let m = Money::new(Decimal::from(500), Currency::USD); + s.add_line(e, cash, Side::Debit, m).unwrap(); + s.add_line(e, capital, Side::Credit, m).unwrap(); + s.post_entry(e).unwrap(); + let issuer = s + .add_party("Harbor Bank", &[ledger_core::Role::FinancialInstitution]) + .unwrap(); + let tobin = s + .add_party("Tobin Harlow", &[ledger_core::Role::Member]) + .unwrap(); + (s, card, due, issuer, tobin) +} + +/// The feature is one this version reads. +#[test] +fn party_creditors_is_a_known_feature() { + assert_eq!(PARTY_CREDITORS, "party_creditors"); + assert!(KNOWN_FEATURES.contains(&PARTY_CREDITORS)); +} + +/// Store::set_party_creditor doc: the first one declares the feature (one +/// journal.format_set keeping the format, at least 2, and every feature +/// already declared), then journals party.creditor_set; later ones journal +/// one line each; the books replay to the same directory, through the +/// store and the viewer's snapshot alike. +#[test] +fn naming_a_creditor_declares_its_feature_once() { + let dir = test_dir("declare"); + let (mut s, card, due, issuer, tobin) = books(&dir); + let before = journal(&dir).len(); + s.set_party_creditor(issuer, card, true).unwrap(); + let j = journal(&dir); + assert_eq!(j.len(), before + 2); + assert_eq!(j[before].0, FORMAT_SET); + assert_eq!( + j[before].1, + json!({"format": 2, "features": ["party_creditors"]}) + ); + assert_eq!( + j[before + 1], + ( + "party.creditor_set".to_string(), + json!({"id": issuer, "account_id": card, "on": true}) + ) + ); + assert_eq!(s.journal_format().features, vec!["party_creditors"]); + s.set_party_creditor(tobin, due, true).unwrap(); + s.set_party_creditor(tobin, due, false).unwrap(); + let j = journal(&dir); + assert_eq!(j.len(), before + 4, "the feature is declared once"); + assert_eq!( + j[before + 3].1, + json!({"id": tobin, "account_id": due, "on": false}) + ); + s.set_party_creditor(tobin, due, true).unwrap(); + let live = s.ledger().clone(); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + assert_eq!(s.ledger().creditors_of(card), vec![issuer]); + assert_eq!(s.ledger().creditors_of(due), vec![tobin]); + assert!(*s.ledger() == live); + drop(s); + let snap = Snapshot::load(&dir).unwrap(); + assert!(*snap.ledger() == live); + assert_eq!(snap.journal_format().features, vec!["party_creditors"]); + // Rebuilt from the journal alone. + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}"))); + } + let s = Store::open(&dir).unwrap(); + assert!(*s.ledger() == live); +} + +/// A refusal journals nothing: not the line, and not the feature either. +#[test] +fn a_refused_creditor_journals_nothing() { + let dir = test_dir("refused"); + let (mut s, _, _, issuer, _) = books(&dir); + let cash = s.ledger().chart().find_by_code("1000").unwrap().id; + let was = bytes(&dir); + for (party, account) in [(issuer, cash), (999, cash), (issuer, 999)] { + assert!(s.set_party_creditor(party, account, true).is_err()); + assert_eq!(bytes(&dir), was); + } + assert!(s.journal_format().features.is_empty()); +} + +/// Books that never name a creditor never declare the feature: opening, +/// verifying and rebuilding them leaves the journal byte-identical, in the +/// format it was in. +#[test] +fn books_without_creditors_are_never_changed_by_the_feature() { + let dir = test_dir("untouched"); + let (s, ..) = books(&dir); + let format = s.journal_format().clone(); + drop(s); + let was = bytes(&dir); + let mut s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + s.rebuild().unwrap(); + assert_eq!(s.journal_format(), &format); + drop(s); + assert_eq!(bytes(&dir), was); + assert!( + journal(&dir) + .iter() + .all(|(t, p)| t != FORMAT_SET || p["features"] == json!([])) + ); +} + +/// A line that can't replay is refused on open, with the database and +/// without it: `on` not a bool, an unknown account, an account that isn't +/// a liability. +#[test] +fn corrupt_creditor_lines_are_refused_on_open() { + for (name, payload) in [ + ( + "on-not-bool", + json!({"id": 1, "account_id": 2, "on": "yes"}), + ), + ( + "unknown-account", + json!({"id": 1, "account_id": 99, "on": true}), + ), + ( + "not-a-liability", + json!({"id": 1, "account_id": 1, "on": true}), + ), + ] { + let dir = test_dir(name); + let (mut s, card, ..) = books(&dir); + // Declare the feature properly first, so only the line is wrong. + let issuer = s.ledger().party_by_name("Harbor Bank").unwrap().id; + s.set_party_creditor(issuer, card, true).unwrap(); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append("party.creditor_set", payload.clone()).unwrap(); + drop(w); + assert!(Store::open(&dir).is_err(), "{name}: {payload} opened"); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}"))); + } + assert!( + Store::open(&dir).is_err(), + "{name}: {payload} opened without the db" + ); + } +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 2db92c8..010bd89 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1940,7 +1940,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) { .map(|_| ()), ); } - let money = ledger_core::parties::money_accounts(l, &cash); + let money = ledger_core::parties::money_accounts(l, &cash, &[]); let party_members: Vec = l .parties() .map(|p| ledger_core::PartyMember { @@ -1953,14 +1953,15 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) { for id in 1..l.next_entry_id() { let Some(e) = l.get_entry(id) else { continue }; tally(ledger_core::parties::entry_flow(e, &money).map(|_| ())); - tally(ledger_core::shares::share_target(e, &money).map(|_| ())); - let _ = ledger_core::shares::resolve(e, l.entry_parties(id), &money, &party_members); + tally(ledger_core::shares::target(l, id, l.entry_parties(id), &money).map(|_| ())); + tally(ledger_core::parties::flow(l, id, l.entry_parties(id), &money).map(|_| ())); + let _ = ledger_core::shares::resolve(l, id, l.entry_parties(id), &money, &party_members); let _ = e.validate(l.chart()); } for p in l.parties() { for (start, end) in spans { let _ = - ledger_core::parties::activity_with(l, p.id, (start, end), &cash, &party_members); + ledger_core::parties::activity_with(l, p.id, (start, end), &money, &party_members); } } for inv in l.invoices() { diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index a217e3c..08c2728 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -856,12 +856,15 @@ texts are at most 500 characters, no control characters. - `books.add_party { name, roles? }` → the party (below). Journaled as `party.added`. - `books.update_party { party, name?, display_name?, website?, address?, - notes?, archived? }` → the party. Give at least one field; `""` clears - `display_name`, `website`, `address`, or `notes`. Every field is checked + notes?, archived?, creditor_of? }` → the party. Give at least one field; + `""` clears `display_name`, `website`, `address`, or `notes`; + `creditor_of` (account codes or ids) replaces the party's list of the + liabilities the company owes it itself (below). Every field is checked (in order, on a rehearsal) before any is written; each is journaled as its own `party.updated`, in the order name, display_name, website, - address, notes, archived. Un-archiving is refused while another party - has the name. + address, notes, archived, then one `party.creditor_set` per account + added or dropped. Un-archiving is refused while another party has the + name. - `books.set_party_roles { party, add?, remove? }` → the party. Adds apply before removes (so a contractor's W-9 survives a swap to landlord); roles it already has, or lacks, are skipped; each change is journaled as @@ -919,22 +922,66 @@ texts are at most 500 characters, no control characters. "require"`, any party (or the no-party mark) satisfies the check. Reversals carry every share. There is no "joint" party: a joint member transaction names both members and lets the shares derive. -- `books.parties { role?, include_archived?, year? }` → `{ year, parties: - [{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file, - tin_last4?, w4_on_file, archived, paid, received }] }`, by id. -- `books.party { party, year? }` → `{ party, year, totals, entries, - invoices, bills, payroll }`: the facts; the calendar year's totals - (default this year); every entry it is on (drafts included, newest - first); its invoices and bills (`{ id, date, due_date, total, paid, - balance, status, entry_id }`); and the payroll lines naming it +- `books.parties { role?, include_archived?, year?, basis? }` → `{ year, + basis, parties: [{ party_id, name, label, roles, vendor_id?, + customer_id?, w9_on_file, tin_last4?, w4_on_file, archived, paid, + received }] }`, by id. `tin_last4` is the TIN's last four when the books + know it: its TIN facts' (`books.set_tin`; a member's SSN for the K-1), + else its W-9 facts' (the two never disagree), null with neither, kept + after `books.purge_tin` (P3 defect 11: it read W-9 facts only, so a + member's showed as missing). +- `books.party { party, year?, basis? }` → `{ party, year, totals, + entries, invoices, bills, payroll }`: the facts; the calendar year's + totals (default this year); every entry it is on (drafts included, + newest first); its invoices and bills (`{ id, date, due_date, total, + paid, balance, status, entry_id }`); and the payroll lines naming it (`{ run_id, date, entry_id, gross_pay, net_pay }`). +- `basis` (both): `"cash"` (the default) or `"all"`; any other word is + refused (`basis is "cash" or "all"`). Echoed in the output. Not the + books' accounting method (`books.accounting_basis`). The party (every mutating party tool returns it): `{ party_id, name, label, display_name?, roles, website?, address?, notes?, archived, vendor_id?, customer_id?, w9?: { received_on, entity_type, exempt_payee_code?, fatca_code?, tin_last4, kept_at, in_vault }, w4?: { received_on, -state, kept_at } }`. `label` is the display name, else the name; `roles` -include vendor and customer when it has those records. +state, kept_at }, tin?, creditor_of }`. `label` is the display name, else +the name; `roles` include vendor and customer when it has those records. +`creditor_of` lists, by code, the liability accounts the company owes the +party itself on: the card it issued, the loan it made, what the members +advanced (each member). `books.update_party { creditor_of: [codes] }` +replaces the list (`[]` clears it), journaled as `party.creditor_set {id, +account_id, on}`, one line per account added or dropped (the journal's +feature `party_creditors` is declared before the first); refused, +journaling nothing, for an account that isn't a liability, is a +placeholder, or is payables or a payroll withholding (owed to many). + +**Each party's part of an entry** (`books.get_entry`, the +`books.post_entry` preview, `books.set_entry_party`: `parties: [{ party_id, +party, share, amount?, percent?, resolved, direction, cash, in_kind }]`, +rules.md B-35): + +- `resolved`: signed as a line on the party's own account would be + (debit-positive): positive, the company paid it or gave it value; + negative, the company received from it. A reversal's is exactly its + original's negated, never read from its own lines. +- `direction`: `paid`, `received` or `neither` (a promise not yet settled: + a bill, an invoice, a fee on the party's own card, a distribution still + owed; or an entry that moved nothing with anyone, like a close). +- `cash`: what of it moved as money; `in_kind`: what moved as property. + Both `"0.00"` for `neither`; `resolved − cash − in_kind` is a promise, + counted on neither basis. All four are null exactly when the shares + don't resolve (`parties_error` says why). +- What the shares split (B-35 item 3): a close, its parties' own-account + movement, neither; a reversal, its original's negated; an entry naming + only members that moves equity, what moved their capital (a + contribution received: the property in it, asset accounts that are + neither cash nor receivables, the K-1 worksheet's test, in kind and the + rest cash; a distribution paid: the money it paid as cash, the property + it gave in kind, the rest still owed, counted when it is paid); money + moved, the money (paid or + received); otherwise the parties' own-account movement (payables, + receivables, a liability owed to them: a bill −, an invoice +), else the + total debits, neither. **Totals** (`books.party` `totals`; `paid` and `received` in `books.parties`), from the journal: @@ -943,24 +990,35 @@ include vendor and customer when it has those records. record (bills and their payments); its customer record's invoices and the payments received on them; and reversals of any of these. Only posted entries dated in the year count. -- *Money accounts:* the `[books] cash_accounts` (every asset account but - receivables when none are set), and every liability account except the - payables and payroll withholding roles (`books.roles`). A liability - counts as how the company paid or was paid: a card, a member paying for - it, a loan. -- `paid` / `received`: each entry's money lines, credits minus debits - (`ledger_core::parties::entry_flow`), with a liability paid down - counting once, as paid to its creditor: by cash (a reimbursement to the - members, a card payment) or by another liability (a member paying the - card from their own funds, a loan paying it off); cash raised by a - liability is received from the lender. A positive net was paid to the - party, a negative one received from it; a reversal counts against its - root original's side (reversing the reversal counts on that side - again). A bill isn't paid until its payment is; a transfer between the - company's own cash accounts nets to zero. An entry naming several - parties counts this party's resolved share only (an entry whose shares - don't resolve counts for none). The year is the whole calendar year; - the viewer's party page and directory show these same numbers. +- *Money accounts* (B-35 item 5, P3 defect 24): the `[books] + cash_accounts` (every asset account but receivables when none are set), + the cards (liability accounts whose names say "card", not "gift card": + the rule the reconciliation reminders read), and the `[books] + financing_accounts` (what the members advanced, loans). Any other + liability (an accrued expense, deferred revenue, gift cards sold, + distributions payable) is a promise, counted when it is settled, never + also when it is booked. A liability owed to a party the entry names (`creditor_of`) + isn't money for that entry: a credit to it never counts as paying the + party. +- `paid` / `received`: each entry's part for the party, added up on its + root original's side (a reversal subtracts there, and reversing the + reversal adds back): on basis `cash`, the `cash` parts; on basis `all`, + `cash` plus `in_kind`. A cash payment, a card charge, a member paying + for the company: paid to the payee then; cash paying one of the money + liabilities down: paid to its creditor (a reimbursement to the members, + a card payment to the issuer); cash raised by one: received from the + lender. A bill or invoice counts on neither basis (a promise is not + payment); a transfer between the company's own cash accounts nets to + zero. An entry naming several parties counts this party's part only (an + entry whose shares don't resolve counts for none). For a member, basis + `all` over the entries that name only members and move their capital + (and the payments of distributions declared to them) is the statement + of members' equity's contributions and distributions, and basis `cash` + their contributions is the K-1 worksheet's cash contributions (B-35 + item 4 gives the conditions: a distribution declared and not yet paid + is in the statement only). The year is the whole + calendar year; the viewer's party page and directory show these same + numbers, labelled "cash basis", with the in-kind figures beside them. - `invoiced` / `billed`: its invoices' and bills' totals dated in the year. `owed` / `receivable`: its open bills' and invoices' balances now (every year). diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index 3d0c638..a48e387 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -77,7 +77,10 @@ Rules (to vendor as B-26 with sources): address, notes, archived), `party.role_set {id, role, on}`, `party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`, `party.tin_set {id, facts|null}` (TIN facts: type, last four, vault -record, when purged), `entry.party_set {entry_id, party_id|null}`, +record, when purged), `party.creditor_set {id, account_id, on}` (P3: the +party is, or stops being, the creditor of a liability the company owes it +itself; the journal declares its feature, `party_creditors`, before the +first one), `entry.party_set {entry_id, party_id|null}`, `entry.parties_set {entry_id, parties: [{party_id, share}]}` (a share is `{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`; `[]` clears). Replayed and projected like @@ -95,9 +98,24 @@ derived. Spec: `ledger_core::shares`. (`set_entry_party`) is one derived share: all of it. `Ledger::entry_party` means "the sole party"; `entry_parties` lists them all. -- **What a share is of:** the entry's `entry_flow` net (what it moved - with its counterparties), or its total debits when the net is zero (a - member contributing property moves no money but is still theirs). +- **What a share is of** (rules.md B-35, P3 defect 14): one signed + target, debit-positive as a line on the parties' own account (positive: + the company paid or gave; negative: it received), with its direction + (paid, received, neither) and what of it was money and what property in + kind. A close: its parties' own-account movement, neither. A reversal: + its original's target negated, never re-read from its own lines. An + entry naming only members that moves equity: what moved their capital. + A contribution is received, the property in it in kind (the K-1 + worksheet's test), the rest cash; a distribution is paid, the money it + paid cash, the property it gave in kind, the rest (declared to + distributions payable, or credited to what the members advanced once + they are named its creditors) still owed, counted when paid. Money moved: the money + (`parties::flow`), all cash. Otherwise its parties' own-account + movement (a bill −, an invoice +), else its total debits: neither, + counted in no total. Read from journaled facts only (the member role, + the chart, `creditor_of`), so leaving `[[company.members]]` never + changes a posted entry's reading. (Before P3 a property contribution + read +500.00, the sign of a payment, and its reversal the same.) - **Amount:** exactly that, positive, in cents; the amounts must sum to what the entry moved. - **Percent:** more than 0, at most 100, summing to exactly 100. Each part @@ -131,19 +149,32 @@ derived. Spec: `ledger_core::shares`. checks only their form; the MCP server checks they resolve once the entry has balanced lines, and `post_entry` refuses (`party_shares`) until they do. Replay never consults settings. -- **Money:** `parties::activity_with` counts each party's resolved share; - an entry moving no money counts nothing; an entry whose shares don't - resolve counts for no one (listed as unresolved). Reversals carry - every share, so a reversal subtracts each party's part; re-tagging - either one (parties, shares, or vendor) re-tags the other, so the pair - always nets to zero. +- **Money:** `parties::activity_with` counts each party's part on two + bases (B-35 item 4): cash (its money: cash, a card, the members' + financing, a loan) and all (adding property contributed or distributed + in kind), the viewer labelling the first "cash basis". Money accounts + are the cash accounts, the cards and `[books] financing_accounts`; any + other liability is a promise, counted once, when settled (P3 defect + 24). A party may be named the creditor of liabilities it is owed itself + (`creditor_of`: a card's issuer, a loan's lender, each member for what + the members advanced); a credit to one never counts as paying it. An + entry that counts nothing (a bill, an invoice, a close) is never + unresolved; an entry whose shares don't resolve counts for no one + (listed as unresolved). Reversals carry every share, so a reversal + subtracts each party's part; re-tagging either one (parties, shares, + or vendor) re-tags the other, so the pair always nets to zero. A reversal counts on its root original's side, so reversing the reversal reinstates the payment as paid (never a negative received). + For a member, basis all over their equity entries is the statement of + members' equity; basis cash their contributions is the K-1 worksheet's + cash contributions. - **One number everywhere:** `books.party`, `books.parties`, and the - viewer's party page and directory read cash as the server does - (`[books] cash_accounts`, else every asset account but receivables) - and count the whole calendar year, so a posted entry dated later this - year counts in both. The viewer shows the year up to the later of today + viewer's party page and directory read money as the server does + (`[books] cash_accounts`, else every asset account but receivables; + the cards; `[books] financing_accounts`) and count the whole calendar + year, so a posted entry dated later this year counts in both. The + directory's TIN last four is the TIN facts', else the W-9 facts' + (`Party::tin_last4`, P3 defect 11), as everywhere the books show one. The viewer shows the year up to the later of today and its last posted entry. - **For the agent:** a joint member transaction (a reimbursement on Due to Shareholders, a contribution or distribution from joint funds) @@ -220,9 +251,13 @@ paid without W-9 facts; a 1099 party paid over the threshold. Money with a party (the owner, 2026-10-15: "how much have I spent on Apple versus how much did I get back"): paid out, received in, and net, for a chosen period (this year, last year, any year, all time), from -`ledger_core::parties::activity`; what it was for (the non-money lines by +`ledger_core::parties::activity_with`, labelled cash basis, with "With +property contributed or distributed: paid $X, received $Y" under them +when anything moved in kind; what it was for (the non-money lines by account); a monthly out-vs-in chart; the 1099 amount for the calendar -year. The directory shows the same columns, sortable. +year. An entry's page says each party's part in words ("received +$740.00, in kind"; "paid $1,480.00 (reversal of #12)"). The directory +shows the same columns, sortable. ## The TIN vault diff --git a/docs/spec/rules.md b/docs/spec/rules.md index f25b0de..54fb04c 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -762,8 +762,11 @@ An entry naming several parties (a payment shared between a contractor and a member; `ledger_core::shares`) counts only the vendor's share toward its 1099: what was paid to the payee (§6041(a)), not what the entry moved for others. The share is resolved (`shares::resolve`) -against the entry's flow, the same target the MCP server checks shares -against, then scaled to the 1099 payment: the share unchanged when the +against the entry's target (B-35: its money, or, when it moved none, its +parties' own-account movement; a reversal's is its original's negated), +the same target the MCP server checks shares against, then scaled to the +1099 payment (a ratio, so B-35's signs change no 1099 figure: a reversal +negates both the payment and the target): the share unchanged when the payment equals the target (the usual case, an expense paid from cash or by a member), else payment × share ÷ target, rounded to the nearest cent, half away from zero (so a part charged to a card scales out, 1099-K). A @@ -1010,7 +1013,13 @@ happen to end in a known last four is refused the same way (filed as a PDF instead). A locked vault or a missing TIN never fails the package: it says which TINs are missing. `books.purge_tin` deletes a -party's vault records and journals that it did. +party's vault records and journals that it did. Wherever the books show a +party's last four (the directory, `books.party`, the 1099 and K-1 +worksheets, the purge preview) it is the TIN facts', else the W-9 +facts' (`Party::tin_last4`; the two never disagree; exactly four digits +or nothing, however the facts were built), kept after a purge +(P3 defect 11: the directory read W-9 facts only, so a member's read as +missing). **Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check, W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`, @@ -1089,9 +1098,10 @@ features already in force (`Store::ensure_feature`): an earlier reader stops at the declaration, and books that never use the feature stay readable by it. P3's: `members_cumulative` (B-13: a members close counted from its count's first close, `"rounding": "cumulative_since"`), -`account_renamed` (before the first `account.renamed`, B-31) and +`account_renamed` (before the first `account.renamed`, B-31), `require_tax_category` (before the first `settings.set` naming -`policy.require_tax_category`, B-32). A later piece asks the owner's +`policy.require_tax_category`, B-32) and `party_creditors` (before the +first `party.creditor_set`, B-35). A later piece asks the owner's go-ahead before the first such declaration (plan D-39). **Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`, @@ -1646,3 +1656,184 @@ appendix: the file line by line and its changes, derived by hand); `iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance`), `mcp_stdio.rs` (`mcp_import_export_over_stdio`, `the_default_period_never_shortens_the_iif_or_the_package`). + +## B-35 A party's part of an entry, and a party's totals + +**Status:** CONVENTION (the sign: B-2's, a line on the party's own +account); DECISION (the owner, 2026-10-15: `entry_flow`, money paid down +counted once; the owner, 2026-10-20, P3 design settled with each review's +recommendation: a member's entry counts what moved their capital, money +is cash, cards, the members' financing and loans, and a liability credit +never counts as paying that liability's own creditor); AUTHORITY for what +a member's capital counts (Treas. Reg. §1.704-1(b)(2)(iv)(b), (c); +Form 1065 Schedule K-1 instructions, Item L: +`sources/irs-capital-accounts.md`) and for a promise not being payment +(Helvering v. Price, 309 U.S. 409 (1940): +`sources/helvering-v-price-1940.md`). Not ASC 230's cash (see item 8) and +not the books' accounting method (`books.accounting_basis`). + +1. **Sign.** A party's part of an entry is signed as a line on the + party's own account would be (debit-positive, B-2): positive, the + company paid it or gave it value; negative, the company received from + it. A bill is negative (owed to the vendor), an invoice positive. +2. **Direction, cash, in kind.** Every part carries its direction (paid, + received, neither) and what of it moved as money (cash) and as property + (in kind); what is neither is a promise still to be settled (a bill, an + invoice, a fee on the party's own card, a distribution still owed) or + moves nothing with anyone (a close), and counts in no total. A part's + cash and in kind have its sign and together never exceed it; what of a + paid part is neither (a distribution partly still owed) is a promise, + counted when it is paid. +3. **What the shares split** (`shares::target`), in order: + 1. A period's closing entry: its parties' own-account movement (the + members' equity, when only members are named), else its total + debits; neither (B-11, B-13: a close allocates income, it moves + nothing with anyone). + 2. A reversal: its original's target, negated, paid and received + swapped, from the root original down (B-9: "a mistake is corrected + by an opposite entry"); never re-read from its own lines, because + `entry_flow` isn't odd when one liability pays down another (a + member paying the card from their own funds, reversed, read as a + second payment: P3 design §1.4). + 3. Every party named has the member role in the directory and the + entry moves equity other than retained earnings (E): E, a + contribution received (E < 0) or a distribution paid (E > 0). + - Of a contribution, in kind is the property in it, at most |E|: + what asset accounts that are neither cash nor receivables took in + (the K-1 worksheet's test, k1 INVARIANT 3), so property + contributed subject to a loan is the member's net property + (§1.704-1(b)(2)(iv)(b)(2)). The rest is cash, as the K-1 counts + it: money, or what the member paid for the company (an expense + booked straight to capital, a debt to them converted, + §1.704-1(b)(2)(iv)(c)(1)). + - Of a distribution, cash is the money it paid (case 4's net, at + most E: §1.704-1(b)(2)(iv)(b)(4)) and in kind the property it + gave up, at most what is left, as the books carry it (the + statement's figure; (b)(5) counts its fair market value, which + stays with the CPA). The rest, credited to a liability that + isn't money for the entry (distributions payable, or what the + members advanced once they are named its creditors), is still + owed: it counts as nothing until it is paid (Helvering v. Price; + before the P3 review a distribution declared to distributions + payable counted when declared and again when paid, and a + distribution of money with depreciated property read the money + as property). + Read from journaled facts only (roles, the chart, creditors), so a + member leaving `[[company.members]]` never changes a posted entry; + the settings' members matter only to a derived split among several, + which the server journals as amounts when the entry posts. + 4. Money moved (`parties::entry_flow` net, item 5's accounts less the + liabilities owed to a party named): the net, paid (+) or received + (−), all of it cash. + 5. Otherwise its parties' own-account movement (payables, receivables, + a liability owed to a party named), else the total debits; neither. + The parts split the target as `shares` INVARIANTS 7-12 say: a + reversal's parts are exactly its original's negated, and a member's in + kind is shared as the K-1 worksheet shares property (in proportion, + leftover cents by largest remainder, ties to the member listed first). +4. **Totals: `basis` cash (the default) or all, always labelled.** Cash + counts each part's cash; all adds what moved in kind (Item L counts + money and property alike). Each part counts on its root original's + side, so a reversal nets what it reverses, and reversing the reversal + brings it back. A bill or invoice counts on neither basis (a promise is + not payment: Helvering v. Price); `billed`, `invoiced`, `owed` and + `receivable` report those. An entry that counts nothing is never + unresolved (its target is found before its split). For a member, over + the entries that name only members and move their capital, plus the + payments of distributions declared to them: basis all's received and + paid are the statement of members' equity's contributions and + distributions for the same dates, and basis cash's received is the + K-1 worksheet's cash contributions, when + - each such entry names exactly the members whose capital it moves + (derived from their capital lines, or amounts equal to them) and + moves every one of them the same way (capital moved from one member + to another moves nothing with the company: neither, though the + statement shows a distribution and a contribution); + - no distribution declared in the span is still unpaid at its end (the + statement counts it when declared, party totals when paid); and + - no reversal in the span undoes an entry outside it (the statement + then counts the reversal by its side; party totals net it on the + original's side). + + Over any span at all, even one a reversal straddles, received less + paid still agrees with contributions less distributions, given the + first two conditions (the property + `member_totals_are_the_members_equity_statement` checks every span). +5. **Money** (P3 defect 24): the cash accounts (`[books] cash_accounts`; + every asset account but receivables when none are set), the cards + (liability accounts whose names say "card" but not "gift card", the + rule the reconciliation reminders read, until payment instruments name + them), and `[books] financing_accounts` (what the members advanced, + loans). Every other liability (payables, the payroll withholdings, an + accrued expense, deferred revenue, gift cards the company sold) is a + promise: counted when it is settled, never also when it is booked + (before P3 an accrual through any liability but payables and payroll + counted twice). So a card account whose name doesn't say "card" is + not money until it is listed in `financing_accounts` or an instrument + names it, and with `financing_accounts` unset what the members + advanced is not money either: a purchase through either then counts + for no one until it is paid off (the handbook tells the agent to list + them). +6. **A party's own liabilities.** A party may be named the creditor of + the liabilities the company owes it itself (`creditor_of`: a card's + issuer, a loan's lender, each member for what the members advanced; + `party.creditor_set`, a journal feature). For an entry naming it, a + credit to one never counts as paying it (a fee on its card, interest on + its loan, equipment on the seller's note: neither, until paid; a + distribution credited to the members' financing: still owed), and a + debit to one is what the money paid for (a card payment, a + reimbursement: paid, as before). +7. **Known limits**, stated in the tools' text: an entry that both repays + a member and distributes to them counts the distribution only; money a + member lends under their own name through the members' financing and + later converts to capital counts both times (received as a loan, then + as a contribution), as does a distribution declared through it while + the members aren't named its creditors (paid when declared, and again + when paid); an entry naming a payee together with the creditor of the + liability that paid it (a vendor and the member who paid it through + the members' financing) reads neither for both; an expense a member + paid that is booked straight to capital and tagged with the vendor + reads neither (the vendor isn't counted as paid; tagged with the + member, it is the member's cash contribution). Record them apart, and + a member's payment for the company through the members' financing, + tagged with the payee: the house conventions already do. +8. **How party totals relate to the cash flow statement** (B-14, D-42, + P3 defect 1). The statement reports the company's cash, ASC 230's: a + member paying a vendor for the company is cash borrowed from the member + and spent, on that date (a financing inflow and an operating or + investing outflow); paying the member back is a financing outflow; + property a member contributes, and a debt to them converted to + capital, are noncash (disclosed). Party totals report what moved with + each party: the vendor is paid when the member pays it (the same date + and amount as the statement's outflow); the member is paid back when + the statement shows the financing outflow; and a member's capital + counts when it moves, a conversion as the cash contribution the K-1 + calls it (the money they paid for the company), property in kind; a + distribution counts when it is paid, as on the statement. Where both + show a payment they agree on its amount and date. The statement shows + more than party totals do in two places: the financing inflow from a + member who pays a vendor is in no party's totals (the entry names the + vendor; the member's totals show only the repayment), and an expense + booked straight to capital and tagged with the vendor isn't counted + as paid to the vendor (item 7). "Cash" in party totals is money + through the company's cash accounts, cards and financing (and, on a + member's contribution, the K-1's cash), not the statement's ASC 230 + cash. + +**Enforced:** `kernel::party_target`, `kernel::reverse_target`, +`shares::{target, split, resolve}`, `parties::{flow, money_accounts, +card_accounts, MoneyAccounts::for_parties, activity_with, Activity::totals, +Basis}`, `Ledger::set_party_creditor`, `Store::set_party_creditor` (the +`party_creditors` journal feature, declared before its first line); +`ledger-mcp` `books.get_entry` (`resolved`, `direction`, `cash`, +`in_kind`), `books.party` and `books.parties` (`basis`), `books.update_party` +(`creditor_of`); the viewer's party pages and entry page. +**Checked:** `ledger-core` `tests/spec_party_totals.rs` (the audit's +table, the members' equity and K-1 identities over every span, the +reversed card payment, accruals, a gift card, a party's own liabilities, +distributions declared or of depreciated property, the properties), Kani +`party_target_direction_is_its_sign`, `party_target_is_odd`, +`reverse_target_is_an_involution`, bridge `party_target_agrees`; Lean +`reverseTarget_involutive`, `capitalTarget_odd`, `partyTarget_odd`, +`sumOver_movement_reversal`; `ledger-store` `tests/party_creditors.rs`; +`ledger-mcp` `tests/spec_party_totals.rs`; `ledger-gui` `tests/parties.rs`. diff --git a/docs/spec/sources/helvering-v-price-1940.md b/docs/spec/sources/helvering-v-price-1940.md new file mode 100644 index 0000000..99901d4 --- /dev/null +++ b/docs/spec/sources/helvering-v-price-1940.md @@ -0,0 +1,11 @@ +# A promise to pay is not payment: Helvering v. Price, 309 U.S. 409 (1940) +Governs why a party's totals never count a promise as a payment (rules.md B-35): a bill, a fee charged on the party's own card, equipment bought on the seller's own note, or a distribution still owed counts when it is paid, not when it is promised. The Supreme Court held that a taxpayer reporting on the cash basis who gave his own note, even a secured one, had not paid. Opinions of the Supreme Court are works of the United States Government (`us-public-domain-status.md`). + +## Helvering v. Price, 309 U.S. 409, 412-413 (1940) +Source: (United States Reports, Library of Congress) +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`usrep309409.pdf`): `ac58d7cb0fbe1257c7ad5e75152e540544194c6accabe261b02068f6cebd7efe` + +> As the return was on the cash basis, there could be no deduction in the year 1932, unless the substitution of respondent's note in that year constituted a payment in cash or its equivalent. There was no cash payment and under the doctrine of the Eckert case the giving of the taxpayer's own note was not the equivalent of cash to entitle the taxpayer to the deduction. + +> Respondent urges that his note was secured, but the collateral was not payment. It was given to secure respondent's promise to pay, and if that promise to pay was not sufficient to warrant the deduction until the promise was made good by actual payment, the giving of security for performance did not transform the promise into the payment required to constitute a deductible loss in the taxable year. diff --git a/docs/spec/sources/irs-capital-accounts.md b/docs/spec/sources/irs-capital-accounts.md new file mode 100644 index 0000000..b05f616 --- /dev/null +++ b/docs/spec/sources/irs-capital-accounts.md @@ -0,0 +1,35 @@ +# Partners' capital accounts: what a member's money and property count as +Governs what a member's party totals count (rules.md B-35): a member's capital account rises by the money and the property (net of liabilities on it) the member contributes, and falls by the money and property distributed to them; a partnership liability the member pays for it is money the member contributed. The K-1's capital account analysis (Item L) reports the same contributions and distributions. wanderbooks counts these from the books as they are; the tax-basis figures stay with the CPA. + +## Treas. Reg. §1.704-1(b)(2)(iv)(b): basic rules of capital accounts +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr7041.html`): `0eafa6f27d7df3a3cc93ebad7cbdc0c37a49b055c53a43d5e5e8efa29f80beeb` + +> (b) Basic rules. Except as otherwise provided in this paragraph (b)(2)(iv), the partners' capital accounts will be considered to be determined and maintained in accordance with the rules of this paragraph (b)(2)(iv) if, and only if, each partner's capital account is increased by + +> (1) the amount of money contributed by him to the partnership, + +> (2) the fair market value of property contributed by him to the partnership (net of liabilities that the partnership is considered to assume or take subject to), and + +> (3) allocations to him of partnership income and gain (or items thereof), including income and gain exempt from tax and income and gain described in paragraph (b)(2)(iv)(g) of this section, but excluding income and gain described in paragraph (b)(4)(i) of this section; and is decreased by + +> (4) the amount of money distributed to him by the partnership, + +> (5) the fair market value of property distributed to him by the partnership (net of liabilities that such partner is considered to assume or take subject to), + +## Treas. Reg. §1.704-1(b)(2)(iv)(c): treatment of liabilities +Source: as above (the same retrieved file). + +> (c) Treatment of liabilities. For purposes of this paragraph (b)(2)(iv), + +> (1) money contributed by a partner to a partnership includes the amount of any partnership liabilities that are assumed by such partner (other than liabilities described in paragraph (b)(2)(iv)(b)(5) of this section that are assumed by a distributee partner) but does not include increases in such partner's share of partnership liabilities (see section 752(a)), and + +> (2) money distributed to a partner by a partnership includes the amount of such partner's individual liabilities that are assumed by the partnership (other than liabilities described in paragraph (b)(2)(iv)(b)(2) of this section that are assumed by the partnership) but does not include decreases in such partner's share of partnership liabilities (see section 752(b)). + +## Partner's Instructions for Schedule K-1 (Form 1065), Item L +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`i1065sk1.html`): `87d46c0e6c07eb0b642e9479f72aed7c35ed005cab0b1c644af5572a9df22d7c` + +> The partnership must report your beginning capital account and ending capital account for the year using the tax-basis method, including the amount of capital you contributed to the partnership during the year, your share of the partnership’s current-year net income or loss as computed for tax purposes, any withdrawals and distributions made to you by the partnership, and any other increases or decreases to your capital account determined in a manner generally consistent with figuring the partner’s adjusted tax basis in its partnership interest (without regard to partnership liabilities), taking into account the rules and principles of sections 705, 722, 733, and 742. diff --git a/docs/testing.md b/docs/testing.md index 495eeb1..34fcb94 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1237,6 +1237,55 @@ range, `export_journal`'s period, `described`, `uncategorized_warning`, `handbook::period_rule`. The viewer is not mutated (owner, 2026-10-18): `view_model.rs` and `show.rs` cover it. +## P3: parties (defects 14, 24, 11; 2026-10-20) + +A party's part of an entry is signed as a line on its own account, with a +direction and its money and property; party totals take a basis; money is +cash, cards and the financing accounts; a credit to a liability a party is +owed itself never pays it; the directory's TIN last four comes from TIN or +W-9 facts (rules.md B-35, B-26). Built, reviewed and checked under D-33: +no mutation run here; what P12 must cover is at the end. + +| What | Where | Oracle | +|---|---|---| +| The audit's table (F36, F37), row by row: resolved, direction, cash, in kind; reversals the exact opposite; bills and invoices signed; totals on both bases | `ledger-core/tests/spec_party_totals.rs` `defect14_shares_take_the_partys_side_and_reversals_flip` | B-35, by hand | +| Basis all is the members' equity statement, cash the K-1's cash contributions | `basis_all_is_the_members_equity_statement_and_cash_the_k1s`; property `member_totals_are_the_members_equity_statement` (members contributing and taking out money and property, alone and jointly, subject to a loan, through 2100 then converted, through their distributions accounts, depreciated property with money, declared to distributions payable and paid later, reversed later in the year; checked over every span from the year's start and, received less paid, over every span) | `reports::members_equity`, `k1::k1_detail` | +| A reversed member card payment nets to zero (brief §1.4) | `defect14_a_reversed_member_card_payment_nets_to_zero` | B-9 | +| Accruals through another liability count once (defect 24) | `defect24_an_accrual_through_another_liability_counts_once` | B-35 item 5 | +| A credit to a party's own liability never pays it: a card fee, a distribution owed, a seller's note, property subject to a loan | `defect24_a_credit_to_a_partys_own_liability_never_pays_it`; the documented limit, pinned: `a_distribution_owed_counts_twice_until_the_members_are_named_its_creditors` | B-35 item 6; Treas. Reg. §1.704-1(b)(2)(iv)(b)(2); Helvering v. Price | +| Review, 2026-10-21: a distribution declared to distributions payable counts once, when paid (it counted twice); a distribution of money and depreciated property splits by what moved (the money read as property); a gift card sold is received once (it was a "card": twice); a refund is received; tin_last4 never shows more than four digits | `defect24_a_distribution_declared_to_a_payable_counts_once_when_paid`, `a_distribution_of_money_and_depreciated_property_splits_by_what_moved`, `defect24_a_gift_card_sold_is_received_once`, `a_refund_is_received_from_the_vendor`, `defect11_a_partys_tin_last4_is_its_tin_facts_else_its_w9s` (SCENARIO 6) | B-35 items 3, 5; B-26 | +| A mixed contribution splits as the K-1 does, ties to the first member | `a_members_mixed_entry_splits_as_the_k1_does` | k1 INVARIANTS 3, 4 | +| Read from journaled facts: an archived member, a one-member company | `a_members_entry_keeps_its_reading_after_they_leave` | review R2 | +| A close is neither; a basis is cash or all; creditor_of's checks; tin_last4's five scenarios; the kernel's cases | `a_close_tagged_with_its_members_is_neither`, `a_basis_is_cash_or_all`, `a_creditor_is_named_only_on_a_liability_with_one_owner`, `defect11_a_partys_tin_last4_is_its_tin_facts_else_its_w9s`, `party_target_cases_by_hand` | specs | +| Properties: a reversal's target, parts and flow are the original's negated; parts sum to the target, each with its sign; a sole party's money part is the flow as before P3; tin_last4 is every source present | `a_reversal_is_the_exact_opposite`, `a_sole_partys_money_part_is_the_flow_as_before`, `tin_last4_is_every_source_present` (`PROPTEST_CASES`, default 256; 5,000 run clean) | B-35, B-9 | +| Decimal and i64 agree on party_target and reverse_target | `kernel_bridge.rs` `party_target_agrees` | the bridge | +| Kani: the direction is the sign, the target is odd, reverse_target an involution | `proofs.rs` `party_target_direction_is_its_sign`, `party_target_is_odd`, `reverse_target_is_an_involution` | the kernel spec | +| The journal: party.creditor_set after its feature, declared once; refusals journal nothing; books without creditors never change; corrupt lines refused | `ledger-store/tests/party_creditors.rs` | the format gate | +| Over the MCP: get_entry's direction, cash, in_kind; party totals by basis, echoed, others refused; basis all equals members' equity; creditor_of round trips and a card fee isn't a payment; the post_entry preview's parts are what it posts | `ledger-mcp/tests/spec_party_totals.rs` | the tool docs | +| Defect 11 over the MCP: a member's TIN last four in the directory, a W-9's, none, after a purge | `ledger-mcp/tests/spec_vault.rs` `defect11_the_directory_shows_a_members_tin_last4` | B-26 | +| The viewer shows the tools' numbers, labelled cash basis, with the in-kind line; its what-for reads a reversal as `parties::flow` does | `ledger-gui/tests/parties.rs` `the_party_page_shows_the_basis_and_in_kind`, `a_reversed_member_card_payment_is_nothing_on_the_issuers_page` | `activity_with`, `flow` | +| bookspec | `bookspec/examples/p3-party-*.toml` | the same cases, by hand | + +**For P12 (mutation targets, none run, D-33):** `kernel::party_target` +(each branch, the `!= 0` and `< 0` tests, the property and money clamps, +the neither-when-nothing-counts test), `kernel::reverse_target` and +`Direction::{flip, of}`; `shares::target` (the closing check, the reversal +arm and its parity, members_only, the equity filter and the retained +earnings exclusion, the property test, the own sums), +`shares::split` (the member-order tie-break, the cash shortcut, the zero +sign); `parties::{flow, root_original, money_accounts, card_accounts, +MoneyAccounts::for_parties}` (the negation of what_for, the walk's +guard, cards and financing as money, the gift-card exclusion); `activity_with` (the target before +the split, the skip of nothing-counts, the bucket by side and basis, the +overflow checks); `Activity::totals`; `Basis::parse`; +`Ledger::{set_party_creditor, check_set_party_creditor, creditors_of, +is_closing_entry}`; `Store::{set_party_creditor, ensure_feature}`; the +server's `parse_basis`, `party_activity`, `entry_parties_out` (direction, +cash, in_kind) and `update_party`'s creditor_of diff; the viewer's +`party_what_for` and `share_text`. Expected equivalent: +`Party::tin_last4`'s `or_else` order swapped (TIN facts and W-9 facts +never disagree, parties INVARIANT 2a, enforced on replay). + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/docs/verification.md b/docs/verification.md index 7924e54..32a4cae 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -256,6 +256,32 @@ their numbers are assigned when P3 lands: year, closed and never closed), the viewer's `a_members_page_shows_the_same_capital_before_and_after_a_close` +### Parties + +19. **A party's part of an entry is signed as its own account would be, + with a direction that is its sign**: paid exactly when what counts is + positive, received when negative, neither exactly when nothing counts; + the money and property in it each carry the whole's sign and never + exceed it; a money target is all cash; a contribution counts all of + itself; a distribution counts no more money than it paid and no more + property than it gave, the rest still owed (rules.md B-35). + - Kani `party_target_direction_is_its_sign` · bridge + `party_target_agrees` +20. **A reversal is the exact opposite of what it reverses**: the target + is odd (negating the lines' capital movement, money and own-account + movement gives the original's reverse, but for the debits fallback and + a member's entry whose cash isn't exactly its money: an expense booked + straight to capital, a debt converted, a distribution still owed; the + engine never reads those off a reversal's lines: its reversal arm + takes the original's target), reversing twice gives the original + back, and a reversal's movement over any accounts is the original's + negated (B-9). + - Kani `party_target_is_odd`, `reverse_target_is_an_involution` · + Lean `partyTarget_odd`, `capitalTarget_odd` (with `cashIsMoney` + shown satisfiable), `reverseTarget_involutive`, + `sumOver_movement_reversal` · property + `a_reversal_is_the_exact_opposite` + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index 20bbd5c..cf3d1ab 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -67,3 +67,7 @@ open Wanderbooks #print axioms cashflow_reversal_nets #print axioms Reachable.cashflow_foots #print axioms Reachable.cashflow_is_change_in_cash +#print axioms reverseTarget_involutive +#print axioms capitalTarget_odd +#print axioms partyTarget_odd +#print axioms sumOver_movement_reversal diff --git a/verify/lean/Wanderbooks.lean b/verify/lean/Wanderbooks.lean index 4d98486..8ab0c43 100644 --- a/verify/lean/Wanderbooks.lean +++ b/verify/lean/Wanderbooks.lean @@ -4,3 +4,4 @@ import Wanderbooks.Statements import Wanderbooks.Closing import Wanderbooks.Ledger import Wanderbooks.Apportion +import Wanderbooks.Parties diff --git a/verify/lean/Wanderbooks/Parties.lean b/verify/lean/Wanderbooks/Parties.lean new file mode 100644 index 0000000..7d8517e --- /dev/null +++ b/verify/lean/Wanderbooks/Parties.lean @@ -0,0 +1,215 @@ +import Wanderbooks.Entry + +/-! +# A party's part of an entry (rules.md B-35) + +Mirrors `kernel::party_target` and `kernel::reverse_target`, and proves +what makes a reversal the exact opposite of what it reverses: the target +is odd (but for the debits fallback, where a sign means nothing, and a +member's entry whose cash isn't its money: an expense booked straight to +capital, a debt converted, a distribution still owed), reversing a target +twice gives it back, and a reversal's movement over any set of accounts +(a member's equity, say) is the original's negated. +-/ + +namespace Wanderbooks + +/-- Which way an entry moved value with its parties (`kernel::Direction`). -/ +inductive Direction where + | paid + | received + | neither + deriving DecidableEq, Repr + +/-- Paid and received swap; neither stays neither. -/ +def Direction.flip : Direction → Direction + | .paid => .received + | .received => .paid + | .neither => .neither + +@[simp] theorem Direction.flip_flip (d : Direction) : d.flip.flip = d := by + cases d <;> rfl + +/-- The direction of a signed amount (`Direction::of`). -/ +def Direction.of (a : Int) : Direction := + if 0 < a then .paid else if a < 0 then .received else .neither + +theorem Direction.of_neg (a : Int) : Direction.of (-a) = (Direction.of a).flip := by + unfold Direction.of + by_cases h1 : 0 < a + · rw [ite_eq_right (show ¬ (0 < -a) by omega), ite_eq_left (show -a < 0 by omega), ite_eq_left h1]; rfl + · by_cases h2 : a < 0 + · rw [ite_eq_left (show 0 < -a by omega), ite_eq_right h1, ite_eq_left h2]; rfl + · rw [ite_eq_right (show ¬ (0 < -a) by omega), ite_eq_right (show ¬ (-a < 0) by omega), ite_eq_right h1, + ite_eq_right h2]; rfl + +/-- `kernel::PartyTarget`. -/ +structure PartyTarget where + amount : Int + direction : Direction + cash : Int + inKind : Int + deriving DecidableEq, Repr + +/-- `kernel::reverse_target`: every amount negated, paid and received +swapped. -/ +def reverseTarget (t : PartyTarget) : PartyTarget := + ⟨-t.amount, t.direction.flip, -t.cash, -t.inKind⟩ + +/-- **Reversing a reversal gives the original back** (B-9). -/ +theorem reverseTarget_involutive (t : PartyTarget) : + reverseTarget (reverseTarget t) = t := by + cases t; simp [reverseTarget] + +/-- A magnitude. -/ +def magnitude (e : Int) : Int := if e < 0 then -e else e + +theorem magnitude_neg (e : Int) : magnitude (-e) = magnitude e := by + unfold magnitude + by_cases h : e < 0 + · rw [ite_eq_right (show ¬ (-e < 0) by omega), ite_eq_left h] + · by_cases h' : -e < 0 + · rw [ite_eq_left h', ite_eq_right h]; omega + · rw [ite_eq_right h', ite_eq_right h]; omega + +/-- The magnitude `m` with the sign of `like`. -/ +def withSign (like m : Int) : Int := if like < 0 then -m else m + +theorem withSign_neg (e m : Int) (he : e ≠ 0) : withSign (-e) m = -withSign e m := by + unfold withSign + by_cases h : e < 0 + · rw [ite_eq_right (show ¬ (-e < 0) by omega), ite_eq_left h]; omega + · rw [ite_eq_left (show -e < 0 by omega), ite_eq_right h] + +/-- What of a member's capital movement `e` counts, as (cash, in kind) +(`kernel::party_target` INVARIANTS 1-2): of a contribution (`e < 0`), the +property in it, at most `|e|`, in kind and the rest cash; of a +distribution, the money it paid (`net`, at most `e`) as cash, then the +property it gave, at most what is left, in kind. The rest of a +distribution is still owed and counts as nothing. -/ +def capitalParts (e property net : Int) : Int × Int := + if e < 0 then + (magnitude e - min (max property 0) (magnitude e), min (max property 0) (magnitude e)) + else + (min (max net 0) (magnitude e), + min (max property 0) (magnitude e - min (max net 0) (magnitude e))) + +/-- The capital case of `kernel::party_target` (a member's entry moving +equity `e ≠ 0`), signed as `e`; neither when nothing counts. -/ +def capitalTarget (e property net : Int) : PartyTarget := + ⟨e, if (capitalParts e property net).1 + (capitalParts e property net).2 = 0 then .neither + else Direction.of e, + withSign e (capitalParts e property net).1, withSign e (capitalParts e property net).2⟩ + +/-- The rest of `kernel::party_target`: money moved, else the parties' +own-account movement, else the debits; neither moves nothing. -/ +def moneyTarget (net own debits : Int) : PartyTarget := + if net ≠ 0 then ⟨net, Direction.of net, net, 0⟩ + else ⟨if own ≠ 0 then own else debits, .neither, 0, 0⟩ + +/-- `kernel::party_target`. -/ +def partyTarget (capital : Option (Int × Int)) (net own debits : Int) : PartyTarget := + match capital with + | some (e, p) => if e ≠ 0 then capitalTarget e p net else moneyTarget net own debits + | none => moneyTarget net own debits + +/-- A member's entry whose cash is exactly its money: the money moved the +member's way (paid out on a distribution, received on a contribution) is +what isn't property. Not so for an expense booked straight to capital, a +debt converted, or a distribution still owed: the reversal arm, never the +lines, gives their reversals a target. -/ +def cashIsMoney (e property net : Int) : Prop := + min (max (if 0 < e then net else -net) 0) (magnitude e) = + magnitude e - min (max property 0) (magnitude e) + +/-- Not vacuous: a cash contribution, a property contribution, and a +distribution of money and property each have their cash as their money. -/ +example : cashIsMoney (-100) 0 (-100) ∧ cashIsMoney (-100) 100 0 ∧ cashIsMoney 100 40 60 := by + unfold cashIsMoney magnitude + decide + +/-- Negating a member's entry whose cash is its money keeps what counts. -/ +theorem capitalParts_neg (e p net : Int) (he : e ≠ 0) (h : cashIsMoney e p net) : + capitalParts (-e) p (-net) = capitalParts e p net := by + unfold cashIsMoney at h + unfold capitalParts + rw [magnitude_neg] + by_cases hn : e < 0 + · rw [ite_eq_right (show ¬ (0 < e) by omega)] at h + rw [ite_eq_right (show ¬ (-e < 0) by omega), ite_eq_left hn, h] + congr 1 + omega + · rw [ite_eq_left (show 0 < e by omega)] at h + rw [ite_eq_left (show -e < 0 by omega), ite_eq_right hn, h] + congr 1 + omega + +/-- **A member's capital target is odd** whenever its cash is its money. -/ +theorem capitalTarget_odd (e p net : Int) (he : e ≠ 0) (h : cashIsMoney e p net) : + capitalTarget (-e) p (-net) = reverseTarget (capitalTarget e p net) := by + unfold capitalTarget reverseTarget + rw [capitalParts_neg e p net he h, withSign_neg _ _ he, withSign_neg _ _ he] + congr 1 + by_cases hc : (capitalParts e p net).1 + (capitalParts e p net).2 = 0 + · simp [hc, Direction.flip] + · simp [hc, Direction.of_neg] + +/-- With money moved, or the parties' own accounts moved, the rest of the +target is odd too. -/ +theorem moneyTarget_odd (net own debits : Int) (h : net ≠ 0 ∨ own ≠ 0) : + moneyTarget (-net) (-own) debits = reverseTarget (moneyTarget net own debits) := by + unfold moneyTarget reverseTarget + by_cases hn : net = 0 + · have ho : own ≠ 0 := by omega + have : ¬ (-own = 0) := by omega + simp [hn, ho, this, Direction.flip] + · have : ¬ (-net = 0) := by omega + simp [hn, this, Direction.of_neg] + +/-- **The target is odd** (rules.md B-35 item 3, B-9): negating an entry's +lines negates its capital movement, its money net and its own-account +movement, while the property beside a capital movement comes from the +other side of the lines and keeps its size; the result is the original's +`reverseTarget`, but for the debits fallback (nothing moved: a sign means +nothing there) and a member's entry whose cash isn't its money. The +engine's reversal arm, not the lines, gives every reversal its target. -/ +theorem partyTarget_odd (capital : Option (Int × Int)) (net own debits : Int) + (h : (∃ e p, capital = some (e, p) ∧ e ≠ 0 ∧ cashIsMoney e p net) ∨ + ((∀ e p, capital = some (e, p) → e = 0) ∧ (net ≠ 0 ∨ own ≠ 0))) : + partyTarget (capital.map fun c => (-c.1, c.2)) (-net) (-own) debits = + reverseTarget (partyTarget capital net own debits) := by + cases capital with + | none => + simp only [Option.map, partyTarget] + apply moneyTarget_odd + rcases h with ⟨_, _, hc, _⟩ | ⟨_, h⟩ + · cases hc + · exact h + | some c => + obtain ⟨e, p⟩ := c + simp only [Option.map, partyTarget] + by_cases he : e = 0 + · have : ¬ (-e ≠ 0) := by omega + simp only [he, ne_eq, not_true_eq_false, ite_false, Int.neg_zero] + apply moneyTarget_odd + rcases h with ⟨e', p', hc, he', _⟩ | ⟨_, h⟩ + · simp at hc; omega + · exact h + · have : -e ≠ 0 := by omega + simp only [ne_eq, he, this, not_false_eq_true, ite_true] + rcases h with ⟨e', p', hc, _, hm⟩ | ⟨hz, _⟩ + · simp only [Option.some.injEq, Prod.mk.injEq] at hc + obtain ⟨rfl, rfl⟩ := hc + exact capitalTarget_odd e p net he hm + · exact absurd (hz e p rfl) he + +/-- **A reversal's movement over any accounts is the original's negated** +(so the equity movement a member's target reads flips with it): summed +over a set of accounts, `movement_reversal` account by account. -/ +theorem sumOver_movement_reversal (as : List Nat) (ls : List Line) : + sumOver (fun a => movement a (reversal ls)) as = -sumOver (fun a => movement a ls) as := by + have : (fun a => movement a (reversal ls)) = (fun a => -movement a ls) := by + funext a; exact movement_reversal a ls + rw [this, sumOver_neg] + +end Wanderbooks