Settlement flow doesn't net related expense + income entries (links + postings incomplete) #33
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Surfaced during aio-demo end-to-end testing of the split-ledger refactor (#28 / #29 / PR #32). Not related to the refactor — the bug is in the manual cash-settlement flow.
What we saw
User nancy has two open entries against the org:
Expense
^exp-bbfe35fcb4cf4a0e— drill from Home Depot, 50 EUR. Org owes nancy.Income
^inc-23dbe0fd230d4be0— 100 EUR collected on org's behalf. Nancy owes org.Net obligation: nancy owes org 50 EUR (100 receivable - 50 payable).
She makes a single 50 EUR cash payment to clear the net. The expectation was that this settlement reconciles both entries (expense + income), so it should:
^exp-bbfe35fcb4cf4a0eAND^inc-23dbe0fd230d4be0What actually got written
Two problems:
1. Income entry link is missing
Settlement links:
^MANUAL-4fe66163,^exp-bbfe35fcb4cf4a0e. The expense is linked but^inc-23dbe0fd230d4be0is not. A reader scanning the settlement entry can't tell from links alone that the income entry was also reconciled by this payment.2. Postings are 2-leg, leaving both per-user accounts open
The expected bookkeeping-complete form for "single cash payment nets both directions" is three legs:
After the actual 2-leg entry, per-account balances look like:
Both forms net to the right total obligation for nancy (zero), but the actual form leaves both per-user accounts showing non-zero balances — so a balance-sheet view per user would mislead: it would suggest nancy still owes the org 50 EUR AND the org still owes nancy 50 EUR.
Suggested direction
The settle-receivable endpoint (probably
POST /api/v1/settle-receivableper docs) needs to:^exp-.../^inc-...referenceAcceptance
bean-query "SELECT account, sum(position) WHERE 'User-4fe66163' in account"returns zero rows (or near-zero) after a complete settlement, not partial open balances on each accountOut of scope for this issue
Reference: see the actual on-disk entries in
aio-demo:/var/lib/fava/transactions.beancountlines 7, 14, 21 as of 2026-06-06 18:00 UTC for the live example.Linking + posting conventions for the new settlement endpoint
Worked through these while talking out partial-settlement semantics. Putting them here so they land with the spec.
Forms the endpoint should produce
Form A — single payment nets both directions (3-leg). When one cash flow resolves an open payable AND an open receivable for the same user. Picking nance's actual state (50 EUR payable open from the drill expense, 100 EUR receivable open from the income), a 30 EUR cash payment from her that the operator allocates to clear the expense fully + 80 EUR of the income:
Postings balance: 30 + 50 − 80 = 0. Result: Payable zeroed, Receivable +20 (income remainder), Cash +30. Header links both source entries.
Form B — settles only one direction (2-leg). Same 30 EUR cash, but operator says "this is purely paying down the income receivable; leave the drill open":
Result: Payable unchanged at −50, Receivable +70. Both obligations remain live on the books as separate items.
Follow-up "remainder" settlement
After Form A above, nance still owes 20 EUR (income remainder). She pays the last 20 EUR cash:
Only the income link is included — the expense was already fully settled in the prior tx, linking it again would suggest it's still being acted on. Beancount's link chain on
^inc-23dbe0fd230d4be0already surfaces all three entries that touched this income:No need to also link prior settlement entries; the source-entry link is the anchor that ties the whole chain.
Scaling: new expenses/incomes landing on top of a partial state
The two-account + per-chain link model holds up as obligations layer. Continuing from after Form A (Receivable +20 from the income remainder, Payable cleared):
Day +2 — 40 EUR paint expense (org owes nance):
Payable: −40, Receivable: +20 (income remainder untouched), Net: −20.
Day +3 — 25 EUR income (nance collects from guest):
Payable: −40, Receivable: +45 (20 old remainder + 25 new), Net: +5.
Day +4 — operator records a 30 EUR cash settlement, allocated 5 EUR to old income + 25 EUR to new income, leaving paint payable alone:
Final state:
Liabilities:Payable:User-4fe66163-40.00 EUR^exp-9a4f12c8c0f04ec1(paint, fully open)Assets:Receivable:User-4fe66163+15.00 EUR^inc-23dbe0fd230d4be0(15 EUR open of original 100),^inc-7e2c5a1b3f4d49af(closed)Each chain stays independently traceable: old-income now shows the original + Form A's −80 + this −5 = 15 still open; new-income shows +25 + this −25 = closed; paint shows the +40 with no settlement events yet. New obligations don't muddy old chains, and a single settlement can touch any subset by listing all relevant links on the header.
Per-entry allocation metadata
Single key, comma-separated allocations:
Scales to any number of source entries per settlement. Pairs with the header links 1:1 (every id in
settles-entries:should appear as^exp-…or^inc-…on the header line). Amounts use the settlement's transaction currency.(An earlier draft of this comment used separate
settles-expense:/settles-income:keys, which can't hold multiple entries cleanly because Beancount metadata is a dict — usingsettles-entries:as a single CSV string sidesteps that. The header links remain the canonical record for Beancount's chain-query mechanism; this key is for human/admin-UI parsing.)Linking convention summary
^MANUAL-…)^exp-…^inc-…^exp-…AND^inc-…^inc-…/^exp-…links on the header^MANUAL-…Optional follow-up:
#fully-settledtag, per chainBeancount has no built-in "this obligation is fully closed" indicator. Cheap convention: when a settlement zeroes out a touched chain completely, tag the settling transaction.
For settlements that touch a single chain and close it, use a bare tag:
For settlements that touch multiple chains and close some-but-not-all (like Day +4 above, which closed
^inc-7e2c5a1b3f4d49afbut left^inc-23dbe0fd230d4be0at 15 EUR open), the tag needs to identify WHICH chain was closed. Two options:#fully-settled-7e2c5a1b3f4d49af— appended for each chain the settlement actually closedcloses-chain: "7e2c5a1b3f4d49af"(CSV if multiple)Either way, the settlement endpoint computes "did this take
sum(position) WHERE link='^inc-X'to zero?" per touched chain and decorates accordingly. Doesn't affect postings; pure reporting aid. Can be added later without a migration.Worth treating as a stretch goal on this issue or splitting into its own follow-up — flagging here so it's not lost.
Design session surfaced that the strict-matching path (reject when cash ≠ net obligation) creates a real operator footgun: cash has already been handed over by the time the API returns 400. Reverting to silent drift is what produced this bug.
The honest fix needs a credit balance — filed as #41 — to absorb any cash that doesn't map onto a clean settle target. Should be merged together as one PR. Acceptance criteria for #33 stand as written; #41 makes them actually achievable without 400-ing operators in the middle of a cash transaction.
Implementation order when picked up:
tests/test_settlement_api.py(task #6 of the test plan) — tests cover both as a single behaviour surface.