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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 12:12:26 -06:00
1 parent 9ab003d0d1
commit c2ceabbae9
52 files changed
+7535 -560

No files matched your search

+56 -20
View File
@@ -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. declares `largest-remainder`, the rule a members close splits by.
Required everywhere: the top-level keys, `[setup]` with its four keys and Required everywhere: the top-level keys, `[setup]` with its four keys and
its accounts, the events, and at least one `[[expect]]` with values: a its accounts, the events (`events = []`, before `[setup]`, when nothing is
figure query's are exact decimal strings (`"2340.00"`, `"-12.50"`; never a posted), and at least one `[[expect]]` with values: a figure query's are
float), a format query's are text (below). Amounts on lines are positive; 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. the side gives the direction.
### Optional keys, for the rules that need them ### 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`, - `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 in the company's time zone: what an omitted date means (B-30). A query
that omits a date needs it. that omits a date needs it.
- `cash_accounts`, `financing_accounts`, `investing_accounts`: arrays of - `cash_accounts`, `financing_accounts`, `investing_accounts` (in
account codes, the statement of cash flows' account roles (ASC `[setup]`): arrays of account codes, the statement of cash flows'
230-10-45-10 to 45-15). The cash and financing accounts are also what a account roles (ASC 230-10-45-10 to 45-15). The cash and financing
payee is paid from, for `vendor_1099_paid` (a payment card is neither: accounts are also what a payee is paid from, for `vendor_1099_paid` (a
its charges go on Form 1099-K; rules.md B-19), and the cash accounts payment card is neither: its charges go on Form 1099-K; rules.md B-19);
are an IIF file's bank accounts (B-34). 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, - `[[setup.fixed_assets]]` `{ cost_account, accumulated_account,
expense_account }`: accounts that hold property and equipment (their expense_account }`: accounts that hold property and equipment (their
cost and accumulated depreciation are investing; ASC 230-10-45-12(c), cost and accumulated depreciation are investing; ASC 230-10-45-12(c),
45-13(c)). 45-13(c)).
- `[[setup.members]]` `{ name, capital_account, share? }`: the members of - `[[setup.parties]]` `{ name, roles, creditor_of?, tin_last4?,
a multi-member LLC. `share` is the member's share of income, an exact w9_tin_last4?, tin_purged? }`: the company's directory (B-26). `roles`
decimal fraction (`"0.5"`, `"0.3333"`); every member has one or none are role keys (`member`, `vendor`, `customer`, `contractor`,
does, and the shares sum to 1. A close to the members and `landlord`, `employee`, `financial_institution`, `payout_platform`,
`members_equity` need them. `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 - `[[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 - `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 entry paid. Not on a reversal, which takes its original's, nor on a
close. 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 - `close = { start, to, rounding? }` as an event: closes the period from
`start` through the event's `date` (rules.md B-11, B-13), `to` `start` through the event's `date` (rules.md B-11, B-13), `to`
`"members"` (split by share, in the order listed) or `"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 `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 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 `from` the first day of the fiscal year that holds `to`, an
omitted `as_of` today (`vendor_1099_paid` and the format queries give omitted `as_of` today (`vendor_1099_paid`, `party_totals` and the
theirs). A query no crate answers is refused, never skipped. format queries give theirs). A query no crate
answers is refused, never skipped.
| query | dates | values | checked by | | 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, `"<code>.debit"` and `"<code>.credit"` each the sum of that account's lines on that side, every line of the entry named | `ledger-core` `tests/bookspec.rs` | | `entry` | `entry = N`, no dates | entry N's lines, `"<code>.debit"` and `"<code>.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` | | `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): `"<member>.beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `members_equity` | `from`, `to` | the statement of changes in members' equity (rules.md B-13): `"<member>.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): `"<member>.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` | | `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` | | `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` | | `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 A figure query is a table (`[expect.values]`, or `values = { ... }`); a
format query, and `uncategorized_accounts`, an array (`values = [ ... format query, and `uncategorized_accounts`, an array (`values = [ ...
]`). `period`, `day` and `calendar_year` give dates and a year, and ]`). `period`, `day` and `calendar_year` give dates and a year,
`account_names` names, not figures. `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 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 does it, and changes nothing checked after it. It shows no values: it is
@@ -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" }
@@ -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" }
@@ -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" }
@@ -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" }
@@ -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" }
@@ -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" }
@@ -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" }
+86
View File
@@ -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" }
+10 -11
View File
@@ -223,10 +223,11 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result<Deci
/// their share; DECISION, owner, 2026-10-15). /// their share; DECISION, owner, 2026-10-15).
/// DEFINITIONS: /// DEFINITIONS:
/// payment(e): credits minus debits to `sources` on entry e. /// payment(e): credits minus debits to `sources` on entry e.
/// money: crate::parties::money_accounts(ledger, sources.cash), what the /// money: crate::parties::money_accounts(ledger, sources.cash,
/// shares resolve against (the MCP server checks them with the same). /// sources.financing), what the shares resolve against (the MCP server
/// target(e): crate::shares::share_target(e, money): the entry_flow net, /// checks them with the same).
/// else the total debits. /// target(e): crate::shares::target(e, money).amount: what the shares
/// split (rules.md B-35); a reversal's is its original's negated.
/// INVARIANTS: /// INVARIANTS:
/// 1. Only posted entries dated in `year` (and, given `through`, on or /// 1. Only posted entries dated in `year` (and, given `through`, on or
/// before it) and tagged with a vendor (Ledger::entry_vendor) count, /// before it) and tagged with a vendor (Ledger::entry_vendor) count,
@@ -277,7 +278,7 @@ pub fn vendor_1099_report_with_members(
Some(t) => t, Some(t) => t,
None => default_threshold(year)?, 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<VendorId, Decimal> = Default::default(); let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
let mut unresolved = Vec::new(); let mut unresolved = Vec::new();
for entry in ledger.posted_entries() { for entry in ledger.posted_entries() {
@@ -293,12 +294,10 @@ pub fn vendor_1099_report_with_members(
cash cash
} else { } else {
let party = ledger.party_of_vendor(vendor); let party = ledger.party_of_vendor(vendor);
match crate::shares::resolve(entry, shares, &money, members) { match crate::shares::resolve(ledger, entry.id(), shares, &money, members) {
Ok(parts) => { Ok(resolved) => {
match parts.into_iter().find(|(p, _)| Some(*p) == party) { match resolved.parts.iter().find(|p| Some(p.party) == party) {
Some((_, share)) => { Some(share) => vendor_part(cash, share.amount, resolved.target.amount)?,
vendor_part(cash, share, crate::shares::share_target(entry, &money)?)?
}
// Tagged by hand with a vendor none of the parties // Tagged by hand with a vendor none of the parties
// is: nothing to scale by, so the whole payment. // is: nothing to scale by, so the whole payment.
None => { None => {
+1 -1
View File
@@ -281,7 +281,7 @@ pub fn k1_detail(
/// largest remainder. Each part is at most its own credit. /// largest remainder. Each part is at most its own credit.
/// Refused when whole × credit is beyond exact arithmetic (credits near /// Refused when whole × credit is beyond exact arithmetic (credits near
/// the line limit; P0 review round 3, S1). /// the line limit; P0 review round 3, S1).
fn share_property( pub(crate) fn share_property(
pool: Decimal, pool: Decimal,
credits: &[Decimal], credits: &[Decimal],
places: u32, places: u32,
+222 -1
View File
@@ -36,7 +36,10 @@
//! to zero, and a reversal's parts are the original's negated (B-14); //! 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 //! * 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 //! 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 crate::{AccountType, Side};
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -642,6 +645,224 @@ pub fn exchanged<T: Amount>(debits: T, credits: T, inflow: T, outflow: T) -> Opt
Some(d.min(c).max(T::ZERO)) 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<T: Amount>(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<T> {
/// 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<T: Amount>(a: T) -> Option<T> {
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<T: Amount>(like: T, m: T) -> Option<T> {
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<T: Amount>(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<T: Amount>(
capital: Option<(T, T)>,
net: T,
own: T,
debits: T,
) -> Option<PartyTarget<T>> {
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: Amount>(t: PartyTarget<T>) -> Option<PartyTarget<T>> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+99
View File
@@ -2358,6 +2358,14 @@ impl Ledger {
.collect() .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. /// All periods, in creation order.
pub fn list_periods(&self) -> Vec<&Period> { pub fn list_periods(&self) -> Vec<&Period> {
let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect(); let mut ids: Vec<PeriodId> = self.periods.keys().copied().collect();
@@ -3272,6 +3280,7 @@ impl Ledger {
tin: None, tin: None,
vendor: None, vendor: None,
customer: None, customer: None,
creditor_of: std::collections::BTreeSet::new(),
}, },
); );
id id
@@ -3397,6 +3406,7 @@ impl Ledger {
tin: None, tin: None,
vendor: None, vendor: None,
customer: None, customer: None,
creditor_of: std::collections::BTreeSet::new(),
}, },
); );
Ok(id) Ok(id)
@@ -3653,6 +3663,95 @@ impl Ledger {
Ok(()) 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<crate::PartyId> {
self.parties
.values()
.filter(|p| p.creditor_of.contains(&account))
.map(|p| p.id)
.collect()
}
/// Record (or clear) a party's W-4 facts. /// Record (or clear) a party's W-4 facts.
pub fn set_party_w4( pub fn set_party_w4(
&mut self, &mut self,
+341 -77
View File
@@ -26,6 +26,12 @@
//! 3. Names are unique, ignoring case and surrounding space, among parties //! 3. Names are unique, ignoring case and surrounding space, among parties
//! not archived. //! not archived.
//! 4. Every vendor and customer record links to exactly one party. //! 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; use chrono::NaiveDate;
@@ -247,6 +253,11 @@ pub struct Party {
/// Its customer record, if the company invoices it. /// Its customer record, if the company invoices it.
#[serde(default)] #[serde(default)]
pub customer: Option<crate::CustomerId>, pub customer: Option<crate::CustomerId>,
/// 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<crate::AccountId>,
} }
impl Party { impl Party {
@@ -255,6 +266,43 @@ impl Party {
self.display_name.as_deref().unwrap_or(&self.name) 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 /// Its roles: the ones set by hand, plus vendor and customer when it
/// has those records. /// has those records.
pub fn all_roles(&self) -> BTreeSet<Role> { pub fn all_roles(&self) -> BTreeSet<Role> {
@@ -522,26 +570,91 @@ pub enum PartyField {
Archived(bool), 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> {
Basis::ALL.into_iter().find(|b| b.key() == key.trim())
}
}
/// What the books did with one party over a span of dates /// What the books did with one party over a span of dates
/// ([`activity`]). /// ([`activity_with`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Activity { pub struct Activity {
/// The party's entries, drafts included, by id. /// The party's entries, drafts included, by id.
pub entries: BTreeSet<crate::EntryId>, pub entries: BTreeSet<crate::EntryId>,
/// 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, 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, pub received: rust_decimal::Decimal,
/// Posted entries in the span naming it among several parties whose /// Given to it as property in the span (a distribution in kind), net
/// shares don't resolve against the entry and the members given /// of reversals: basis all adds it to `paid`.
/// ([`crate::shares::resolve`]): counted in neither total. 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<crate::EntryId>, pub unresolved: BTreeSet<crate::EntryId>,
} }
/// The accounts [`entry_flow`] reads money from: the cash accounts (every impl Activity {
/// asset account but receivables when `cash` is empty), and the /// (paid, received) on `basis`: cash is `paid` and `received`; all
/// liabilities other than payables and the payroll withholdings (cards, /// adds what moved in kind. [`activity_with`] admits an entry only when
/// what members paid for the company and are owed back, loans). /// 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)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MoneyAccounts { pub struct MoneyAccounts {
/// Cash: bank, savings, cash on hand. /// Cash: bank, savings, cash on hand.
@@ -550,21 +663,80 @@ pub struct MoneyAccounts {
pub liabilities: std::collections::HashSet<crate::AccountId>, pub liabilities: std::collections::HashSet<crate::AccountId>,
} }
/// The [`MoneyAccounts`] of `ledger`, with `cash` the books' cash accounts. impl MoneyAccounts {
pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> 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<crate::AccountId> = 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<crate::AccountId> {
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; use crate::AccountType;
let not_money: std::collections::HashSet<crate::AccountId> = crate::PAYROLL[2..] let not_money: std::collections::HashSet<crate::AccountId> = crate::PAYROLL[2..]
.iter() .iter()
.chain([&crate::PAYABLES, &crate::RECEIVABLES]) .chain([&crate::PAYABLES, &crate::RECEIVABLES])
.filter_map(|c| ledger.role_account(c)) .filter_map(|c| ledger.role_account(c))
.collect(); .collect();
let cards = card_accounts(ledger);
let mut out = MoneyAccounts::default(); let mut out = MoneyAccounts::default();
for a in ledger.chart().all().filter(|a| !not_money.contains(&a.id)) { for a in ledger.chart().all().filter(|a| !not_money.contains(&a.id)) {
match a.account_type { match a.account_type {
AccountType::Asset if cash.is_empty() || cash.contains(&a.id) => { AccountType::Asset if cash.is_empty() || cash.contains(&a.id) => {
out.cash.insert(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); out.liabilities.insert(a.id);
} }
_ => {} _ => {}
@@ -573,6 +745,28 @@ pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> Mone
out 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`]). /// What one entry moved with its counterparty ([`entry_flow`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EntryFlow { pub struct EntryFlow {
@@ -630,6 +824,12 @@ pub struct EntryFlow {
/// 7. A member paying the card bill from their own funds (Dr 2050 / Cr /// 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 /// 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. /// 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( pub fn entry_flow(
entry: &crate::JournalEntry, 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<EntryFlow, crate::LedgerError> {
let entry = ledger
.get_entry(id)
.ok_or(crate::LedgerError::UnknownEntry(id))?;
let (root, depth) = root_original(ledger, entry);
let named: Vec<PartyId> = 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 /// `start..=end`, with no members ([`activity_with`] with none: an entry
/// naming several parties then needs explicit shares to count). /// naming several parties then needs explicit shares to count).
pub fn activity( pub fn activity(
ledger: &crate::Ledger, ledger: &crate::Ledger,
party: PartyId, party: PartyId,
span: (NaiveDate, NaiveDate), span: (NaiveDate, NaiveDate),
cash: &[crate::AccountId], money: &MoneyAccounts,
) -> Activity { ) -> 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 /// One party's entries, and its part of what was paid to and received
/// from the entries' parties in `start..=end`. /// from the entries' parties in `start..=end`, on both bases.
/// ///
/// ```text /// ```text
/// GOAL: one answer to "what did we pay them this year?" for the MCP /// GOAL: one answer to "what did we pay them this year, and what did we
/// server and the viewer alike, right when an entry was with /// get from them?" for the MCP server and the viewer alike, right
/// several parties. /// when an entry was with several parties, for property as well as
/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); DECISION /// money, and for reversals.
/// (owner, 2026-10-15). /// SOURCE: docs/parties-and-attachments.md (Parties, Shares); rules.md
/// B-35 (DECISION, owner, 2026-10-15 and 2026-10-20).
/// INVARIANTS: /// INVARIANTS:
/// 1. entries: every entry Ledger::entry_parties names the party in /// 1. entries: every entry Ledger::entry_parties names the party in
/// (hand tags, vendor tags, bills and their payments, invoices and /// (hand tags, vendor tags, bills and their payments, invoices and
/// their payments, and reversals of any of these carry it). /// their payments, and reversals of any of these carry it).
/// 2. Each posted entry dated in the span whose entry_flow net is not /// 2. Each posted entry dated in the span counts the party's part of it
/// zero counts the party's resolved share of it /// (crate::shares::resolve with `money` and `members`): its cash in
/// (crate::shares::resolve with `members`; the whole net for a sole /// paid or received, its in_kind in paid_in_kind or received_in_kind,
/// party): positive is a payment, negative a receipt. A reversal /// by the direction of the entry's root original (Target::counts_as),
/// counts on its root original's side (the first entry of /// so a reversal subtracts on its original's side, and a reversal of
/// Ledger::reversal_chain), so a reversal subtracts and a reversal /// that reversal adds back there.
/// of that reversal adds back, on the same side. /// 3. An entry whose target is direction neither at its root (a bill,
/// 3. An entry moving no money (net zero, like property contributed) /// an invoice, a close, an accrual, a fee on the party's own card),
/// counts nothing. An entry whose shares don't resolve counts /// or whose part counts nothing (a distribution still owed), counts
/// nothing and is listed in `unresolved`. /// nothing and is never unresolved: its target is found before its
/// 4. Over all the parties an entry names, the shares counted sum to its /// split.
/// net exactly (crate::shares INVARIANT 6). /// 4. An entry whose target or split is refused (shares that don't fit,
/// 5. A card charge counts as paid (the company paid them, by card); a /// a sum past exact arithmetic), or whose part would take any total,
/// bill doesn't until it is paid. Not the 1099 amount: that is /// 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. /// form1099::vendor_1099_report_with.
/// ``` /// ```
pub fn activity_with( pub fn activity_with(
ledger: &crate::Ledger, ledger: &crate::Ledger,
party: PartyId, party: PartyId,
(start, end): (NaiveDate, NaiveDate), (start, end): (NaiveDate, NaiveDate),
cash: &[crate::AccountId], money: &MoneyAccounts,
members: &[crate::PartyMember], members: &[crate::PartyMember],
) -> Activity { ) -> Activity {
use rust_decimal::Decimal; use crate::kernel::Direction;
let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id()) let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id())
.filter(|&id| ledger.entry_names_party(id, party)) .filter(|&id| ledger.entry_names_party(id, party))
.collect(); .collect();
let money = money_accounts(ledger, cash); let mut a = Activity::default();
// 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();
for &id in &entries { for &id in &entries {
let Some(e) = ledger.get_entry(id) else { let Some(e) = ledger.get_entry(id) else {
continue; continue;
@@ -808,43 +1063,52 @@ pub fn activity_with(
if e.status() != crate::EntryStatus::Posted || e.date() < start || e.date() > end { if e.status() != crate::EntryStatus::Posted || e.date() < start || e.date() > end {
continue; continue;
} }
let Ok(net) = net_out(e) else { let shares = ledger.entry_parties(id);
unresolved.insert(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; continue;
}; };
if net.is_zero() { if t.counts_as == Direction::Neither || (t.cash.is_zero() && t.in_kind.is_zero()) {
continue; continue;
} }
let share = match crate::shares::resolve(e, ledger.entry_parties(id), &money, members) { let Ok(parts) = crate::shares::split(e, shares, &t, members) else {
Ok(parts) => parts a.unresolved.insert(id);
.into_iter() continue;
.find(|(p, _)| *p == party) };
.map(|(_, v)| v) let Some(p) = parts.iter().find(|p| p.party == party) else {
.unwrap_or_default(), continue;
Err(_) => { };
unresolved.insert(id); // Paid adds, received subtracts (a receipt is negative), each on
continue; // 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 { a.entries = entries;
entries, a
paid,
received,
unresolved,
}
} }
+138
View File
@@ -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::<u8>() {
0 => kernel::Direction::Paid,
1 => kernel::Direction::Received,
_ => kernel::Direction::Neither,
};
let t = kernel::PartyTarget {
amount: kani::any::<i64>(),
direction,
cash: kani::any::<i64>(),
in_kind: kani::any::<i64>(),
};
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 // The checker itself
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+308 -52
View File
@@ -9,22 +9,58 @@
//! itself ([`Share::Derived`]). //! itself ([`Share::Derived`]).
//! //!
//! ```text //! ```text
//! GOAL: "what did we pay each of them?" right when one entry was with //! GOAL: "what did we pay each of them, and what did we get from each?"
//! several parties, with apportioning the human controls exactly. //! 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 //! SOURCE: DECISION (owner, 2026-10-15): no "joint" party; a joint
//! transaction is with both members, each with their share, and //! transaction is with both members, each with their share, and
//! shares can be apportioned explicitly ("do you allow for //! shares can be apportioned explicitly ("do you allow for
//! controlled apportioning?" Yes). docs/parties-and-attachments.md //! 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: //! DEFINITIONS:
//! flow: crate::parties::entry_flow(entry, money). //! target (target(), rules.md B-35 item 3): what the shares split,
//! target: flow.net when it is not zero, else the entry's total debits //! signed debit-positive as a line on the parties' own account would
//! (a member contributing property moves no money but is still all //! be (positive: the company paid or gave; negative: it received),
//! theirs). magnitude = |target|; sign = the sign of target (positive //! with its direction (paid, received, neither) and what of it moved
//! when net is zero). //! as money (cash) and as property (in_kind), in this order of cases:
//! A member: a PartyMember (party, income share, capital account, //! 1. A period's closing entry: the parties' own-account movement (the
//! distributions account), passed in by the caller from the company's //! members' equity, when every party named is a member), else its
//! settings ([[company.members]]); core doesn't read the settings. //! 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): //! INVARIANTS (of an entry's shares, checked by check_shares, journaled):
//! 1. No party appears twice; every party is in the directory. //! 1. No party appears twice; every party is in the directory.
//! 2. All shares are one kind: all Amount, all Percent, or all Derived. //! 2. All shares are one kind: all Amount, all Percent, or all Derived.
@@ -33,14 +69,16 @@
//! 100. //! 100.
//! 5. At most one party has a vendor record: a 1099 counts a payment //! 5. At most one party has a vendor record: a 1099 counts a payment
//! toward a single vendor. //! toward a single vendor.
//! INVARIANTS (of resolve, against the entry's lines and the members): //! INVARIANTS (of split and resolve, against the entry's lines, the
//! 6. Resolved amounts sum to target exactly, and each has target's sign. //! 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 //! 7. Amount: each party gets sign × its amount; refused unless the
//! amounts sum to magnitude. //! amounts sum to magnitude.
//! 8. Percent: each party gets magnitude × percent / 100 rounded toward //! 8. Percent: each party gets magnitude × percent / 100 rounded toward
//! zero to the cent; the cents left over (fewer than one per party) //! zero to the cent; the cents left over (fewer than one per party)
//! go to the first party in order. Then the sign. //! 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 //! 10. Derived, several parties: every one must be a member (else
//! refused: give amounts or percents). If the entry has a line on any //! refused: give amounts or percents). If the entry has a line on any
//! named member's capital or distributions account, each member gets //! named member's capital or distributions account, each member gets
@@ -48,21 +86,49 @@
//! to magnitude (else refused). Otherwise each gets their income share //! to magnitude (else refused). Otherwise each gets their income share
//! of magnitude, the shares rescaled to the members named, rounded as //! of magnitude, the shares rescaled to the members named, rounded as
//! in 8 (remainder to the first). //! 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: //! SCENARIOS:
//! 1. Reimbursing both members $100.00 (Dr 2100 / Cr 1000), both named, //! 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): //! 2. A joint contribution (Dr 1000 300 / Cr 3000 200 / Cr 3001 100):
//! derived from the capital lines, Tobin −200.00, Morgan −100.00 //! derived from the capital lines, Tobin −200.00, Morgan −100.00,
//! (received). //! received, cash.
//! 3. A property contribution (Dr Equipment 1000 / Cr 3000 500 / Cr 3001 //! 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: //! 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 //! 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). //! (the leftover cent to the first).
//! 5. Amounts of $60.00 and $30.00 on a $100.00 payment: refused. //! 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 rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -202,28 +268,6 @@ pub fn check_shares_journaled(
Ok(()) 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<Decimal, crate::LedgerError> {
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. /// The refusal of a sum past exact arithmetic.
fn too_large(what: String) -> crate::LedgerError { fn too_large(what: String) -> crate::LedgerError {
crate::LedgerError::TooLargeToCompute(what) crate::LedgerError::TooLargeToCompute(what)
@@ -259,23 +303,182 @@ fn split_first(magnitude: Decimal, weights: &[Decimal]) -> Option<Vec<Decimal>>
Some(parts) Some(parts)
} }
/// Each party's exact part of what `entry` moved (the module's INVARIANTS /// What an entry's shares split, and what of it counts (the module's
/// 6–10), in the shares' order. Refused, with why, when the shares can't /// DEFINITIONS: target; rules.md B-35).
/// resolve against these lines and members. #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub fn resolve( 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<Part>,
}
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<Target, LedgerError> {
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, entry: &JournalEntry,
shares: &[PartyShare], shares: &[PartyShare],
money: &crate::parties::MoneyAccounts, money: &crate::parties::MoneyAccounts,
) -> Result<PartyTarget<Decimal>, LedgerError> {
let past_exact = || too_large(format!("entry {}'s target", entry.id()));
let named: Vec<PartyId> = 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<Vec<Decimal>> {
let ranked: Vec<Decimal> = 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], members: &[PartyMember],
) -> Result<Vec<(PartyId, Decimal)>, String> { ) -> Result<Vec<Part>, String> {
let target = share_target(entry, money).map_err(|e| e.to_string())?;
let past_exact = || too_large(format!("entry {}'s shares", entry.id())).to_string(); let past_exact = || too_large(format!("entry {}'s shares", entry.id())).to_string();
let magnitude = target.abs(); let magnitude = target.amount.abs();
let signed = |v: Decimal| if target.is_sign_negative() { -v } else { v };
let Some(first) = shares.first() else { let Some(first) = shares.first() else {
return Ok(Vec::new()); return Ok(Vec::new());
}; };
let parts: Vec<Decimal> = match first.share { let sizes: Vec<Decimal> = match first.share {
Share::Amount(_) => { Share::Amount(_) => {
let amounts: Vec<Decimal> = shares let amounts: Vec<Decimal> = shares
.iter() .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<usize> = (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<Decimal> = sizes
.iter()
.zip(&in_kind_sizes)
.map(|(s, k)| s.checked_sub(*k))
.collect::<Option<_>>()
.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 Ok(shares
.iter() .iter()
.zip(parts) .enumerate()
.map(|(s, v)| (s.party, signed(v))) .map(|(i, s)| Part {
party: s.party,
amount: signed(sizes[i]),
cash: signed(cash_sizes[i]),
in_kind: signed(in_kind_sizes[i]),
})
.collect()) .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<Resolved, String> {
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 })
}
+491 -36
View File
@@ -5,13 +5,15 @@
//! Each example is a setup, events and expected values worked by hand from //! Each example is a setup, events and expected values worked by hand from
//! its cited source (`bookspec/README.md` gives the shape). Every example //! its cited source (`bookspec/README.md` gives the shape). Every example
//! must load, whatever it asks or whichever appendix it is in: its shape, //! must load, whatever it asks or whichever appendix it is in: its shape,
//! its accounts, fixed assets, members and payees, every event (an entry, //! its accounts, fixed assets, parties, members and payees, every event
//! a reversal, a close or a bill, posting entry #N as event N) and every //! (an entry, a reversal, a close or a bill, posting entry #N as event N,
//! change that posts no entry (a rename, a policy). The queries the core //! with the parties and payee it is tagged with) and every change that
//! answers (`cash_flow`, `trial_balance`, `profit_and_loss`, //! posts no entry (a rename, a policy). The queries the core answers
//! `vendor_1099_paid`, `entry`, `balances`, `members_equity`, `period`, //! (`cash_flow`, `trial_balance`, `profit_and_loss`, `vendor_1099_paid`,
//! `day`, `calendar_year`, `account_names`, `uncategorized_accounts`) are //! `entry`, `balances`, `members_equity`, `k1`, `party_part`,
//! then checked value by value, and the commands it tries //! `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 //! (`rename_account`, `add_account`, `set_tax_category`) accepted or
//! refused on a copy of the books, each after the event it names (`after`) //! 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 //! 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 chrono::NaiveDate;
use ledger_core::calendar::{PeriodDefaults, period_defaults}; use ledger_core::calendar::{PeriodDefaults, period_defaults};
use ledger_core::parties::{Basis, MoneyAccounts};
use ledger_core::settings::Company; use ledger_core::settings::Company;
use ledger_core::{ use ledger_core::{
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
DocumentLine, EntryId, Form1099, Ledger, LedgerError, MemberCapital, MemberClose, Money, 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, profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report_with_members,
}; };
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -71,6 +75,10 @@ struct Setup {
investing_accounts: Vec<String>, investing_accounts: Vec<String>,
#[serde(default)] #[serde(default)]
fixed_assets: Vec<FixedAsset>, fixed_assets: Vec<FixedAsset>,
/// 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<PartySpec>,
#[serde(default)] #[serde(default)]
members: Vec<MemberSpec>, members: Vec<MemberSpec>,
#[serde(default)] #[serde(default)]
@@ -124,6 +132,8 @@ struct FixedAsset {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct MemberSpec { 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, name: String,
capital_account: String, capital_account: String,
/// Their share of income, an exact decimal fraction; every member has /// Their share of income, an exact decimal fraction; every member has
@@ -131,9 +141,14 @@ struct MemberSpec {
/// needs them. /// needs them.
#[serde(default)] #[serde(default)]
share: Option<String>, share: Option<String>,
/// Their distributions account (rules.md B-13), when they have one.
#[serde(default)]
distributions_account: Option<String>,
} }
/// 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)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct PayeeSpec { struct PayeeSpec {
@@ -141,6 +156,43 @@ struct PayeeSpec {
form: String, 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<String>,
/// The liabilities, by code, the company owes the party itself
/// (rules.md B-35 item 6).
#[serde(default)]
creditor_of: Vec<String>,
/// Its TIN facts' last four digits (an SSN's).
#[serde(default)]
tin_last4: Option<String>,
/// Its W-9 facts' last four digits (a 1099 role's).
#[serde(default)]
w9_tin_last4: Option<String>,
/// 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<String>,
#[serde(default)]
percent: Option<String>,
}
/// One event: an entry (`lines`), a reversal (`reverses`), a close /// One event: an entry (`lines`), a reversal (`reverses`), a close
/// (`close`) or a bill (`bill`), exactly one of them. /// (`close`) or a bill (`bill`), exactly one of them.
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -158,6 +210,10 @@ struct Event {
/// The payee (in `setup.payees`) the entry paid. /// The payee (in `setup.payees`) the entry paid.
#[serde(default)] #[serde(default)]
payee: Option<String>, payee: Option<String>,
/// The parties (in `setup.parties`) the entry is with, and their
/// shares.
#[serde(default)]
parties: Vec<ShareSpec>,
} }
/// A bill from `vendor` for `amount`, dated and due on the event's date: /// A bill from `vendor` for `amount`, dated and due on the event's date:
@@ -224,8 +280,13 @@ struct Expect {
from: Option<String>, from: Option<String>,
to: Option<String>, to: Option<String>,
as_of: Option<String>, as_of: Option<String>,
/// The entry an `entry` query asks about. /// The entry an `entry` or `party_part` query asks about.
entry: Option<EntryId>, entry: Option<EntryId>,
/// The party a `party_part`, `party_totals` or `party_tin_last4`
/// query asks about (in `setup.parties`).
party: Option<String>,
/// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4).
basis: Option<String>,
/// The books after event N, not after the last. /// The books after event N, not after the last.
after: Option<usize>, after: Option<usize>,
/// The account a `rename_account` or `set_tax_category` command /// The account a `rename_account` or `set_tax_category` command
@@ -412,15 +473,103 @@ struct Books<'a> {
currency: Currency, currency: Currency,
/// The members, with their shares (zero when the example gives none). /// The members, with their shares (zero when the example gives none).
members: Vec<MemberCapital>, members: Vec<MemberCapital>,
/// 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<PartyMember>,
/// The directory's parties, by name.
parties: BTreeMap<String, PartyId>,
/// The payees' vendor records, by name. /// The payees' vendor records, by name.
payees: BTreeMap<String, VendorId>, payees: BTreeMap<String, VendorId>,
/// The vendors bills came from, by name (a payee's record if one). /// The vendors bills came from, by name (a payee's record if one).
vendors: BTreeMap<String, VendorId>, vendors: BTreeMap<String, VendorId>,
} }
/// 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<String, PartyId> {
let mut out = BTreeMap::new();
for p in &ex.setup.parties {
let what = format!("{} party {}", ex.id, p.name);
let roles: Vec<Role> = 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<Role> = 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> { impl<'a> Books<'a> {
/// The books an example sets up, before any event: its shape checked, /// 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 { fn new(ex: &'a Example) -> Self {
let s = &ex.setup; let s = &ex.setup;
let currency = Currency::new(&s.currency).unwrap(); let currency = Currency::new(&s.currency).unwrap();
@@ -508,7 +657,7 @@ impl<'a> Books<'a> {
.map(|m| MemberCapital { .map(|m| MemberCapital {
name: m.name.clone(), name: m.name.clone(),
capital_account: id(&l, &m.capital_account), 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| { share: m.share.as_deref().map_or(Decimal::ZERO, |x| {
let share = exact(x, &format!("{} {} share", ex.id, m.name)); let share = exact(x, &format!("{} {} share", ex.id, m.name));
assert!(share > Decimal::ZERO, "{}: {}'s 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(); let total: Decimal = members.iter().map(|m| m.share).sum();
assert_eq!(total, Decimal::ONE, "{}: the shares sum to 1", ex.id); 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<PartyMember> = 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(); let mut payees = BTreeMap::new();
for p in &s.payees { for p in &s.payees {
let form = Form1099::parse(&p.form) let form = Form1099::parse(&p.form)
.filter(|_| p.form.starts_with("1099-"))
.unwrap_or_else(|| panic!("{}: payee {}: form {:?}", ex.id, p.name, p.form)); .unwrap_or_else(|| panic!("{}: payee {}: form {:?}", ex.id, p.name, p.form));
let v = l // A payee in the directory with the vendor role is that
.add_vendor(&p.name) // 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)); .unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name));
l.set_vendor_1099(v, Some(form), false).unwrap();
assert!( assert!(
payees.insert(p.name.clone(), v).is_none(), payees.insert(p.name.clone(), v).is_none(),
"{}: payee {} twice", "{}: payee {} twice",
@@ -540,6 +711,8 @@ impl<'a> Books<'a> {
l, l,
currency, currency,
members, members,
party_members,
parties,
vendors: payees.clone(), vendors: payees.clone(),
payees, 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()), 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" "{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()) { let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
(None, None, true) if e.bill.is_some() => { (None, None, true) if e.bill.is_some() => {
let b = e.bill.as_ref().unwrap(); 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"), _ => 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}"); assert_eq!(posted, n, "{ex}: event {n} must be entry #{n}");
if !e.parties.is_empty() {
let shares: Vec<PartyShare> = 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 { if let Some(p) = &e.payee {
let v = *self let v = *self
.payees .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 1099 report a `vendor_1099_paid` expectation asks for: the
/// the cash (and financing) accounts from January 1 through `to`, a /// calendar year of `to`, from January 1 through `to`, out of the cash
/// payment card's left out, every payee listed (rules.md B-19, B-33). /// and financing accounts, an entry's shares resolved with the members
fn check_vendor_1099_paid( /// who are parties (rules.md B-19, B-33).
ex: &Example, fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core::Vendor1099Report) {
l: &Ledger, let (ex, l) = (b.ex, &b.l);
payees: &BTreeMap<String, VendorId>,
x: &Expect,
) {
// A calendar-year count, never the fiscal default (rules.md B-30): // A calendar-year count, never the fiscal default (rules.md B-30):
// both dates given. // both dates given.
assert!( assert!(
x.from.is_some() && x.to.is_some(), x.from.is_some() && x.to.is_some(),
"{}: vendor_1099_paid gives from and to", "{}: {} gives from and to",
ex.id ex.id,
x.query
); );
let (from, to) = x.period(ex); let (from, to) = x.period(ex);
let year = chrono::Datelike::year(&to); let year = chrono::Datelike::year(&to);
@@ -999,28 +1197,221 @@ fn check_vendor_1099_paid(
); );
assert!( assert!(
!ex.setup.cash_accounts.is_empty(), !ex.setup.cash_accounts.is_empty(),
"{}: vendor_1099_paid needs cash_accounts (what payees are paid from)", "{}: {} needs cash_accounts (what payees are paid from)",
ex.id ex.id,
x.query
); );
let sources = PaymentAccounts { let sources = PaymentAccounts {
cash: codes(l, &ex.setup.cash_accounts, &ex.id), cash: codes(l, &ex.setup.cash_accounts, &ex.id),
financing: codes(l, &ex.setup.financing_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)); .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) { for (name, want) in x.figures(&ex.id) {
let want = exact(want, &format!("{} {name}", ex.id)); let want = exact(want, &format!("{} {name}", ex.id));
let got: Decimal = r let got: Decimal = r
.vendors .vendors
.iter() .iter()
.filter(|v| v.vendor_id == payees[name]) .filter(|v| v.vendor_id == b.payees[name])
.map(|v| v.paid) .map(|v| v.paid)
.sum(); .sum();
assert_eq!(got, want, "{} 1099 paid [{from} .. {to}]: {name}", ex.id); 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), `"<member>.<column>"` 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 /// The formats appendix's queries, and the test (another crate's) that
/// checks them against the engine. /// checks them against the engine.
const FORMAT_QUERIES: [(&str, &str); 2] = [ const FORMAT_QUERIES: [(&str, &str); 2] = [
@@ -1374,7 +1765,13 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
queries.join(" and ") 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( only(
x.code.is_some(), x.code.is_some(),
"code", "code",
@@ -1419,10 +1816,14 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
"cash_flow" => check_cash_flow(ex, l, &b.members, x), "cash_flow" => check_cash_flow(ex, l, &b.members, x),
"trial_balance" => check_trial_balance(ex, l, x), "trial_balance" => check_trial_balance(ex, l, x),
"profit_and_loss" => check_profit_and_loss(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), "entry" => check_entry(ex, l, x),
"balances" => check_balances(ex, l, x), "balances" => check_balances(ex, l, x),
"members_equity" => check_members_equity(ex, l, &b.members, 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 => { q => {
let &(_, by) = FORMAT_QUERIES let &(_, by) = FORMAT_QUERIES
.iter() .iter()
@@ -1508,8 +1909,8 @@ fn every_bookspec_example_holds() {
} }
// Each query the examples use ran: the cash flow's six examples at // Each query the examples use ran: the cash flow's six examples at
// least, the as-of and 1099 examples', the members closes', the // least, the as-of and 1099 examples', the members closes', the
// formats appendix's, and P3's default period, rename and tax // formats appendix's, and P3's default period, rename, tax category
// category examples'. // and parties examples'.
for (q, at_least) in [ for (q, at_least) in [
("cash_flow", 6), ("cash_flow", 6),
("trial_balance", 2), ("trial_balance", 2),
@@ -1517,7 +1918,11 @@ fn every_bookspec_example_holds() {
("vendor_1099_paid", 3), ("vendor_1099_paid", 3),
("entry", 8), ("entry", 8),
("balances", 4), ("balances", 4),
("members_equity", 5), ("members_equity", 10),
("k1", 2),
("party_part", 27),
("party_totals", 21),
("party_tin_last4", 5),
("iif_lines", 1), ("iif_lines", 1),
("iif_changes", 1), ("iif_changes", 1),
("period", 6), ("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)
});
}
+33
View File
@@ -315,4 +315,37 @@ proptest! {
prop_assert_eq!(fi.into_iter().map(dec).collect::<Vec<_>>(), fd); prop_assert_eq!(fi.into_iter().map(dec).collect::<Vec<_>>(), fd);
prop_assert_eq!(ti.into_iter().map(dec).collect::<Vec<_>>(), td); prop_assert_eq!(ti.into_iter().map(dec).collect::<Vec<_>>(), 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<i64>| 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));
}
} }
+2 -3
View File
@@ -561,9 +561,8 @@ fn replayed_bases_and_shares_past_the_limit_are_accepted() {
)); ));
b.ledger.replay_set_entry_parties(e, &shares).unwrap(); b.ledger.replay_set_entry_parties(e, &shares).unwrap();
assert_eq!(b.ledger.entry_parties(e), &shares); assert_eq!(b.ledger.entry_parties(e), &shares);
let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash]); 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, &[]) let why = ledger_core::shares::resolve(&b.ledger, e, &shares, &money, &[]).unwrap_err();
.unwrap_err();
assert!(why.contains("too large to compute"), "{why}"); assert!(why.contains("too large to compute"), "{why}");
// Replay still checks what every version checked. // Replay still checks what every version checked.
assert_eq!( assert_eq!(
+137 -51
View File
@@ -132,6 +132,7 @@ fn bare_party(name: &str) -> Party {
tin: None, tin: None,
vendor: None, vendor: None,
customer: 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::parties::{entry_flow, money_accounts};
use ledger_core::{AccountType, Currency, Ledger, Money, Side}; use ledger_core::{AccountType, Currency, Ledger, Money, Side};
let mut l = Ledger::new(Currency::USD); let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let mut acct = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = acct("1000", AccountType::Asset); let bank = acct("1000", "Bank", AccountType::Asset);
let card_a = acct("2050", AccountType::Liability); let card_a = acct("2050", "Card A", AccountType::Liability);
let card_b = acct("2060", AccountType::Liability); let card_b = acct("2060", "Card B", AccountType::Liability);
let members = acct("2100", AccountType::Liability); let members = acct("2100", "Due to Members", AccountType::Liability);
let capital = acct("3000", AccountType::Equity); let capital = acct("3000", "Capital", AccountType::Equity);
let fees = acct("6000", AccountType::Expense); let fees = acct("6000", "Fees", AccountType::Expense);
let money = money_accounts(&l, &[bank]); let money = money_accounts(&l, &[bank], &[members]);
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x"); let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), "x");
for (a, side, amt) in [ for (a, side, amt) in [
(card_a, Side::Debit, "30.00"), (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 /// Oracle: shares::target DEFINITIONS, case 3 (rules.md B-35): members
/// total debits, whatever its credits (a draft not yet balanced). /// 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] #[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::parties::money_accounts;
use ledger_core::shares::{Direction, Target, target};
use ledger_core::{AccountType, Currency, Ledger, Money, Side}; use ledger_core::{AccountType, Currency, Ledger, Money, Side};
let mut l = Ledger::new(Currency::USD); let mut l = Ledger::new(Currency::USD);
let bank = l let bank = l
@@ -1453,7 +1458,9 @@ fn a_share_target_without_money_is_the_debits() {
let capital = l let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false) .add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap(); .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"); 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)) l.add_line(e, equip, Side::Debit, Money::new(d("10.00"), Currency::USD))
.unwrap(); .unwrap();
@@ -1465,8 +1472,25 @@ fn a_share_target_without_money_is_the_debits() {
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
ledger_core::shares::share_target(l.get_entry(e).unwrap(), &money), target(&l, e, &[PartyShare::whole(member)], &money),
Ok(d("10.00")) 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 cap_b = acct("3001", AccountType::Equity);
let a = l.add_party("A", &[Role::Member]).unwrap(); let a = l.add_party("A", &[Role::Member]).unwrap();
let b = l.add_party("B", &[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 { let member = |party, capital| ledger_core::PartyMember {
party, party,
share: d("0.5"), share: d("0.5"),
@@ -1506,9 +1530,13 @@ fn derived_member_shares_net_their_capital_lines() {
s.share = ledger_core::Share::Derived; s.share = ledger_core::Share::Derived;
s s
}); });
let r = ledger_core::shares::resolve(&l, e, &shares, &money, &members).unwrap();
assert_eq!( assert_eq!(
ledger_core::shares::resolve(l.get_entry(e).unwrap(), &shares, &money, &members), r.parts
Ok(vec![(a, d("-70.00")), (b, d("-30.00"))]) .iter()
.map(|p| (p.party, p.amount))
.collect::<Vec<_>>(),
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 ledger_core::{AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal; use rust_decimal::Decimal;
let mut l = Ledger::new(Currency::USD); let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let mut acct = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let bank = acct("1000", AccountType::Asset); let bank = acct("1000", "Bank", AccountType::Asset);
let savings = acct("1010", AccountType::Asset); let savings = acct("1010", "Savings", AccountType::Asset);
let equip = acct("1500", AccountType::Asset); let equip = acct("1500", "Equipment", AccountType::Asset);
let card = acct("2050", AccountType::Liability); let card = acct("2050", "Business Card", AccountType::Liability);
let members = acct("2100", AccountType::Liability); let members = acct("2100", "Due to Members", AccountType::Liability);
let loan = acct("2500", AccountType::Liability); let loan = acct("2500", "Loan", AccountType::Liability);
let capital = acct("3000", AccountType::Equity); let accrued = acct("2200", "Accrued Expenses", AccountType::Liability);
let exp = acct("6000", AccountType::Expense); let capital = acct("3000", "Capital", AccountType::Equity);
let money = money_accounts(&l, &[bank, savings]); 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 d = chrono::NaiveDate::from_ymd_opt(2026, 5, 1).unwrap();
let mut case = |lines: &[(u64, Side, &str)]| { let mut case = |lines: &[(u64, Side, &str)]| {
let e = l.create_entry(d, "x"); let e = l.create_entry(d, "x");
@@ -1619,6 +1648,13 @@ fn entry_flow_scenarios() {
(card, C, "50.00"), (card, C, "50.00"),
]); ]);
assert_eq!(f.net, dec("350.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 // ------------------------------------------------------- party shares
@@ -1729,25 +1765,33 @@ mod shares {
.collect() .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<Vec<(PartyId, Decimal)>, String> { fn resolved(b: &Books, e: EntryId) -> Result<Vec<(PartyId, Decimal)>, String> {
let money = money_accounts(&b.l, &[b.bank]); resolve(&b.l, e, b.l.entry_parties(e), &money(b), &b.members)
resolve( .map(|r| r.parts.iter().map(|p| (p.party, p.amount)).collect())
b.l.get_entry(e).unwrap(),
b.l.entry_parties(e),
&money,
&b.members,
)
} }
fn year() -> (chrono::NaiveDate, chrono::NaiveDate) { fn year() -> (chrono::NaiveDate, chrono::NaiveDate) {
(date(2026, 1, 1), date(2026, 12, 31)) (date(2026, 1, 1), date(2026, 12, 31))
} }
/// (paid, received) on basis cash.
fn paid_received(b: &Books, p: PartyId) -> (Decimal, Decimal) { 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) (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. /// SCENARIO 1: a reimbursement to both members, derived, 50/50.
#[test] #[test]
fn reimbursing_both_members_splits_by_member_share() { 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"))); assert_eq!(paid_received(&b, b.mo), (Decimal::ZERO, d("100.00")));
} }
/// SCENARIO 3: property contributed moves no money; the shares are of /// SCENARIO 3 (defect 14, rules.md B-35): property contributed moves
/// the total, and activity counts nothing. /// 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] #[test]
fn property_contribution_resolves_against_the_total() { fn property_contribution_resolves_against_the_total() {
let mut b = books(); let mut b = books();
@@ -1810,14 +1857,52 @@ mod shares {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
resolved(&b, e).unwrap(), 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(&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] #[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 mut b = books();
let e = post( let e = post(
&mut b.l, &mut b.l,
@@ -1829,10 +1914,12 @@ mod shares {
); );
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])) b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
.unwrap(); .unwrap();
assert!(resolved(&b, e).is_err()); assert_eq!(
let a = activity_with(&b.l, b.tobin, year(), &[b.bank], &b.members); resolved(&b, e).unwrap(),
assert!(a.unresolved.contains(&e)); vec![(b.tobin, d("-200.00")), (b.mo, Decimal::ZERO)]
assert_eq!((a.paid, a.received), (Decimal::ZERO, 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. /// 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. // With no members passed (plain activity), two members can't derive.
b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])) b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo]))
.unwrap(); .unwrap();
let money = money_accounts(&b.l, &[b.bank]); assert!(resolve(&b.l, e, b.l.entry_parties(e), &money(&b), &[]).is_err());
assert!(resolve(b.l.get_entry(e).unwrap(), b.l.entry_parties(e), &money, &[]).is_err());
// A sole party needs no members. // A sole party needs no members.
b.l.set_entry_parties(e, &derived(&[b.acme])).unwrap(); b.l.set_entry_parties(e, &derived(&[b.acme])).unwrap();
assert_eq!(resolved(&b, e).unwrap(), vec![(b.acme, d("10.00"))]); 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(); 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_eq!(a.paid, d("12.34"));
assert!(a.entries.contains(&e)); assert!(a.entries.contains(&e));
} }
@@ -2168,14 +2254,14 @@ mod shares {
b.l.set_entry_party(a, Some(b.acme)).unwrap(); b.l.set_entry_party(a, Some(b.acme)).unwrap();
let r1 = b.l.reverse_entry(a, date(2026, 5, 2), "undo").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 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.entries, std::collections::BTreeSet::from([a, r1, r2]));
assert_eq!((act.paid, act.received), (d("100.00"), Decimal::ZERO)); assert_eq!((act.paid, act.received), (d("100.00"), Decimal::ZERO));
// A third level takes it back out again, still on the paid side. // A third level takes it back out again, still on the paid side.
let _r3 = let _r3 =
b.l.reverse_entry(r2, date(2026, 5, 4), "undo again") b.l.reverse_entry(r2, date(2026, 5, 4), "undo again")
.unwrap(); .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)); 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(); b.l.set_entry_parties(e, &derived(&[b.tobin, b.mo])).unwrap();
let r = resolved(&b, e).unwrap(); let r = resolved(&b, e).unwrap();
prop_assert_eq!(r, vec![(b.tobin, -a), (b.mo, -c)]); 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 sa = activity_with(&b.l, b.tobin, year(), &money(&b), &b.members);
let sk = activity_with(&b.l, b.mo, year(), &[b.bank], &b.members); let sk = activity_with(&b.l, b.mo, year(), &money(&b), &b.members);
prop_assert_eq!(sa.received + sk.received, total); prop_assert_eq!(sa.received + sk.received, total);
} }
} }
@@ -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)]
File diff suppressed because it is too large. Load diff
@@ -199,7 +199,16 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
let q = l let q = l
.add_party("Beta", &[ledger_core::Role::Contractor]) .add_party("Beta", &[ledger_core::Role::Contractor])
.unwrap(); .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)] { for (dr, cr) in [(rent, cash), (rent, card), (card, cash), (cash, card)] {
let e = l.create_entry(d(2026, 3, 1), "huge"); let e = l.create_entry(d(2026, 3, 1), "huge");
for side in [Side::Debit, Side::Debit] { for side in [Side::Debit, Side::Debit] {
@@ -211,12 +220,31 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
.unwrap(); .unwrap();
let entry = l.get_entry(e).unwrap(); let entry = l.get_entry(e).unwrap();
assert!(too_large(ledger_core::parties::entry_flow(entry, &money))); assert!(too_large(ledger_core::parties::entry_flow(entry, &money)));
assert!(too_large(ledger_core::shares::share_target(entry, &money))); assert!(too_large(ledger_core::shares::target(
let why = &l,
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap_err(); e,
&[PartyShare::whole(p)],
&money
)));
let why = resolve(&l, e, &[PartyShare::whole(p)]).unwrap_err();
assert!(why.contains("too large to compute"), "{why}"); assert!(why.contains("too large to compute"), "{why}");
assert_eq!(l.post_entry(e).unwrap_err(), LedgerError::AmountOutOfRange); 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. // One line of Decimal's largest flows, and its share resolves whole.
let e = l.create_entry(d(2026, 3, 1), "one huge"); let e = l.create_entry(d(2026, 3, 1), "one huge");
l.replay_add_line(e, rent, Side::Debit, usd(Decimal::MAX)) 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, ledger_core::parties::entry_flow(entry, &money).unwrap().net,
Decimal::MAX Decimal::MAX
); );
let r = resolve(&l, e, &[PartyShare::whole(p)]).unwrap();
assert_eq!( assert_eq!(
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap(), (r.parts[0].party, r.parts[0].amount, r.parts[0].cash),
[(p, Decimal::MAX)] (p, Decimal::MAX, Decimal::MAX)
); );
// A percent of it: the product is past exact arithmetic. // A percent of it: the product is past exact arithmetic.
let halves = [p, q].map(|party| PartyShare { let halves = [p, q].map(|party| PartyShare {
party, party,
share: ledger_core::shares::Share::Percent(Decimal::from(50)), 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}"); assert!(why.contains("too large to compute"), "{why}");
} }
+142 -98
View File
@@ -67,13 +67,16 @@ pub struct EntryInfo {
/// One party an entry names, with its share. /// One party an entry names, with its share.
/// ///
/// ```text /// ```text
/// SOURCE: ledger_core::shares (resolve), with Books::money_cash_accounts /// SOURCE: ledger_core::shares (resolve), with Books::money and
/// and Books::members, as the server's books.get_entry resolves /// Books::members, as the server's books.get_entry resolves them.
/// them.
/// INVARIANTS: /// INVARIANTS:
/// 1. resolved: the party's part from ledger_core::shares::resolve, None /// 1. resolved: the party's part from ledger_core::shares::resolve
/// when the entry's shares don't resolve; over an entry's parties the /// (signed as a line on its own account: positive paid to it,
/// resolved parts sum to what it moved exactly. /// 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)] #[derive(Clone, Debug, PartialEq)]
pub struct EntryParty { pub struct EntryParty {
@@ -81,6 +84,9 @@ pub struct EntryParty {
pub label: String, pub label: String,
pub share: ledger_core::Share, pub share: ledger_core::Share,
pub resolved: Option<Decimal>, pub resolved: Option<Decimal>,
pub direction: Option<ledger_core::shares::Direction>,
pub cash: Option<Decimal>,
pub in_kind: Option<Decimal>,
} }
/// One fixed asset's trail through the journal and the entries. /// 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, /// year 1099s are counted in, and books.party's year: a posted entry,
/// invoice, or bill dated later this year counts). /// invoice, or bill dated later this year counts).
/// Money: Books::activity (ledger_core::parties::activity_with with /// Money: Books::activity (ledger_core::parties::activity_with with
/// Books::money_cash_accounts and Books::members: a party's share of /// Books::money and Books::members: a party's share of an entry naming
/// an entry naming several; the same numbers books.party reports). /// several; the same numbers books.party reports).
/// INVARIANTS: /// INVARIANTS:
/// 1. entries: every entry that is the party's, drafts included, in /// 1. entries: every entry that is the party's, drafts included, in
/// Books::entries order (newest first). /// Books::entries order (newest first).
/// 2. paid_ytd = activity's paid − received over the year, exactly. /// 2. paid_ytd = activity's paid − received over the year, exactly, on
/// Positive: paid to them, net; negative: received from them, net. /// 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 /// 3. invoiced_ytd = Σ Invoice::total() over its customer record's
/// invoices dated in the year; billed_ytd the same for its vendor /// invoices dated in the year; billed_ytd the same for its vendor
/// record's bills. /// record's bills.
@@ -242,6 +249,11 @@ pub struct Books {
pub members: Vec<ledger_core::PartyMember>, pub members: Vec<ledger_core::PartyMember>,
/// `[books] financing_accounts`, resolved (empty when not set). /// `[books] financing_accounts`, resolved (empty when not set).
pub financing_accounts: Vec<AccountId>, pub financing_accounts: Vec<AccountId>,
/// 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 /// The cash accounts every number the server also reports reads
/// money with: `[books] cash_accounts`, resolved; when that is empty, /// money with: `[books] cash_accounts`, resolved; when that is empty,
/// core's fallback (ledger_core::parties::money_accounts: every asset /// core's fallback (ledger_core::parties::money_accounts: every asset
@@ -301,26 +313,26 @@ pub enum PartyPeriod {
/// ```text /// ```text
/// GOAL: answer "how much did we spend with them, and how much did we /// 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. /// get back?" (owner, 2026-10-15) with the books' own numbers.
/// SOURCE: ledger_core::parties::activity_with (paid, received) and /// SOURCE: ledger_core::parties::activity_with (paid, received, in kind)
/// ledger_core::parties::entry_flow (what for), with /// and ledger_core::parties::flow (what for), with Books::money
/// Books::money_cash_accounts and Books::members, as PartyInfo /// and Books::members, as PartyInfo uses them; rules.md B-35.
/// uses them.
/// INVARIANTS: /// 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. /// 2. net() = paid − received.
/// 3. what_for: over the party's posted entries dated in the span, the /// 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 /// sum by account of each entry's breakdown for the party
/// down, like a reimbursement, appears as what the money paid for), /// (Books::party_what_for: parties::flow's what_for, its share of it
/// for an entry naming several parties this party's part of it: a /// for an entry naming several, made to sum to the party's cash part
/// member's own capital and distributions lines when they come to /// of the entry), the accounts whose sum is zero left out, largest
/// its resolved share exactly, else the what_for apportioned to the /// magnitude first (then by account id). Positive: what money paid
/// share (share_of_what_for; nothing when the shares don't resolve), /// for; negative: where money received came from (or, on payables
/// the accounts whose sum is zero left out, largest magnitude first /// and receivables, what is still owed).
/// (then by account id). Positive: what money paid for; negative: /// 4. Σ what_for = net(), exactly: each entry's breakdown sums to the
/// where money received came from (or, on payables and /// party's cash part of it, and activity sums those parts (a
/// receivables, what is still owed). /// reversal's are its original's negated).
/// 4. Σ what_for = net(), exactly: each entry_flow's what_for sums to
/// its net (a reversal reverses both).
/// 5. entries: how many of the party's entries are posted and dated /// 5. entries: how many of the party's entries are posted and dated
/// in the span. /// in the span.
/// ``` /// ```
@@ -328,6 +340,8 @@ pub enum PartyPeriod {
pub struct PartyFlow { pub struct PartyFlow {
pub paid: Decimal, pub paid: Decimal,
pub received: Decimal, pub received: Decimal,
pub paid_in_kind: Decimal,
pub received_in_kind: Decimal,
pub what_for: Vec<(AccountId, Decimal)>, pub what_for: Vec<(AccountId, Decimal)>,
pub entries: usize, pub entries: usize,
} }
@@ -571,17 +585,22 @@ impl Books {
let money_cash_accounts = { let money_cash_accounts = {
let configured = codes(&settings.cash_accounts); let configured = codes(&settings.cash_accounts);
if configured.is_empty() { if configured.is_empty() {
let mut all: Vec<AccountId> = ledger_core::parties::money_accounts(&ledger, &[]) let mut all: Vec<AccountId> =
.cash ledger_core::parties::money_accounts(&ledger, &[], &[])
.into_iter() .cash
.collect(); .into_iter()
.collect();
all.sort_unstable(); all.sort_unstable();
all all
} else { } else {
configured 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 investing_accounts = codes(&settings.investing_accounts);
let card_accounts = ledger_store::reconcilable::card_accounts(&ledger); let card_accounts = ledger_store::reconcilable::card_accounts(&ledger);
@@ -675,7 +694,8 @@ impl Books {
// vendor tags, bills, invoices, their payments, reversals). // vendor tags, bills, invoices, their payments, reversals).
let party_id = ledger.entry_party(id); let party_id = ledger.entry_party(id);
let shares = ledger.entry_parties(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<EntryParty> = shares let parties: Vec<EntryParty> = shares
.iter() .iter()
.enumerate() .enumerate()
@@ -686,7 +706,10 @@ impl Books {
.map(|p| p.label().to_string()) .map(|p| p.label().to_string())
.unwrap_or_else(|| format!("party {}", s.party)), .unwrap_or_else(|| format!("party {}", s.party)),
share: s.share, 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(); .collect();
let party = if parties.is_empty() { let party = if parties.is_empty() {
@@ -766,6 +789,7 @@ impl Books {
cash_accounts, cash_accounts,
members, members,
financing_accounts, financing_accounts,
money: share_money,
money_cash_accounts, money_cash_accounts,
investing_accounts, investing_accounts,
card_accounts, card_accounts,
@@ -1505,17 +1529,14 @@ impl Books {
.collect(); .collect();
let mut what_for = Vec::new(); let mut what_for = Vec::new();
if with_breakdown { if with_breakdown {
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new(); let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
for e in &posted { for e in &posted {
// A posted entry's flow computes (its lines are in a line's // An entry activity counts as unresolved counts nothing, so
// range); one that doesn't, activity counts as unresolved // neither does what it was for.
// and counts nothing, so neither does what it was for. if a.unresolved.contains(&e.id()) {
let Ok(flow) = ledger_core::parties::entry_flow(e, &money) else {
continue; continue;
}; }
let parts = self.party_what_for(e, party, &flow); for (account, amount) in self.party_what_for(e, party) {
for (account, amount) in parts {
*by.entry(account).or_default() += amount; *by.entry(account).or_default() += amount;
} }
} }
@@ -1525,26 +1546,22 @@ impl Books {
PartyFlow { PartyFlow {
paid: a.paid, paid: a.paid,
received: a.received, received: a.received,
paid_in_kind: a.paid_in_kind,
received_in_kind: a.received_in_kind,
what_for, what_for,
entries: posted.len(), entries: posted.len(),
} }
} }
/// ledger_core::parties::activity_with over `span`, with /// ledger_core::parties::activity_with over `span`, with Books::money
/// Books::money_cash_accounts and the members: the one source of a /// and the members: the one source of a party's money, the server's own
/// party's money, the server's own (books.party, books.parties). /// (books.party, books.parties).
pub fn activity( pub fn activity(
&self, &self,
party: PartyId, party: PartyId,
span: (NaiveDate, NaiveDate), span: (NaiveDate, NaiveDate),
) -> ledger_core::parties::Activity { ) -> ledger_core::parties::Activity {
ledger_core::parties::activity_with( ledger_core::parties::activity_with(&self.ledger, party, span, &self.money, &self.members)
&self.ledger,
party,
span,
&self.money_cash_accounts,
&self.members,
)
} }
/// The books dir: the folder holding `books-data/` (and `vault/`). /// 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 /// 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 /// 3): its share of the entry's money breakdown (ledger_core::parties::
/// money share exactly (zero when the entry moves no money, like a /// flow's what_for: a reversal's is its original's negated), summing
/// bill or a contribution of property). /// to its cash part of the entry exactly (nothing counted when the
/// entry's shares don't resolve).
/// ///
/// ```text /// ```text
/// The fraction: 1 for an entry with one party; else its resolved /// The fraction: the party's resolved part over the target (the sum of
/// share over what the shares split (the net, or the entry's total /// every party's part): 1 for an entry with one party.
/// debits when the net is zero). /// A member's own capital and distribution lines are theirs in full
/// A member's own capital and distribution lines are theirs in full; /// when the entry names several; another member's are never theirs.
/// another member's are never theirs. Every other line is scaled by /// Every other line is scaled by the fraction (to the cent, toward
/// the fraction (to the cent, toward zero), and the first scaled line /// zero), and the first scaled line takes what is left over so the
/// takes the leftover so the parts sum to the money share. /// 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( pub fn party_what_for(
&self, &self,
e: &ledger_core::JournalEntry, e: &ledger_core::JournalEntry,
party: PartyId, party: PartyId,
flow: &ledger_core::parties::EntryFlow,
) -> Vec<(AccountId, Decimal)> { ) -> Vec<(AccountId, Decimal)> {
use rust_decimal::RoundingStrategy::ToZero; use rust_decimal::RoundingStrategy::ToZero;
let parties = self.ledger.entry_parties(e.id()).len(); let Some(info) = self
if parties <= 1 { .entry_index
return flow.what_for.clone(); .get(&e.id())
} .and_then(|&i| self.entries.get(i))
let resolved = self.party_share(e, party); else {
// 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 {
return Vec::new(); return Vec::new();
}; };
let money_share = if flow.net.is_zero() { let Some(me) = info.parties.iter().find(|p| p.id == party) else {
Decimal::ZERO 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 { } 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| { let member_line = |a: AccountId| {
self.members self.members
.iter() .iter()
@@ -1682,9 +1734,12 @@ impl Books {
let mut own: Vec<(AccountId, Decimal)> = Vec::new(); let mut own: Vec<(AccountId, Decimal)> = Vec::new();
let mut scaled: Vec<(AccountId, Decimal)> = Vec::new(); let mut scaled: Vec<(AccountId, Decimal)> = Vec::new();
for &(a, v) in &flow.what_for { 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)) { if me.is_some_and(|m| m.capital == a || m.distributions == Some(a)) {
own.push((a, v)); own.push((a, v));
} else if !member_line(a) { } else if !several || !member_line(a) {
let Some(part) = v.checked_mul(fraction) else { let Some(part) = v.checked_mul(fraction) else {
return Vec::new(); return Vec::new();
}; };
@@ -1695,12 +1750,12 @@ impl Books {
.iter() .iter()
.chain(&scaled) .chain(&scaled)
.try_fold(Decimal::ZERO, |t, (_, v)| t.checked_add(*v)) .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 { else {
return Vec::new(); return Vec::new();
}; };
if !left.is_zero() { if !left.is_zero() {
match scaled.first_mut() { match scaled.first_mut().or(own.first_mut()) {
Some(first) => first.1 += left, Some(first) => first.1 += left,
None => own.push((e.lines()[0].account, left)), None => own.push((e.lines()[0].account, left)),
} }
@@ -1711,17 +1766,6 @@ impl Books {
.collect() .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 /// A member's capital statement over `span` (see
/// [`CapitalStatement`]); None when the party isn't a member. /// [`CapitalStatement`]); None when the party isn't a member.
pub fn capital_statement( pub fn capital_statement(
+63 -14
View File
@@ -94,8 +94,9 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
}; };
ui.add_space(4.0); ui.add_space(4.0);
let mut text = format!("{} {}", super::parties::party_icon(party), party.label()); let mut text = format!("{} {}", super::parties::party_icon(party), party.label());
if several { let what = share_text(share, several, e.reversal_of);
text.push_str(&format!(" · {}", share_text(share))); if !what.is_empty() {
text.push_str(&format!(" · {what}"));
} }
if widgets::link(ui, RichText::new(text).color(p.accent)) if widgets::link(ui, RichText::new(text).color(p.accent))
.on_hover_text(format!( .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", /// A party's part of an entry in words (rules.md B-35): which way it
/// "$33.34 (33.333%)", or the share alone when it doesn't resolve. /// went and how much ("paid $64.00", "received $740.00, in kind"), what of
pub fn share_text(s: &crate::books::EntryParty) -> String { /// it is still owed, the share it came from when the entry names several
let how = match s.share { /// ("(50%)", "(derived)"), and a reversal said as one ("paid $1,480.00
ledger_core::Share::Amount(_) => None, /// (reversal of #12)"), so a property contribution and its reversal never
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())), /// read alike. A part that moved nothing with the party (a bill, a close)
ledger_core::Share::Derived => Some("derived".to_string()), /// 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<ledger_core::EntryId>,
) -> 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) { let mut t = match (s.resolved, s.direction) {
(Some(v), Some(h)) => format!("{} ({h})", fmt::dollars(v.abs())), (Some(v), Some(d)) => {
(Some(v), None) => fmt::dollars(v.abs()), let amount = fmt::dollars(v.abs());
(None, Some(h)) => h, let mut t = match d {
(None, None) => match s.share { 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), ledger_core::Share::Amount(a) => fmt::dollars(a),
_ => String::new(), _ => String::new(),
}, },
_ => String::new(),
};
let mut notes: Vec<String> = 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)] #[cfg(test)]
+15
View File
@@ -483,6 +483,21 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
}; };
ev(icon::USER_GEAR, text, pid.map(Route::Party)) 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" => { "party.w9_set" => {
let pid = p.get("id").and_then(|v| v.as_u64()); let pid = p.get("id").and_then(|v| v.as_u64());
let name = party_name(b, pid); let name = party_name(b, pid);
+53 -6
View File
@@ -564,7 +564,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
ui.add( ui.add(
egui::Label::new( egui::Label::new(
RichText::new(format!( 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)) .font(theme::regular(11.5))
.color(p.faint), .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<String> {
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, /// A number tile on a party's page: label, amount, how it's computed,
/// and the line under it. /// and the line under it.
/// (label, value, how, sub); the value None when it can't be computed /// (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(( tiles.push((
"Paid out".into(), "Paid out".into(),
Some(flow.paid), 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)."), 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((label.clone(), p.muted)), Some((format!("{label} · cash basis"), p.muted)),
)); ));
tiles.push(( tiles.push((
"Received in".into(), "Received in".into(),
Some(flow.received), Some(flow.received),
format!("{label}, {span_words}: what this party's posted entries brought into cash and those accounts (net of reversals)."), format!("{label}, {span_words}, cash basis: the money this party's posted entries brought in (net of reversals)."),
Some((label.clone(), p.muted)), Some((format!("{label} · cash basis"), p.muted)),
)); ));
let net = flow.net(); let net = flow.net();
tiles.push(( 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())); 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. // A member's page is their capital account statement too.
if let Some(st) = b.capital_statement(id, span) { 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<Route>) {
} }
} }
ui.end_row(); 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 { if let Some(v) = party.vendor {
k(ui, "Vendor record"); k(ui, "Vendor record");
if widgets::link(ui, RichText::new(format!("vendor {v}")).color(p.accent)) 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<Route>
.collect(); .collect();
sub = format!( sub = format!(
"Their share, {} of {}, with {} · {sub}", "Their share, {} of {}, with {} · {sub}",
super::entry::share_text(mine), super::entry::share_text(mine, true, e.reversal_of),
fmt::dollars_or(e.amount), fmt::dollars_or(e.amount),
others.join(" & ") others.join(" & ")
); );
+8 -9
View File
@@ -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() { fn a_partys_part_of_a_split_entry_is_its_fraction_of_what_was_bought() {
let f = fixture("what-for"); let f = fixture("what-for");
let e = f.books.ledger.get_entry(f.entry).unwrap(); 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 flow = entry_flow(e, &money).unwrap();
let dec = |c: i64| Decimal::new(c, 2); let dec = |c: i64| Decimal::new(c, 2);
assert_eq!( let sam = f.books.party_what_for(e, f.sam);
f.books.party_what_for(e, f.sam, &flow), let riley = f.books.party_what_for(e, f.riley);
vec![(f.rent, dec(4_200)), (f.fees, dec(1_800))] 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))]);
assert_eq!( // Together, the entry's whole breakdown.
f.books.party_what_for(e, f.riley, &flow), let total: Decimal = sam.iter().chain(&riley).map(|(_, v)| *v).sum();
vec![(f.rent, dec(2_800)), (f.fees, dec(1_200))] assert_eq!(total, flow.net);
);
} }
/// share_of_what_for doc: the whole what-for for the whole net; nothing /// share_of_what_for doc: the whole what-for for the whole net; nothing
+267 -11
View File
@@ -8,7 +8,7 @@ use egui_kittest::Harness;
use ledger_core::arap::DocumentLine; use ledger_core::arap::DocumentLine;
use ledger_core::{ use ledger_core::{
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side, 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::books::Books;
use ledger_gui::nav::{Route, Section}; 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); let e = pay(&mut s, d(2026, 1, 20), "Old Co", cash, 12_300);
s.set_entry_party(e, Some(old)).unwrap(); s.set_entry_party(e, Some(old)).unwrap();
s.update_party(old, PartyField::Archived(true)).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); drop(s);
let oracle = Store::open(&data).unwrap().ledger().clone(); let oracle = Store::open(&data).unwrap().ledger().clone();
Fixture { 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, bill.balance().ok());
assert_eq!(sam.open_bills, Some(cents(30_000))); assert_eq!(sam.open_bills, Some(cents(30_000)));
assert_eq!(sam.bills, vec![1]); assert_eq!(sam.bills, vec![1]);
// The 1099 amount is ledger-core's worksheet line, exactly. // The 1099 amount is ledger-core's worksheet line, exactly, over the
let r = vendor_1099_report(o, 2026, None).unwrap(); // 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 let line = r
.vendors .vendors
.iter() .iter()
@@ -306,8 +321,9 @@ fn each_partys_totals_are_the_books_numbers() {
.unwrap(); .unwrap();
assert_eq!(sam.form_1099.as_ref(), Some(line)); assert_eq!(sam.form_1099.as_ref(), Some(line));
assert_eq!(sam.threshold_1099, Some(r.threshold)); assert_eq!(sam.threshold_1099, Some(r.threshold));
// (Cash only: card and owner-paid aren't in it.) // (Cash and what the owner paid for the company: the card isn't in
assert_eq!(line.paid, cents(150_000)); // it.)
assert_eq!(line.paid, cents(180_000));
// Every entry tagged, drafts included, newest first. // Every entry tagged, drafts included, newest first.
assert!(sam.entries.contains(&f.bill_entry)); assert!(sam.entries.contains(&f.bill_entry));
assert!(sam.entries.contains(&f.reversal)); 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<Route>)> = 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] #[test]
fn w9_text_never_shows_more_than_the_last_four() { fn w9_text_never_shows_more_than_the_last_four() {
let f = fixture("w9text"); 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)) { for period in b.party_periods(Some(p.id)) {
let span = b.party_span(period); let span = b.party_span(period);
let fl = b.party_flow(p.id, span, true); 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.paid, fl.received), (a.paid, a.received));
assert_eq!( assert_eq!(
fl.what_for.iter().map(|x| x.1).sum::<Decimal>(), fl.what_for.iter().map(|x| x.1).sum::<Decimal>(),
@@ -824,7 +891,7 @@ fn party_money_by_period_and_what_for() {
let (line, _) = b.party_1099(f.sam, 2025).unwrap(); let (line, _) = b.party_1099(f.sam, 2025).unwrap();
assert_eq!(line.paid, cents(50_000)); assert_eq!(line.paid, cents(50_000));
let (line, _) = b.party_1099(f.sam, 2026).unwrap(); let (line, _) = b.party_1099(f.sam, 2026).unwrap();
assert_eq!(line.paid, cents(150_000)); assert_eq!(line.paid, cents(180_000));
} }
#[test] #[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)))] vec![(m.tobin, Some(cents(5_000))), (m.mo, Some(cents(5_000)))]
); );
assert_eq!(e.party.as_deref(), Some("Tobin Harlow & Morgan Ellis")); 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(); 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. // The journal says who, for parties_set.
let line = b let line = b
.journal .journal
@@ -1257,6 +1334,185 @@ fn a_members_page_is_their_capital_statement() {
h.get_by_label("Contributions of property (at fair market value)"); 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::<Decimal>(),
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, /// The viewer's statement is the K-1 worksheet's row, field for field,
/// over any span (CapitalStatement INVARIANT 1): ledger_core::k1 called /// over any span (CapitalStatement INVARIANT 1): ledger_core::k1 called
/// here with the members and cash accounts as the CPA package builds them. /// 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 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 .cash
.into_iter() .into_iter()
.collect(); .collect();
@@ -1521,7 +1777,7 @@ fn party_money_is_the_servers_without_cash_accounts_and_after_today() {
&oracle, &oracle,
apple, apple,
(d(2026, 1, 1), d(2026, 12, 31)), (d(2026, 1, 1), d(2026, 12, 31)),
&[], &ledger_core::parties::money_accounts(&oracle, &[], &[]),
&[], &[],
); );
assert_eq!( assert_eq!(
@@ -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). - 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). - 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. - 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. - 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. - 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). - 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. - 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.
+103 -49
View File
@@ -2026,7 +2026,7 @@ fn party_roles(p: &ledger_core::Party) -> Vec<String> {
} }
/// A party's facts for a tool's output. /// 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 { PartyOut {
party_id: p.id, party_id: p.id,
name: p.name.clone(), name: p.name.clone(),
@@ -2059,6 +2059,11 @@ fn party_out(p: &ledger_core::Party) -> PartyOut {
in_vault: t.vault_record.is_some(), in_vault: t.vault_record.is_some(),
purged: t.purged.clone(), 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 { struct PartyActivity {
/// The party's entries, drafts included. /// The party's entries, drafts included.
entries: std::collections::BTreeSet<u64>, entries: std::collections::BTreeSet<u64>,
/// Paid to it in the year. /// Paid to it in the year, on the basis asked.
paid: Decimal, paid: Decimal,
/// Received from it in the year. /// Received from it in the year, on the basis asked.
received: Decimal, 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<ledger_core::parties::Basis, String> {
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 /// (`PartyTotalsOut`): ledger_core::parties::activity_with over the
/// calendar year, with the `[books] cash_accounts` and the members (so an /// calendar year, with the money accounts of [`share_context`] and the
/// entry naming several parties counts this one's share). /// members (so an entry naming several parties counts this one's part).
fn party_activity( fn party_activity(
store: &Store, store: &Store,
party: &ledger_core::Party, party: &ledger_core::Party,
settings: &Settings, settings: &Settings,
year: i32, year: i32,
basis: ledger_core::parties::Basis,
) -> Result<PartyActivity, String> { ) -> Result<PartyActivity, String> {
let ledger = store.ledger(); let ledger = store.ledger();
let cash = cash_ids(ledger, &settings.roles.cash);
let span = ( let span = (
NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?, 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")?, NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?,
); );
let members = settings.company.party_members(ledger); let (money, members) = share_context(ledger, settings);
let a = ledger_core::parties::activity_with(ledger, party.id, span, &cash, &members); let a = ledger_core::parties::activity_with(ledger, party.id, span, &money, &members);
let (paid, received) = a.totals(basis);
Ok(PartyActivity { Ok(PartyActivity {
entries: a.entries, entries: a.entries,
paid: a.paid, paid,
received: a.received, received,
}) })
} }
@@ -2175,7 +2191,8 @@ fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> {
} }
/// What party shares resolve with ([`ledger_core::shares::resolve`]): the /// 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( fn share_context(
ledger: &Ledger, ledger: &Ledger,
settings: &Settings, settings: &Settings,
@@ -2184,7 +2201,11 @@ fn share_context(
Vec<ledger_core::PartyMember>, Vec<ledger_core::PartyMember>,
) { ) {
( (
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), settings.company.party_members(ledger),
) )
} }
@@ -2252,7 +2273,7 @@ fn check_shares_resolve(
return Ok(()); return Ok(());
} }
let (money, members) = share_context(ledger, settings); 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 /// 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(()); return Ok(());
} }
let (money, members) = share_context(ledger, settings); 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(()); return Ok(());
}; };
if parts.iter().any(|(_, v)| v.is_zero()) { if resolved.parts.iter().any(|p| p.amount.is_zero()) {
return Ok(()); return Ok(());
} }
let fixed: Vec<ledger_core::PartyShare> = parts let fixed: Vec<ledger_core::PartyShare> = resolved
.into_iter() .parts
.map(|(party, v)| ledger_core::PartyShare { .iter()
party, .map(|p| ledger_core::PartyShare {
share: ledger_core::Share::Amount(v.abs()), party: p.party,
share: ledger_core::Share::Amount(p.amount.abs()),
}) })
.collect(); .collect();
store store
@@ -2317,10 +2340,11 @@ fn entry_parties_out(
let ledger = store.ledger(); let ledger = store.ledger();
let shares = ledger.entry_parties(entry.id()); let shares = ledger.entry_parties(entry.id());
let (accounts, members) = share_context(ledger, settings); let (accounts, members) = share_context(ledger, settings);
let (resolved, error) = match ledger_core::shares::resolve(entry, shares, &accounts, &members) { let (resolved, error) =
Ok(r) => (Some(r), None), match ledger_core::shares::resolve(ledger, entry.id(), shares, &accounts, &members) {
Err(e) => (None, Some(e)), Ok(r) => (Some(r), None),
}; Err(e) => (None, Some(e)),
};
let out = shares let out = shares
.iter() .iter()
.enumerate() .enumerate()
@@ -2341,7 +2365,12 @@ fn entry_parties_out(
share: kind.to_string(), share: kind.to_string(),
amount, amount,
percent, 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(); .collect();
@@ -6906,13 +6935,13 @@ impl BooksServer {
.add_party(&p.name, &roles) .add_party(&p.name, &roles)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let party = store.ledger().get_party(id).expect("just added"); let party = store.ledger().get_party(id).expect("just added");
Ok(Json(party_out(party))) Ok(Json(party_out(store.ledger(), party)))
} }
#[tool( #[tool(
name = "books.update_party", name = "books.update_party",
title = "Update a 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( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -6946,24 +6975,46 @@ impl BooksServer {
if let Some(a) = p.archived { if let Some(a) = p.archived {
fields.push(F::Archived(a)); 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<AccountId> = codes
.iter()
.map(|c| resolve_account(&store, c.trim()))
.collect::<Result<_, _>>()?;
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( 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(), .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(); let mut rehearsal = store.ledger().clone();
for f in &fields { for f in &fields {
rehearsal rehearsal
.update_party(id, f.clone()) .update_party(id, f.clone())
.map_err(|e| e.to_string())?; .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 { for f in fields {
store.update_party(id, f).map_err(|e| e.to_string())?; 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"); let party = store.ledger().get_party(id).expect("resolved");
Ok(Json(party_out(party))) Ok(Json(party_out(store.ledger(), party)))
} }
#[tool( #[tool(
@@ -7030,7 +7081,7 @@ impl BooksServer {
store.set_party_role(id, r, on).map_err(|e| e.to_string())?; store.set_party_role(id, r, on).map_err(|e| e.to_string())?;
} }
let party = store.ledger().get_party(id).expect("resolved"); let party = store.ledger().get_party(id).expect("resolved");
Ok(Json(party_out(party))) Ok(Json(party_out(store.ledger(), party)))
} }
/// ```text /// ```text
@@ -7272,7 +7323,7 @@ impl BooksServer {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
let party = store.ledger().get_party(id).expect("resolved"); let party = store.ledger().get_party(id).expect("resolved");
Ok(Json(party_out(party))) Ok(Json(party_out(store.ledger(), party)))
} }
/// ```text /// ```text
@@ -7359,7 +7410,7 @@ impl BooksServer {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
let party = store.ledger().get_party(id).expect("resolved"); let party = store.ledger().get_party(id).expect("resolved");
Ok(Json(party_out(party))) Ok(Json(party_out(store.ledger(), party)))
} }
/// ```text /// ```text
@@ -7494,11 +7545,7 @@ impl BooksServer {
name: party.name.clone(), name: party.name.clone(),
tin_in_vault: held_tin, tin_in_vault: held_tin,
w9_file_in_vault: held_w9, w9_file_in_vault: held_w9,
tin_last4: party tin_last4: party.tin_last4().map(str::to_string),
.w9
.as_ref()
.map(|f| f.tin_last4.clone())
.or_else(|| party.tin.as_ref().map(|t| t.last4.clone())),
journaled: false, journaled: false,
note: format!( note: format!(
"Confirming deletes {}{}; backups made before keep their copies. Make sure the CPA has what they need first.", "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)?; let id = resolve_party(&store, &p.party)?;
store.set_party_w4(id, facts).map_err(|e| e.to_string())?; store.set_party_w4(id, facts).map_err(|e| e.to_string())?;
let party = store.ledger().get_party(id).expect("resolved"); let party = store.ledger().get_party(id).expect("resolved");
Ok(Json(party_out(party))) Ok(Json(party_out(store.ledger(), party)))
} }
#[tool( #[tool(
name = "books.parties", name = "books.parties",
title = "List parties", title = "List parties",
description = described( 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 ledger_core::calendar::PeriodKind::CalendarYear
), ),
annotations(read_only_hint = true, open_world_hint = false) 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])) .map(|r| parse_roles(&[r.to_string()]).map(|v| v[0]))
.transpose()?; .transpose()?;
let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); let year = p.year.unwrap_or_else(|| self.periods().calendar_year());
let basis = parse_basis(p.basis.as_deref())?;
let settings = self.settings(); let settings = self.settings();
let store = self.lock()?; let store = self.lock()?;
let mut parties = Vec::new(); let mut parties = Vec::new();
@@ -7611,7 +7659,7 @@ impl BooksServer {
if role.is_some_and(|r| !party.all_roles().contains(&r)) { if role.is_some_and(|r| !party.all_roles().contains(&r)) {
continue; continue;
} }
let act = party_activity(&store, party, &settings, year)?; let act = party_activity(&store, party, &settings, year, basis)?;
parties.push(PartySummaryOut { parties.push(PartySummaryOut {
party_id: party.id, party_id: party.id,
name: party.name.clone(), name: party.name.clone(),
@@ -7620,21 +7668,25 @@ impl BooksServer {
vendor_id: party.vendor, vendor_id: party.vendor,
customer_id: party.customer, customer_id: party.customer,
w9_on_file: party.w9.is_some(), 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(), w4_on_file: party.w4.is_some(),
archived: party.archived, archived: party.archived,
paid: money(act.paid), paid: money(act.paid),
received: money(act.received), received: money(act.received),
}); });
} }
Ok(Json(ListPartiesOut { year, parties })) Ok(Json(ListPartiesOut {
year,
basis: basis.key().to_string(),
parties,
}))
} }
#[tool( #[tool(
name = "books.party", name = "books.party",
title = "Get a party", title = "Get a party",
description = described( 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 ledger_core::calendar::PeriodKind::CalendarYear
), ),
annotations(read_only_hint = true, open_world_hint = false) annotations(read_only_hint = true, open_world_hint = false)
@@ -7642,12 +7694,13 @@ impl BooksServer {
async fn party(&self, params: Parameters<PartyParams>) -> Result<Json<PartyDetailOut>, String> { async fn party(&self, params: Parameters<PartyParams>) -> Result<Json<PartyDetailOut>, String> {
let p = params.0; let p = params.0;
let year = p.year.unwrap_or_else(|| self.periods().calendar_year()); let year = p.year.unwrap_or_else(|| self.periods().calendar_year());
let basis = parse_basis(p.basis.as_deref())?;
let settings = self.settings(); let settings = self.settings();
let store = self.lock()?; let store = self.lock()?;
let ledger = store.ledger(); let ledger = store.ledger();
let id = resolve_party(&store, &p.party)?; let id = resolve_party(&store, &p.party)?;
let party = ledger.get_party(id).expect("resolved"); 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 in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year;
let mut entries: Vec<PartyEntryOut> = act let mut entries: Vec<PartyEntryOut> = act
.entries .entries
@@ -7704,6 +7757,7 @@ impl BooksServer {
} }
} }
let totals = PartyTotalsOut { let totals = PartyTotalsOut {
basis: basis.key().to_string(),
paid: money(act.paid), paid: money(act.paid),
received: money(act.received), received: money(act.received),
invoiced: money(checked_sum( invoiced: money(checked_sum(
@@ -7734,7 +7788,7 @@ impl BooksServer {
net_pay: money(net), net_pay: money(net),
}; };
Ok(Json(PartyDetailOut { Ok(Json(PartyDetailOut {
party: party_out(party), party: party_out(ledger, party),
year, year,
totals, totals,
entries, entries,
@@ -7769,7 +7823,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.set_entry_party", name = "books.set_entry_party",
title = "Tag an entry's parties", 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( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
+103 -27
View File
@@ -1256,11 +1256,33 @@ pub struct EntryPartyOut {
pub amount: Option<String>, pub amount: Option<String>,
/// The percent given, for a percent share, decimal string. /// The percent given, for a percent share, decimal string.
pub percent: Option<String>, pub percent: Option<String>,
/// Its exact part of what the entry moved (positive: paid to it; /// Its exact part of what the entry moved, signed as a line on the
/// negative: received from it; for an entry moving no money, its part /// party's own account would be (debit-positive): positive, the
/// of the total), decimal string. Null when the shares don't resolve /// company paid it or gave it value; negative, received from it. With
/// (see `parties_error`). /// 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<String>, pub resolved: Option<String>,
/// "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<String>,
/// 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<String>,
/// 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<String>,
} }
/// Result of `books.draft_entry`. /// Result of `books.draft_entry`.
@@ -3000,6 +3022,14 @@ pub struct UpdatePartyParams {
/// resolves by name, and its name is free for another party). False: /// resolves by name, and its name is free for another party). False:
/// dealt with again (refused while another party has its name). /// dealt with again (refused while another party has its name).
pub archived: Option<bool>, pub archived: Option<bool>,
/// 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<Vec<String>>,
} }
/// Parameters for `books.set_party_roles`. Give at least one role to add /// Parameters for `books.set_party_roles`. Give at least one role to add
@@ -3243,6 +3273,10 @@ pub struct ListPartiesParams {
/// so year to date). /// so year to date).
#[schemars(description = YEAR_DOC)] #[schemars(description = YEAR_DOC)]
pub year: Option<i32>, pub year: Option<i32>,
/// 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<String>,
} }
/// Parameters for `books.party`. /// Parameters for `books.party`.
@@ -3254,6 +3288,10 @@ pub struct PartyParams {
/// date). /// date).
#[schemars(description = YEAR_DOC)] #[schemars(description = YEAR_DOC)]
pub year: Option<i32>, pub year: Option<i32>,
/// 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<String>,
} }
/// Parameters for `books.set_entry_party`. /// Parameters for `books.set_entry_party`.
@@ -3345,6 +3383,10 @@ pub struct PartyOut {
pub w4: Option<W4Out>, pub w4: Option<W4Out>,
/// Taxpayer-ID facts (any role), if on file: type and last four. /// Taxpayer-ID facts (any role), if on file: type and last four.
pub tin: Option<TinOut>, pub tin: Option<TinOut>,
/// 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<String>,
} }
/// One party in `books.parties`. /// One party in `books.parties`.
@@ -3364,16 +3406,19 @@ pub struct PartySummaryOut {
pub customer_id: Option<u64>, pub customer_id: Option<u64>,
/// W-9 facts are on file. /// W-9 facts are on file.
pub w9_on_file: bool, 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<String>, pub tin_last4: Option<String>,
/// W-4 facts are on file. /// W-4 facts are on file.
pub w4_on_file: bool, pub w4_on_file: bool,
/// No longer dealt with. /// No longer dealt with.
pub archived: bool, pub archived: bool,
/// Paid to the party in the year, decimal string (as `books.party` /// Paid to the party in the year on `basis`, decimal string (as
/// totals). /// `books.party` totals).
pub paid: String, 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). /// `books.party` totals).
pub received: String, pub received: String,
} }
@@ -3383,6 +3428,8 @@ pub struct PartySummaryOut {
pub struct ListPartiesOut { pub struct ListPartiesOut {
/// The year `paid` and `received` cover. /// The year `paid` and `received` cover.
pub year: i32, pub year: i32,
/// What `paid` and `received` count: "cash" or "all".
pub basis: String,
/// Parties, by id. /// Parties, by id.
pub parties: Vec<PartySummaryOut>, pub parties: Vec<PartySummaryOut>,
} }
@@ -3394,30 +3441,59 @@ pub struct ListPartiesOut {
/// the payments received on them, and reversals of any of these. Only /// the payments received on them, and reversals of any of these. Only
/// posted entries dated in the calendar year count. /// posted entries dated in the calendar year count.
/// ///
/// Each entry's money is `ledger_core::parties::entry_flow`: what left or /// `paid` and `received` add up each entry's part for the party, as
/// reached the `[books] cash_accounts` (every asset account but /// `books.get_entry` shows it (`resolved`, `direction`, `cash`,
/// receivables when none are set), plus what the company paid through a /// `in_kind`): on `basis` "cash" (the default) what moved as money, on
/// liability (a card, a member paying for it, a loan; not payables or /// "all" that plus property contributed or distributed in kind. A
/// payroll withholdings), with cash that pays a liability down counting /// reversal counts on its original's side and nets it to zero. (Not the
/// once, as paid to that liability's creditor (a reimbursement to the /// books' accounting method.)
/// 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 /// Money is what left or reached the `[books] cash_accounts` (every asset
/// funds, a loan paying it off), and cash raised by a liability counting /// account but receivables when none are set), the cards (liabilities
/// as received from the lender. Positive is paid to the party, negative /// whose names say "card", not "gift card"), and the `[books]
/// received from it; a reversal counts against its root original's side /// financing_accounts` (what the members
/// (reversing the reversal counts on that side again). A transfer between the company's own cash accounts nets to zero. /// advanced, loans): a card charge or a member paying for the company is
/// A bill is not paid until its payment is. /// paid to the payee then; cash that pays one of these down is paid to
/// An entry naming several parties counts each one's share of that money /// its creditor (a reimbursement to the members, a card payment to the
/// (its amount, its percent, or its derived share: see /// issuer); cash it raises is received from the lender. Any other
/// `books.set_entry_party` `parties`); an entry whose shares don't resolve /// liability is a promise (a bill, an accrual, deferred revenue): counted
/// counts for none of them. /// 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]]`, /// (`ledger_core::parties::activity_with`, with the `[[company.members]]`,
/// computes it; the viewer shows the same numbers.) /// computes it; the viewer shows the same numbers.)
#[derive(Debug, Clone, Serialize, JsonSchema)] #[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct PartyTotalsOut { 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, pub paid: String,
/// Received from the party, decimal string. /// Received from the party on `basis`, decimal string.
pub received: String, pub received: String,
/// Its customer record's invoices dated in the year, total, decimal /// Its customer record's invoices dated in the year, total, decimal
/// string. /// string.
+24 -7
View File
@@ -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["received"], "2000.00", "a contribution");
assert_eq!(t["paid"], "500.00", "a distribution"); assert_eq!(t["paid"], "500.00", "a distribution");
// A prepaid bought with a member's card: an asset, paid by a liability. // A prepaid bought with a member's card: an asset, paid through what
m.add_party("Insurer", &[]); // the members advanced. That is money when it is a financing account
m.post( // (rules.md B-35 item 5, P3 defect 24); unlisted, Due to Members is a
"2026-04-01", // promise like any other liability, counted when the member is paid
json!({"party": "Insurer"}), // back.
&[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")], 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["paid"], "1200.00");
assert_eq!(t["received"], "0.00"); assert_eq!(t["received"], "0.00");
} }
@@ -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<String>,
child: Child,
next_id: u64,
data: PathBuf,
validators: HashMap<String, jsonschema::Validator>,
/// The books dir when the server owns it: removed after the server
/// stops (fields drop after `Drop::drop`).
books_dir: Option<TestDir>,
}
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<std::path::Path> 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<u8> {
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
}
/// The journal's line types, in order.
fn journal_types(&self) -> Vec<String> {
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<Value> {
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<String> = 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<Value> {
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")));
}
+58
View File
@@ -2219,3 +2219,61 @@ fn idempotent_tools_change_nothing_when_repeated() {
.collect(); .collect();
assert!(wrong.is_empty(), "{wrong:#?}"); 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));
}
+9 -6
View File
@@ -263,7 +263,11 @@ pub fn build_package<B: PackageBooks + ?Sized>(
} }
None => { None => {
cells.push( 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( cells.push(
facts facts
@@ -396,11 +400,10 @@ pub fn build_package<B: PackageBooks + ?Sized>(
// The payee's full TIN from the source (INVARIANT 2). // The payee's full TIN from the source (INVARIANT 2).
if let Some(tins) = tins { if let Some(tins) = tins {
let party = ledger.party_of_vendor(v.vendor_id); let party = ledger.party_of_vendor(v.vendor_id);
let last4 = party.and_then(|id| ledger.get_party(id)).and_then(|p| { let last4 = party
p.w9.as_ref() .and_then(|id| ledger.get_party(id))
.map(|f| f.tin_last4.clone()) .and_then(|p| p.tin_last4())
.or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) .map(str::to_string);
});
let got = match party { let got = match party {
None => Err("TIN not in the vault: no party for this vendor".into()), None => Err("TIN not in the vault: no party for this vendor".into()),
Some(id) => tins(id), Some(id) => tins(id),
+4 -5
View File
@@ -173,11 +173,10 @@ pub fn vendor_1099_worksheet(
let mut lines = Vec::new(); let mut lines = Vec::new();
for v in r.vendors { for v in r.vendors {
let party_id = ledger.party_of_vendor(v.vendor_id); 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| { let tin_last4 = party_id
p.w9.as_ref() .and_then(|id| ledger.get_party(id))
.map(|f| f.tin_last4.clone()) .and_then(|p| p.tin_last4())
.or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) .map(str::to_string);
});
let mut line = Vendor1099WorksheetLine { let mut line = Vendor1099WorksheetLine {
vendor_id: v.vendor_id, vendor_id: v.vendor_id,
name: v.name, name: v.name,
+6
View File
@@ -43,6 +43,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
MEMBERS_CUMULATIVE, MEMBERS_CUMULATIVE,
FEATURE_ACCOUNT_RENAMED, FEATURE_ACCOUNT_RENAMED,
FEATURE_REQUIRE_TAX_CATEGORY, FEATURE_REQUIRE_TAX_CATEGORY,
PARTY_CREDITORS,
]; ];
/// Feature: `period.closed` closes to members with `"rounding": /// 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. /// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set"; pub const FORMAT_SET: &str = "journal.format_set";
@@ -119,6 +124,7 @@ pub const EVENT_TYPES: &[&str] = &[
"open_item.raised", "open_item.raised",
"open_item.resolved", "open_item.resolved",
"party.added", "party.added",
"party.creditor_set",
"party.role_set", "party.role_set",
"party.tin_set", "party.tin_set",
"party.updated", "party.updated",
+4 -9
View File
@@ -55,16 +55,11 @@ pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
.collect() .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<AccountId> { pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
ledger ledger_core::parties::card_accounts(ledger)
.chart()
.all()
.filter(|a| {
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
})
.map(|a| a.id)
.collect()
} }
/// The accounts the books reconcile monthly (INVARIANT 1). /// The accounts the books reconcile monthly (INVARIANT 1).
+33 -2
View File
@@ -1238,6 +1238,30 @@ impl Store {
Ok(()) 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`. /// Record or clear a party's W-9 facts. Journaled as `party.w9_set`.
pub fn set_party_w9( pub fn set_party_w9(
&mut self, &mut self,
@@ -4844,6 +4868,13 @@ pub(crate) fn replay_line_into_ledger(
.ok_or_else(|| StoreError::JournalCorrupt("party.role_set on".into()))?; .ok_or_else(|| StoreError::JournalCorrupt("party.role_set on".into()))?;
ledger.set_party_role(get_u64(p, "id")?, role, on)?; 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" => { "party.w9_set" => {
let facts: Option<ledger_core::W9Facts> = serde_json::from_value(p["facts"].clone()) let facts: Option<ledger_core::W9Facts> = serde_json::from_value(p["facts"].clone())
.map_err(|_| StoreError::JournalCorrupt("party.w9_set facts".into()))?; .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" "role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set"
| "party.w4_set" | "party.tin_set" | "entry.party_set" | "entry.parties_set" | "party.w4_set" | "party.tin_set" | "party.creditor_set" | "entry.party_set"
| "entry.no_party" => { | "entry.parties_set" | "entry.no_party" => {
// Control roles and the party directory live in the ledger // Control roles and the party directory live in the ledger
// (rebuilt by replay); the events mirror below keeps the // (rebuilt by replay); the events mirror below keeps the
// history. // history.
@@ -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<u8> {
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"
);
}
}
+5 -4
View File
@@ -1940,7 +1940,7 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
.map(|_| ()), .map(|_| ()),
); );
} }
let money = ledger_core::parties::money_accounts(l, &cash); let money = ledger_core::parties::money_accounts(l, &cash, &[]);
let party_members: Vec<ledger_core::PartyMember> = l let party_members: Vec<ledger_core::PartyMember> = l
.parties() .parties()
.map(|p| ledger_core::PartyMember { .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() { for id in 1..l.next_entry_id() {
let Some(e) = l.get_entry(id) else { continue }; let Some(e) = l.get_entry(id) else { continue };
tally(ledger_core::parties::entry_flow(e, &money).map(|_| ())); tally(ledger_core::parties::entry_flow(e, &money).map(|_| ()));
tally(ledger_core::shares::share_target(e, &money).map(|_| ())); tally(ledger_core::shares::target(l, id, l.entry_parties(id), &money).map(|_| ()));
let _ = ledger_core::shares::resolve(e, l.entry_parties(id), &money, &party_members); 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()); let _ = e.validate(l.chart());
} }
for p in l.parties() { for p in l.parties() {
for (start, end) in spans { for (start, end) in spans {
let _ = 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() { for inv in l.invoices() {
+90 -32
View File
@@ -856,12 +856,15 @@ texts are at most 500 characters, no control characters.
- `books.add_party { name, roles? }` → the party (below). Journaled as - `books.add_party { name, roles? }` → the party (below). Journaled as
`party.added`. `party.added`.
- `books.update_party { party, name?, display_name?, website?, address?, - `books.update_party { party, name?, display_name?, website?, address?,
notes?, archived? }` → the party. Give at least one field; `""` clears notes?, archived?, creditor_of? }` → the party. Give at least one field;
`display_name`, `website`, `address`, or `notes`. Every field is checked `""` 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 (in order, on a rehearsal) before any is written; each is journaled as
its own `party.updated`, in the order name, display_name, website, its own `party.updated`, in the order name, display_name, website,
address, notes, archived. Un-archiving is refused while another party address, notes, archived, then one `party.creditor_set` per account
has the name. added or dropped. Un-archiving is refused while another party has the
name.
- `books.set_party_roles { party, add?, remove? }` → the party. Adds apply - `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 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 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. "require"`, any party (or the no-party mark) satisfies the check.
Reversals carry every share. There is no "joint" party: a joint member Reversals carry every share. There is no "joint" party: a joint member
transaction names both members and lets the shares derive. transaction names both members and lets the shares derive.
- `books.parties { role?, include_archived?, year? }` → `{ year, parties: - `books.parties { role?, include_archived?, year?, basis? }` → `{ year,
[{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file, basis, parties: [{ party_id, name, label, roles, vendor_id?,
tin_last4?, w4_on_file, archived, paid, received }] }`, by id. customer_id?, w9_on_file, tin_last4?, w4_on_file, archived, paid,
- `books.party { party, year? }` → `{ party, year, totals, entries, received }] }`, by id. `tin_last4` is the TIN's last four when the books
invoices, bills, payroll }`: the facts; the calendar year's totals know it: its TIN facts' (`books.set_tin`; a member's SSN for the K-1),
(default this year); every entry it is on (drafts included, newest else its W-9 facts' (the two never disagree), null with neither, kept
first); its invoices and bills (`{ id, date, due_date, total, paid, after `books.purge_tin` (P3 defect 11: it read W-9 facts only, so a
balance, status, entry_id }`); and the payroll lines naming it 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 }`). (`{ 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, The party (every mutating party tool returns it): `{ party_id, name,
label, display_name?, roles, website?, address?, notes?, archived, label, display_name?, roles, website?, address?, notes?, archived,
vendor_id?, customer_id?, w9?: { received_on, entity_type, vendor_id?, customer_id?, w9?: { received_on, entity_type,
exempt_payee_code?, fatca_code?, tin_last4, kept_at, in_vault }, w4?: { received_on, 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` state, kept_at }, tin?, creditor_of }`. `label` is the display name, else
include vendor and customer when it has those records. 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 **Totals** (`books.party` `totals`; `paid` and `received` in
`books.parties`), from the journal: `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 record (bills and their payments); its customer record's invoices and
the payments received on them; and reversals of any of these. Only the payments received on them; and reversals of any of these. Only
posted entries dated in the year count. posted entries dated in the year count.
- *Money accounts:* the `[books] cash_accounts` (every asset account but - *Money accounts* (B-35 item 5, P3 defect 24): the `[books]
receivables when none are set), and every liability account except the cash_accounts` (every asset account but receivables when none are set),
payables and payroll withholding roles (`books.roles`). A liability the cards (liability accounts whose names say "card", not "gift card":
counts as how the company paid or was paid: a card, a member paying for the rule the reconciliation reminders read), and the `[books]
it, a loan. financing_accounts` (what the members advanced, loans). Any other
- `paid` / `received`: each entry's money lines, credits minus debits liability (an accrued expense, deferred revenue, gift cards sold,
(`ledger_core::parties::entry_flow`), with a liability paid down distributions payable) is a promise, counted when it is settled, never
counting once, as paid to its creditor: by cash (a reimbursement to the also when it is booked. A liability owed to a party the entry names (`creditor_of`)
members, a card payment) or by another liability (a member paying the isn't money for that entry: a credit to it never counts as paying the
card from their own funds, a loan paying it off); cash raised by a party.
liability is received from the lender. A positive net was paid to the - `paid` / `received`: each entry's part for the party, added up on its
party, a negative one received from it; a reversal counts against its root original's side (a reversal subtracts there, and reversing the
root original's side (reversing the reversal counts on that side reversal adds back): on basis `cash`, the `cash` parts; on basis `all`,
again). A bill isn't paid until its payment is; a transfer between the `cash` plus `in_kind`. A cash payment, a card charge, a member paying
company's own cash accounts nets to zero. An entry naming several for the company: paid to the payee then; cash paying one of the money
parties counts this party's resolved share only (an entry whose shares liabilities down: paid to its creditor (a reimbursement to the members,
don't resolve counts for none). The year is the whole calendar year; a card payment to the issuer); cash raised by one: received from the
the viewer's party page and directory show these same numbers. 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. - `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
`owed` / `receivable`: its open bills' and invoices' balances now (every `owed` / `receivable`: its open bills' and invoices' balances now (every
year). year).
+51 -16
View File
@@ -77,7 +77,10 @@ Rules (to vendor as B-26 with sources):
address, notes, archived), `party.role_set {id, role, on}`, address, notes, archived), `party.role_set {id, role, on}`,
`party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`, `party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`,
`party.tin_set {id, facts|null}` (TIN facts: type, last four, vault `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 `entry.parties_set {entry_id, parties: [{party_id, share}]}` (a share is
`{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`; `[]` `{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`; `[]`
clears). Replayed and projected like clears). Replayed and projected like
@@ -95,9 +98,24 @@ derived. Spec: `ledger_core::shares`.
(`set_entry_party`) is one derived share: all of it. (`set_entry_party`) is one derived share: all of it.
`Ledger::entry_party` means "the sole party"; `entry_parties` lists `Ledger::entry_party` means "the sole party"; `entry_parties` lists
them all. them all.
- **What a share is of:** the entry's `entry_flow` net (what it moved - **What a share is of** (rules.md B-35, P3 defect 14): one signed
with its counterparties), or its total debits when the net is zero (a target, debit-positive as a line on the parties' own account (positive:
member contributing property moves no money but is still theirs). 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 - **Amount:** exactly that, positive, in cents; the amounts must sum to
what the entry moved. what the entry moved.
- **Percent:** more than 0, at most 100, summing to exactly 100. Each part - **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 checks only their form; the MCP server checks they resolve once the
entry has balanced lines, and `post_entry` refuses (`party_shares`) entry has balanced lines, and `post_entry` refuses (`party_shares`)
until they do. Replay never consults settings. until they do. Replay never consults settings.
- **Money:** `parties::activity_with` counts each party's resolved share; - **Money:** `parties::activity_with` counts each party's part on two
an entry moving no money counts nothing; an entry whose shares don't bases (B-35 item 4): cash (its money: cash, a card, the members'
resolve counts for no one (listed as unresolved). Reversals carry financing, a loan) and all (adding property contributed or distributed
every share, so a reversal subtracts each party's part; re-tagging in kind), the viewer labelling the first "cash basis". Money accounts
either one (parties, shares, or vendor) re-tags the other, so the pair are the cash accounts, the cards and `[books] financing_accounts`; any
always nets to zero. 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 A reversal counts on its root original's side, so reversing the
reversal reinstates the payment as paid (never a negative received). 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 - **One number everywhere:** `books.party`, `books.parties`, and the
viewer's party page and directory read cash as the server does viewer's party page and directory read money as the server does
(`[books] cash_accounts`, else every asset account but receivables) (`[books] cash_accounts`, else every asset account but receivables;
and count the whole calendar year, so a posted entry dated later this the cards; `[books] financing_accounts`) and count the whole calendar
year counts in both. The viewer shows the year up to the later of today 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. and its last posted entry.
- **For the agent:** a joint member transaction (a reimbursement on Due - **For the agent:** a joint member transaction (a reimbursement on Due
to Shareholders, a contribution or distribution from joint funds) 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 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 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 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 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 ## The TIN vault
+196 -5
View File
@@ -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 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 toward its 1099: what was paid to the payee (§6041(a)), not what the
entry moved for others. The share is resolved (`shares::resolve`) entry moved for others. The share is resolved (`shares::resolve`)
against the entry's flow, the same target the MCP server checks shares against the entry's target (B-35: its money, or, when it moved none, its
against, then scaled to the 1099 payment: the share unchanged when the 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 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, 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 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). PDF instead).
A locked vault or a missing TIN never fails A locked vault or a missing TIN never fails
the package: it says which TINs are missing. `books.purge_tin` deletes a 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, **Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check,
W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`, 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 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 readable by it. P3's: `members_cumulative` (B-13: a members close counted
from its count's first close, `"rounding": "cumulative_since"`), 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 `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). go-ahead before the first such declaration (plan D-39).
**Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`, **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`), `iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance`),
`mcp_stdio.rs` (`mcp_import_export_over_stdio`, `mcp_stdio.rs` (`mcp_import_export_over_stdio`,
`the_default_period_never_shortens_the_iif_or_the_package`). `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`.
@@ -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: <https://tile.loc.gov/storage-services/service/ll/usrep/usrep309/usrep309409/usrep309409.pdf> (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.
+35
View File
@@ -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: <https://www.ecfr.gov/api/renderer/v1/content/enhanced/current/title-26?part=1&section=1.704-1>
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: <https://www.irs.gov/instructions/i1065sk1>
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.
+49
View File
@@ -1237,6 +1237,55 @@ range, `export_journal`'s period, `described`, `uncategorized_warning`,
`handbook::period_rule`. The viewer is not mutated (owner, 2026-10-18): `handbook::period_rule`. The viewer is not mutated (owner, 2026-10-18):
`view_model.rs` and `show.rs` cover it. `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) ## Benchmarks (baseline 2026-10-13)
No budget, by decision (the owner, 2026-10-13: the books do little work No budget, by decision (the owner, 2026-10-13: the books do little work
+26
View File
@@ -256,6 +256,32 @@ their numbers are assigned when P3 lands:
year, closed and never closed), the viewer's year, closed and never closed), the viewer's
`a_members_page_shows_the_same_capital_before_and_after_a_close` `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 ## What the proofs found
The engine already had 190+ tests, property tests, mutation testing, The engine already had 190+ tests, property tests, mutation testing,
+4
View File
@@ -67,3 +67,7 @@ open Wanderbooks
#print axioms cashflow_reversal_nets #print axioms cashflow_reversal_nets
#print axioms Reachable.cashflow_foots #print axioms Reachable.cashflow_foots
#print axioms Reachable.cashflow_is_change_in_cash #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
+1
View File
@@ -4,3 +4,4 @@ import Wanderbooks.Statements
import Wanderbooks.Closing import Wanderbooks.Closing
import Wanderbooks.Ledger import Wanderbooks.Ledger
import Wanderbooks.Apportion import Wanderbooks.Apportion
import Wanderbooks.Parties
+215
View File
@@ -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