D30-F05-A02 / Released engineering topic

Correct a Bank Ledger with an Atomic Reversal and Replacement

Preserve original history, derive an exact ordered inverse, validate a replacement, and commit the correction once with Python and TypeScript.

Correct a Bank Ledger with an Atomic Reversal and ReplacementD30 / D30-F05

A transfer can balance perfectly and still name the wrong beneficiary. A useful ledger must let an operator repair that instruction while preserving what was accepted and when it was known. The engineering challenge is making the entire correction visible together: a successful reversal paired with a rejected replacement would leave the account in another wrong state.

We will build an exact full reversal plus replacement, calculate its effect account by account, and test retries, invalid replacements and recording-time boundaries. This tutorial follows Double-Entry Posting Engine, Append-Only Journal Enforcement and Idempotent Journal Posting. The reference code is self-contained in Python and TypeScript; a separate PostgreSQL control profile tests the actual storage boundary.

Read the bank's side of the books

Our entirely fictional Cedar Teaching Bank records customer deposits as liabilities. Cash is an asset. A debit raises an asset's normal balance; a credit raises a liability's normal balance. The normal-side conventions follow GnuCash's accounting guide; the bank-liability perspective is visible in the Federal Reserve's H.8 classification. These sources do not prescribe this tutorial's correction policy.

Synthetic example, all amounts in USD cents: start with Dr cash 100000 and Cr Ada deposits 100000. A later transfer mistakenly debits Ada 2500 and credits Ben 2500. Cash stays 100000; Ada's liability becomes 97500 and Ben's 2500. Equal debits and credits establish accounting balance, but they cannot tell us whether Ben was the intended recipient.

The intended transfer was 2400 to Cora. Editing the accepted journal would hide the original decision. Instead, append its exact inverse, then the intended instruction.

Derive the inverse from stored legs

The original ordered legs are Ada debit 2500, then Ben credit 2500. Its inverse is Ada credit 2500, then Ben debit 2500. Keep each account, positive amount and position. Do not replace the stored leg list with a caller-supplied net adjustment.

The immutable original and one atomic reversal-replacement operation

Open this diagram at full size.

The inverse restores the affected accounts before the intended instruction is applied. The two new journals share one commit boundary. The diagram's sequence explains the arithmetic; it does not authorize exposing a separately committed reversal.

For account a, let v_a be its debit-positive raw movement: sum positive minor-unit amounts on debit legs and subtract amounts on credit legs. The inverse contributes -v_a. If w_a is replacement movement, then v_a + (-v_a) + w_a = w_a. This equality must hold separately for each account. Overall zero residual alone would permit offsets to the wrong account.

InstructionAda liability movementBen movementCora movement
Wrong transfer-2500+25000
Exact inverse+2500-25000
Intended replacement-24000+2400
Combined-24000+2400

Independent closing arithmetic is Ada=100000-2500+2500-2400=97600, Ben=2500-2500=0, Cora=2400, cash=100000. Deposit liabilities sum 97600+0+2400=100000. Across funding, wrong transfer, inverse and replacement, debits=credits=107400 cents. All these numbers are author-derived from synthetic instructions; no bank's customer transaction is represented.

Freeze money, identity and time before implementation

Use canonical positive decimal strings for minor units. The profile accepts 1..30 digits per leg and 2..32 ordered legs with at least two distinct accounts. Python int and TypeScript BigInt preserve exactness. For example,9007199254740000+993=9007199254740993 exceeds JavaScript's safe Number range; converting through Number first loses the guarantee. No rounding, epsilon, implicit currency conversion or exponent strings are accepted.

A correction envelope names the ledger/entity, currency, correction identity, original ID, new reversal ID and business identity, effective time, source availability, booking period, reason and complete replacement command. Every field is required. Unknown fields and malformed values reject. The replacement must agree with envelope scope, currency and clocks. Its amounts, active accounts, currency consistency and exact debit-credit balance receive ordinary posting validation.

The durable correction identity is scoped by ledger and entity. Same command under the same key returns the stored original outcome; changed reason, amount, time, IDs or leg order conflicts. Object property ordering is irrelevant. This is exact semantic equality, not a cryptographic journal commitment. Preserve accepted identities rather than expiring a cache and permitting the economic operation to run again.

Effective time says when the transaction belongs economically. Source availability says when the correction instruction became available. Server recorded_at says when it was recorded inside the transaction; it is not proof of physical commit time. Use genuine UTC millisecond dates and require effective/source time<=recorded_at. The original's clocks remain unchanged. A correction learned on 22 October must not appear known in a 21 October knowledge query, even if its economic date is 20 October.

Booking period is a separate YYYY-MM control. It must be open. The default policy requires its month to match effective time. A prior-effective/current-booking adjustment needs explicit trusted authority and a reason. A changed month string alone rejects. This teaching choice cannot determine jurisdiction-specific prior-period recognition.

One transaction publishes the whole correction

PostgreSQL's transaction documentation describes the all-or-none visibility boundary. The deployed adapter must implement it across the inverse, replacement, immutable relationship, durable identities and ledger counter. A function's provisional returned row inside an uncommitted transaction is not an accepted client outcome.

Resolve an accepted retry before fresh account/period validation, after trusted scope authorization. Otherwise a request that succeeded before period close could later be reported as a new failure. For a new operation, serialize the ledger, recheck identity and target uniqueness, and allocate its sequence counter transactionally. Native sequence allocation is not a proof of gap-free committed history.

The portable implementation returns a new snapshot only after both journals validate. Its central pattern is readable in Python:

Python
first = post_journal(state, reversal, context)
if first['status'] != 'committed':
    return reject(state, first['code'])
second = post_journal(first['resulting_state'], replacement, context)
if second['status'] != 'committed':
    return reject(state, second['code'])
candidate = second['resulting_state']
# append relationship and identity, then expose candidate once

The first result is an isolated candidate, not a persisted partial correction. Rejection returns the untouched original state. A database implementation uses one actual transaction rather than treating these two function returns as two commits. See the complete contract and source and the companion append-only PostgreSQL profile.

Exercise the failure boundary

Open the guided correction lab. Start with the accepted wrong transfer, derive the inverse, inspect the replacement and step to the single commit boundary. Compare the invalid-replacement scenario: a one-cent imbalance leaves both new journals absent, the original unchanged and the next counter untouched. Back and Reset reproduce the same sequence. Reduced-motion Play reveals one meaningful step.

The reference tests also cover identical correction retry, changed-payload conflict, a competing second reversal, missing originals, self IDs, reused business IDs, wrong currency/entity, closed accounts/periods, future evidence, unauthorized and authorized period adjustments, and exact huge amounts. Python and TypeScript run the same fixtures and compare complete outputs, including snapshots and error states. The arithmetic oracle was specified before the implementations.

Run python tests/test_correction.py output-python.json from the package. Compile tests/correction.test.ts targeting ES2022/NodeNext, then run it with datasets/cases.json and an output path. Validation evidence records executable, browser and storage checks separately. A snapshot passing these tests does not establish concurrent database durability; actual PostgreSQL sessions test that claim in the companion profile.

Decide which correction method applies

MethodAppropriate questionBoundary
Full reversal plus replacementWhat exact old instruction is replaced by what new instruction?This tutorial, one full reversal per original
Delta adjustmentWhat difference should be added?Separate allocation and lineage rules required
Partial reversalWhich part is cancelled?Not supported here
Scheduled accrual reversalWhat should reverse on a future date?A different scheduler topic

A replacement may later become the original for another explicit correction. Reversing an inverse directly is excluded to keep lineage unambiguous. A closed account rejects under this profile; an institution must supply its approved compensating workflow. Owner and infrastructure permissions remain trusted. Exact arithmetic and app-role append-only controls do not prove fraud authorization, external settlement or cryptographic integrity.

Historical-example decision: not useful for this initial mechanical variant. A real case would require authoritative instruction identity, dates, accounting perspective, recognition policy and redistribution rights; inventing those would weaken the lesson. The synthetic case makes every amount and failure inspectable.

You can now preserve a wrong instruction, derive its exact inverse, validate the intended replacement and verify the all-or-none repair. The planned Event-Sourced Balance Fold and Bitemporal Ledger Versioning topics will build on preserved journal history and separate economic/knowledge clocks. Those handoffs belong to the next production stage.

ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

S1 — Accounting Concepts

  • Organization: GnuCash documentation project.
  • Source type/version: official v5 guide, sections2.1.3–2.1.4; accessed2026-10-25.
  • URL: Accounting Concepts.
  • Jurisdiction: general educational mechanics, no accounting-framework authority.
  • Supports: debit/credit normal balances and equal double-entry movements.
  • Limitations: does not prescribe our correction API, bank approvals or recognition policy.

S2 — Transactions

  • Organization: PostgreSQL Global Development Group.
  • Source type/version: official PostgreSQL18 documentation §3.4; accessed2026-10-25.
  • URL: Transactions.
  • Supports: all-or-none visibility, COMMIT/ROLLBACK transaction boundary.
  • Limitations: actual durability depends on deployed configuration and hardware; portable reducers do not prove database behavior.

S3 — Explicit Locking

  • Organization: PostgreSQL Global Development Group.
  • Source type/version: official PostgreSQL18 documentation §13.3; accessed2026-10-25.
  • URL: Explicit Locking.
  • Supports: conflicting row locks serialize tested ledger operations.
  • Limitations: unrelated readers/policies and privileged administrators require separately designed controls. The full-pair correction policy is our implementation choice.

S4 — ISO4217 currency snapshot

  • Organization: SIX, official maintenance agency.
  • Source type: List One XML, Pblshd2026-09-17, accessed2026-10-24 during Stage0.
  • URL: Official List One.
  • Supports: USD840 and EUR978 minor-unit exponent2 in snapshot.
  • Limitations: no universal exponent2 assumption, no cross-currency balancing or legal tender inference.

S5 — Bank-book perspective

  • Organization: Federal Reserve Board.
  • Source type: H.8 release2026-10-23, accessed2026-10-24 in Stage0.
  • URL: Assets and Liabilities of Commercial Banks.
  • Supports: deposits appear in bank liabilities. No reported statistics are reproduced.
  • Limitations: current URL changes; no customer transaction history or bank-specific recognition policy is implied.

Historical-example decision

Not useful for the initial deterministic variant. All named customers and the institution are synthetic. No empirical trading, fraud-detection, compliance or bank-performance result is claimed. Sources establish accounting and technical mechanics; all fixture arithmetic is independently author-derived. Real cases require authoritative identity/time/accounting-policy records and redistribution rights.

  • Accessed: 2026-10-25 for S1–S3; 2026-10-24 for Stage0 S4–S5.
correction.ts
/** Exact copy-on-success reference. Snapshots model persistence, not SQL concurrency. */
export type Json = null | boolean | number | string | Json[] | {[key: string]: Json};
// Inputs intentionally unknown: the wire is validated before arithmetic or identity use.
type Obj = Record<string, any>;
const jf = 'schema_version ledger_id entity_id journal_id business_operation_id currency effective_at source_available_at booking_period legs'.split(' ');
const cf = 'schema_version ledger_id entity_id currency correction_id original_journal_id reversal_journal_id reversal_business_operation_id effective_at source_available_at booking_period reason replacement'.split(' ');
const id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const amount = /^[1-9][0-9]{0,29}$/;
const copy = <T>(v: T): T => structuredClone(v);
const object = (v: unknown): v is Obj => v !== null && typeof v === 'object' && !Array.isArray(v);
const fields = (v: unknown, keys: string[]): v is Obj => object(v) && Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v,k));
const identifier = (v: unknown) => typeof v === 'string' && id.test(v) && !v.endsWith('\n');
// Match Python's Unicode str.strip set explicitly, including NEL/control separators.
const trimReason=(v:string)=>v.replace(/^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g,'');
export function validTime(v: unknown): v is string {
  if (typeof v !== 'string' || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/.test(v) || v.endsWith('\n') || v.startsWith('0000')) return false;
  const d = new Date(v); return Number.isFinite(d.getTime()) && d.toISOString() === v;
}
export function signature(v: any): string {
  if (Array.isArray(v)) return '[' + v.map(signature).join(',') + ']';
  if (object(v)) return '{' + Object.keys(v).sort().map(k=>JSON.stringify(k)+':'+signature(v[k])).join(',') + '}';
  return JSON.stringify(v);
}
export function newState(accounts: Obj[], ledgerId='CB-GENERAL', entityId='CB-A'): Obj {
  return {ledger_id:ledgerId,entity_id:entityId,accounts:copy(accounts),next_sequence:'1',journals:[],operations:{},corrections:[]};
}
function reject(state: Obj, code: string): Obj {return {status:'rejected',code,outcome:null,resulting_state:copy(state)};}
const scopeOk = (s: Obj,c: Obj) => c.ledger_id === s.ledger_id && c.entity_id === s.entity_id;
export function validateJournal(c: unknown, s: Obj, ctx: Obj): string | null {
  if (!fields(c,jf)) return 'INVALID_SCHEMA';
  if (c.schema_version !== 'd30-journal-v1') return 'UNSUPPORTED_SCHEMA';
  for (const f of ['ledger_id','entity_id','journal_id','business_operation_id']) if (!identifier(c[f])) return 'INVALID_IDENTIFIER';
  if (!scopeOk(s,c)) return 'ACCOUNT_SCOPE_MISMATCH';
  if (!['USD','EUR'].includes(c.currency)) return 'UNSUPPORTED_CURRENCY';
  if (![c.effective_at,c.source_available_at,ctx.recorded_at].every(validTime)) return 'INVALID_TIMESTAMP';
  if (c.effective_at > ctx.recorded_at) return 'FUTURE_EFFECTIVE_TIME';
  if (c.source_available_at > ctx.recorded_at) return 'FUTURE_SOURCE_AVAILABILITY';
  if (s.journals.length && ctx.recorded_at < s.journals.at(-1).recorded_at) return 'RECORDED_TIME_REGRESSION';
  const p=c.booking_period;
  if (typeof p !== 'string' || !/^[0-9]{4}-(0[1-9]|1[0-2])$/.test(p) || p.endsWith('\n') || p.startsWith('0000')) return 'INVALID_SCHEMA';
  if (!(ctx.open_periods ?? []).includes(p)) return 'PERIOD_CLOSED';
  if (p !== c.effective_at.slice(0,7) && !(ctx.allow_current_period_adjustment === true && typeof ctx.adjustment_reason === 'string' && trimReason(ctx.adjustment_reason))) return 'UNAUTHORIZED_PERIOD_ADJUSTMENT';
  if (!Array.isArray(c.legs) || c.legs.length < 2 || c.legs.length > 32) return 'INVALID_LEGS';
  const accounts = new Map<string,Obj>(s.accounts.map((a:Obj)=>[a.account_id,a]));
  const seen=new Set<string>(); let debit=0n,credit=0n;
  for (const l of c.legs) {
    if (!fields(l,['account_id','side','amount_minor'])) return 'INVALID_SCHEMA';
    if (!identifier(l.account_id)) return 'INVALID_IDENTIFIER';
    if (!['debit','credit'].includes(l.side)) return 'INVALID_SIDE';
    if (typeof l.amount_minor !== 'string' || !amount.test(l.amount_minor) || l.amount_minor.endsWith('\n')) return 'INVALID_AMOUNT';
    const a=accounts.get(l.account_id); if (!a) return 'UNKNOWN_ACCOUNT';
    if (a.ledger_id !== c.ledger_id || a.entity_id !== c.entity_id) return 'ACCOUNT_SCOPE_MISMATCH';
    if (a.currency !== c.currency) return 'CURRENCY_MISMATCH';
    if (a.status !== 'active') return 'ACCOUNT_CLOSED';
    seen.add(l.account_id); if (l.side==='debit') debit+=BigInt(l.amount_minor); else credit+=BigInt(l.amount_minor);
  }
  if (seen.size<2) return 'DISTINCT_ACCOUNTS_REQUIRED';
  return debit===credit?null:'UNBALANCED_JOURNAL';
}
function appendValidated(s:Obj,c:Obj,ctx:Obj):Obj {
  const j={...copy(c),sequence:s.next_sequence,recorded_at:ctx.recorded_at,adjustment_reason:c.booking_period!==c.effective_at.slice(0,7)?(ctx.adjustment_reason??''):''};
  s.next_sequence=(BigInt(s.next_sequence)+1n).toString(); s.journals.push(j);
  // defineProperty avoids the legacy __proto__ setter for valid identifiers.
  Object.defineProperty(s.operations,c.business_operation_id,{value:{signature:signature(c),journal:copy(j)},enumerable:true,writable:true,configurable:true});
  return j;
}
export function postJournal(s:Obj,c:unknown,ctx:Obj):Obj {
  if (!fields(c,jf)) return reject(s,'INVALID_SCHEMA');
  if (!scopeOk(s,c)) return reject(s,'ACCOUNT_SCOPE_MISMATCH');
  const prior=typeof c.business_operation_id==='string' && Object.hasOwn(s.operations,c.business_operation_id)?s.operations[c.business_operation_id]:null;
  if (prior) {
    if (prior.signature!==signature(c)) return reject(s,'IDEMPOTENCY_CONFLICT');
    return {status:'replayed',code:'ORIGINAL_RESULT',outcome:copy(prior.journal),resulting_state:copy(s)};
  }
  const code=validateJournal(c,s,ctx); if (code) return reject(s,code);
  if (s.journals.some((j:Obj)=>j.journal_id===c.journal_id)) return reject(s,'JOURNAL_ID_EXISTS');
  const candidate=copy(s),journal=appendValidated(candidate,c,ctx);
  return {status:'committed',code:'JOURNAL_POSTED',outcome:copy(journal),resulting_state:candidate};
}
export function correctJournal(s:Obj,c:unknown,ctx:Obj):Obj {
  if (!fields(c,cf)) return reject(s,'INVALID_SCHEMA');
  if (!scopeOk(s,c)) return reject(s,'ACCOUNT_SCOPE_MISMATCH');
  if (c.schema_version!=='d30-correction-v1') return reject(s,'UNSUPPORTED_SCHEMA');
  for (const f of ['ledger_id','entity_id','correction_id','original_journal_id','reversal_journal_id','reversal_business_operation_id']) if (!identifier(c[f])) return reject(s,'INVALID_IDENTIFIER');
  const prior=s.corrections.find((r:Obj)=>r.correction_id===c.correction_id);
  if (prior) {
    if (prior.signature!==signature(c)) return reject(s,'CORRECTION_CONFLICT');
    return {status:'replayed',code:'ORIGINAL_CORRECTION',outcome:copy(prior.outcome),resulting_state:copy(s)};
  }
  if (typeof c.reason!=='string'||c.reason!==trimReason(c.reason)||[...c.reason].length<1||[...c.reason].length>240||[...c.reason].some(ch=>ch.codePointAt(0)!>=0xd800&&ch.codePointAt(0)!<=0xdfff)) return reject(s,'INVALID_REASON');
  const original=s.journals.find((j:Obj)=>j.journal_id===c.original_journal_id);
  if (!original) return reject(s,'ORIGINAL_NOT_FOUND');
  if (original.currency!==c.currency) return reject(s,'CURRENCY_MISMATCH');
  if (s.corrections.some((r:Obj)=>r.outcome.relationship.original_journal_id===c.original_journal_id)) return reject(s,'ALREADY_REVERSED');
  if (s.corrections.some((r:Obj)=>r.outcome.relationship.reversal_journal_id===c.original_journal_id)) return reject(s,'REVERSAL_TARGET_FORBIDDEN');
  const repl=c.replacement;
  if (!fields(repl,jf)) return reject(s,'INVALID_SCHEMA');
  for (const f of ['ledger_id','entity_id','currency','effective_at','source_available_at','booking_period']) if (repl[f]!==c[f]) return reject(s,'REPLACEMENT_ENVELOPE_MISMATCH');
  const code=validateJournal(repl,s,ctx); if(code) return reject(s,code);
  if(new Set([c.original_journal_id,c.reversal_journal_id,repl.journal_id]).size!==3) return reject(s,'JOURNAL_ID_EXISTS');
  if(c.reversal_business_operation_id===repl.business_operation_id) return reject(s,'OPERATION_ID_EXISTS');
  for(const op of [c.reversal_business_operation_id,repl.business_operation_id]) if (Object.hasOwn(s.operations,op)) return reject(s,'OPERATION_ID_EXISTS');
  const rev:Obj={schema_version:'d30-journal-v1',journal_id:c.reversal_journal_id,business_operation_id:c.reversal_business_operation_id};
  for (const f of ['ledger_id','entity_id','currency','effective_at','source_available_at','booking_period']) rev[f]=copy(c[f]);
  rev.legs=original.legs.map((l:Obj)=>({...l,side:l.side==='debit'?'credit':'debit'}));
  const first=postJournal(s,rev,ctx); if(first.status!=='committed') return reject(s,first.code);
  const second=postJournal(first.resulting_state,repl,ctx); if(second.status!=='committed') return reject(s,second.code);
  const relationship:Obj={replacement_journal_id:repl.journal_id,recorded_at:ctx.recorded_at};
  for(const f of ['correction_id','original_journal_id','reversal_journal_id','reason']) relationship[f]=c[f];
  const outcome={relationship,reversal:first.outcome,replacement:second.outcome};
  const candidate=second.resulting_state;
  candidate.corrections.push({correction_id:c.correction_id,signature:signature(c),outcome:copy(outcome)});
  return {status:'committed',code:'CORRECTION_POSTED',outcome:copy(outcome),resulting_state:candidate};
}
export function balances(s:Obj,effectiveCutoff='9999-12-31T23:59:59.999Z',knowledgeCutoff='9999-12-31T23:59:59.999Z'):Obj {
  const raw=new Map<string,bigint>(s.accounts.map((a:Obj)=>[a.account_id,0n]));
  for(const j of s.journals) if(j.effective_at<=effectiveCutoff&&j.recorded_at<=knowledgeCutoff) for(const l of j.legs) raw.set(l.account_id,raw.get(l.account_id)!+BigInt(l.amount_minor)*(l.side==='debit'?1n:-1n));
  return Object.fromEntries(s.accounts.map((a:Obj)=>[a.account_id,(raw.get(a.account_id)!*(a.normal_side==='debit'?1n:-1n)).toString()]));
}
Full-height labcorrection labOpen full screen
Written by

Fintech engineer building market-data and financial systems, and the author of every article, glossary record, and reference implementation on The Fintech Builder.