Writing Backend correctness

The journal balances. The transaction doesn't.

A balanced journal is not necessarily an explained transaction. Leftover cents are invented money unless every cent is named before insert.

A journal can balance and still contain unexplained money. A bank statement is 10,000 cents; named allocations sum to 9,900. Post a 100-cent plug and debits still equal credits. The bank line is not explained.

That is the failure this note is about. Transaction here is the bank row and its split; journal balances only means debits equal credits. Remainder is bankAmount − sum(legs) in integer cents, not a float-rounding crumb. Posted means a journal insert happened for that row, not a tick in the UI.

Bank transaction:     $100.00
Food:                   $60.00
Transport:              $39.00
                        -------
Explained:              $99.00
Remainder:               $1.00

Two paths. Post-then-allocate: write the bank side so the books move, attach counterparties later. Allocate-then-post: the legs exist first, remainder is zero, then insert is allowed.

The wrong model is: post the bank line first so the books move; leftover cents can wait.

Invariant: sum(legs) === bankAmount (same currency, integer cents) before any journal insert. Remainder ≠ 0 is not posted.

Why balancing is not explaining

Two equalities. Journal identity asks sum(debits) === sum(credits). Allocation identity asks sum(legs) === bankAmount. A plug satisfies the first and violates the second.

Nothing has to crash. The bank line can still be unexplained. Same shape as minting a user so a foreign key passes: a local constraint goes green and the real identity is false.

Add $1 suspense → journal balances (wrong)
Reject → allocation incomplete (right)

Float rounding is a sibling door. This lab uses integer cents so the leftover cannot hide there.

Allocation, adjustment, and the plug

A named 100-cent bank fee as a third leg (6000 + 3900 + 100) is allocation: every cent is explained before insert.

Bank amount:       10,000
Expense A:          6,000
Expense B:          3,900
Fee:                  100
                   ------
Total:             10,000

A 100-cent suspense line because 6000 + 3900 missed 10000 is a plug: invented money so the journal balances.

Bank amount:       10,000
Expense A:          6,000
Expense B:          3,900
(suspense):           100   ← gap filler, not a fee
                   ------
Named legs:         9,900

When is a remainder a legitimate adjustment? When the name is the explanation: a fee, tax, measured FX spread, a reconciliation difference you can point to on the statement. The leg is not “whatever makes the math work.”

When is it invented? When the name is a bucket for missing knowledge: suspense, rounding (with integer cents), “I’ll fix it later.” Reject before posting. Pending allocation belongs outside the journal: a queue row, a draft split, a reconciliation task. Not a minted credit that makes sum(debits) === sum(credits).

Bank reconciliation may eventually post an adjustment, but only after the difference has a real source document or policy name, not because insert could not wait.

Lab: exact, under, over, and the plug

I ran node lab/allocate-to-the-cent.mjs in this repo on 24 August 2026. In-process. No network. Bank line 10000 cents. Debit the bank; credit the legs.

No production statement. No dated outage. The claims stop at the two equalities and whether a journal row exists.

PathLegsWhat we needResult
exact6000 + 4000both equalitiesaccepted; remainder 0; journal balances; explained
under6000 + 3999refuseunder_allocated; remainder 1; no journal
over6000 + 4001refuseover_allocated; remainder -1; no journal
plug6000 + 3900, mint 100interesting failureaccepted; invented 100; journal balances; named legs 9900; bank line not explained
{
  "bankCents": 10000,
  "exact": {
    "accepted": true,
    "remainder": 0,
    "inventedCents": 0,
    "journalCount": 1,
    "journalBalanced": true,
    "bankLineExplained": true
  },
  "under": {
    "accepted": false,
    "reason": "under_allocated",
    "remainder": 1,
    "journalCount": 0
  },
  "over": {
    "accepted": false,
    "reason": "over_allocated",
    "remainder": -1,
    "journalCount": 0
  },
  "plug": {
    "accepted": true,
    "inventedCents": 100,
    "journalCount": 1,
    "journalBalanced": true,
    "namedLegsCents": 9900,
    "bankLineExplained": false
  },
  "allPassed": true
}

The interesting failure is the plug path: accepted, journal balances, 100 cents invented. Named legs still sum to 9900.

const explained = sum(legs); // 9900
const plug = BANK - explained; // 100
const debits = [BANK];
const credits = [...legs, plug];
// sum(debits) === sum(credits), and the bank line is still not explained

Allocate, then post

Remainder is the gate. Insert only when it is zero.

const remainder = BANK - sum(legs);
if (remainder > 0) {
  return { accepted: false, reason: "under_allocated" };
}
if (remainder < 0) {
  return { accepted: false, reason: "over_allocated" };
}
// debit bank, credit legs; never mint a plug here

One journal per statement row is a lab convenience, not a chart of accounts lesson. Under and over both refuse. The extra on over is not a tip you keep on the bank side.

Decision boundary

Rejected: post-then-allocate; parking leftover cents in suspense or rounding; minting a plug so the journal balances.

Accepted: explicit fee, tax, or adjustment named as a leg when that name is the explanation, not a gap filler.

A true single-leg post (the whole statement to one account) still wants sum === bankAmount. FX is a rate problem, not a same-currency remainder.

This does not buy a bank feed, a statement parser, “this row is that invoice,” multi-currency, float rounding, or idempotent re-import. The gate is completeness of the split before insert.

A balanced journal is not necessarily an explained transaction. Posted means legs already sum to the bank amount before insert. If the books moved because you minted the missing cents, the bug is the plug.

A public notebook

Notes from real delivery: racey quotas, sync when a device is offline, messaging APIs, and the gap between a clean local demo and production.

Not a product catalog, a tutorial syllabus, or a course funnel. If a post names a tool, I used it. If it describes a failure, it happened.

About Contact