D30-F01-A01 / Released engineering topic

Build a double-entry posting engine that accepts a complete journal or rejects it

Validate bank-book signs, exact minor-unit amounts, account scope and booking clocks before one complete journal boundary.

One complete journal balances cash, customer deposit liabilities and fee income.D30 / D30-F01

When money moves, the books need a complete explanation of what changed. A funding instruction may increase an institution's cash and its liability to a customer. A fee may reduce that liability and increase income. Engineers need to preserve those relationships even when a request has malformed amounts, the wrong account, or a final leg that fails.

This tutorial builds an exact single-currency journal validator, then a portable posting model that returns a complete new snapshot or an unchanged one. The code is intentionally small enough to inspect. Actual database transactions and access controls are the next tutorial's work.

All accounts and events below are synthetic teaching data for fictional Cedar Teaching Bank. Calculations are author-derived. The selected limits and policies are implementation choices, not a banking jurisdiction's accounting rules.

Start with the institution's perspective

In the institution's books, cash is an asset. Customer deposits are liabilities: the institution owes the customers. This perspective matters because a customer's own bank account would be an asset in that customer's books. The Federal Reserve's bank balance-sheet release provides a classification example by displaying deposits among bank liabilities.

Normal account sides tell us how to interpret entries. Assets and expenses normally increase on debit; liabilities, equity and income normally increase on credit. Opposite sides decrease those normal balances. These mechanics are documented in GnuCash's accounting guide. Debit does not universally mean money leaving an account.

Look at both normal balances in this synthetic USD 1,000.00 funding:

Cash is debit-normal and deposits credit-normal. Funding has opposite raw signs but increases both normal balances.

Open this diagram at full size.

The journal debits cash 100000 minor units and credits Ada's deposit liability 100000. Raw debit-positive movements are +100000 and -100000. Normal-side movements are both +100000. The cash asset and deposit liability rise together.

State the bounded contract before writing code

The first variant accepts one entity, ledger and currency per journal. USD and EUR each use exponent 2 in the pinned SIX currency list, but they remain separate denominations. A debit of USD 100.00 cannot balance a credit of EUR 100.00 just because both wire strings say 10000.

The journal has exactly ten fields: version, ledger ID, entity ID, journal ID, business-operation ID, currency, effective time, source-availability time, booking period and ordered legs. Each leg has exactly account ID, side and amount. Unknown fields reject rather than silently disappearing. The full data contract specifies every rule and error precedence.

Use positive canonical decimal strings for monetary JSON. The profile permits at most 30 digits per leg and 2–32 ordered legs, involving at least two distinct accounts. Repeated account legs are allowed and retained. Reject 0, -1, 01, 1.00, 1e3, numeric JSON, booleans and null. Posting never guesses whether a number is dollars or cents and never repairs an invalid amount by rounding it.

Trusted account state supplies each account's scope, currency, type, normal side and status. A client cannot declare an account active or supply its own adjustment authority inside the journal.

Preserve the clocks and booking decision

Effective time answers when the instruction is economically effective under the selected convention. Source availability answers when the service had the evidence. Trusted recording time identifies server recording inside the transaction; it is not proof of the physical COMMIT instant.

All use genuine Gregorian UTC timestamps with exactly three fractional digits. Effective and source times must be no later than trusted recording time. Late evidence is supported: an effective date can precede source availability.

Booking period must be open. It normally equals the effective UTC month. If a September-effective instruction is booked into open October, this teaching profile requires trusted adjustment authority and a nonempty reason. Preserve September effective time and the explanation. Changing a month string alone is insufficient.

This makes a policy decision explicit without pretending that a generic engine decides how every accounting framework handles prior-period errors. Full period closing and reopening remain separate work.

Compute exact equality

For leg amount mi>0m_i>0, measured in minor units of the one currency, set si=+1s_i=+1 for debit and −1-1 for credit. The exact totals and residual are

D=∑si=+1mi,C=∑si=−1mi,R=D−C.D=\sum_{s_i=+1}m_i,\quad C=\sum_{s_i=-1}m_i,\quad R=D-C.

The balancing decision is R=0R=0. There is no tolerance: a residual of one minor unit is still a rejected candidate.

For a synthetic three-leg allocation:

AccountSideMinor unitsMajor-unit interpretation
Cash assetDebit10000USD 100.00 received
Ada deposit liabilityCredit9700USD 97.00 owed to Ada
Teaching fee incomeCredit300USD 3.00 income

The independently calculated credit total is 9700+300=10000, equal to the debit total. Normal-side movements are cash +10000, deposit liability +9700, income +300. This example does not establish a real bank tariff or fee eligibility.

Raw movement for account aa is ∑i:account⁡(i)=asimi\sum_{i:\operatorname{account}(i)=a}s_i m_i. Flip its sign for a credit-normal account to display normal-side movement. Negative normal movements are allowed: an ordinary debit to income decreases income. Funds capacity and acceptable account balances require additional product rules.

Validate everything before the commit boundary

The order matters. First validate trusted account/context structures, then the exact command, identifiers, currency, clocks and period. Next inspect each ordered leg, including positive string amount and account eligibility. Only after all legs pass should the validator return exact totals and accepted account movements.

A valid first leg does not authorize a partial write. The final leg might name an unknown account:

All three legs belong to one instruction. An invalid final leg leaves no committed journal; database evidence is separate from a pure snapshot test.

Open this diagram at full size.

A real database transaction must group the journal's effects all-or-nothing. Current account/period policy and counter allocation must be protected in that same boundary. The pure function here demonstrates the output/state contract without claiming to exercise database races or storage durability.

Read the Python and TypeScript core

The full reference modules include strict schemas and typed rejections. Their central arithmetic is deliberately explicit:

Python
amount = int(leg["amount_minor"])
if leg["side"] == "debit":
    debit += amount
    signed = amount
else:
    credit += amount
    signed = -amount
raw[leg["account_id"]] = raw.get(leg["account_id"], 0) + signed

This runs after the amount has passed the positive canonical-string rule. Python's integer arithmetic retains all digits.

TypeScript
const amount = BigInt(leg.amount_minor);
if (leg.side === 'debit') debit += amount;
else credit += amount;
raw.set(
  leg.account_id,
  (raw.get(leg.account_id) ?? 0n) +
    (leg.side === 'debit' ? amount : -amount)
);

Do not insert Number(leg.amount_minor) before BigInt. The boundary case is 9007199254740000+993=9007199254740993. The result exceeds the safe Number integer range. Once a Number conversion loses a unit, converting that rounded value to BigInt cannot recover the instruction.

The APIs return complete JSON-safe decisions:

  • Valid candidate: accepted / VALID_JOURNAL, exact totals, ordered movements and a preserved copied command.
  • Rejected candidate: typed reason, null journal and no accepted movements. An unbalanced candidate includes its exact residual.
  • Portable append: committed / POSTED with a new snapshot, string sequence and server recording metadata, or rejected with unchanged state.

The Python and TypeScript modules have no sibling or external runtime imports. See usage instructions.

Test the boundaries that change the answer

The fixture matrix includes independently specified funding, transfer, fee, multi-leg and huge-string expectations. Failure cases cover malformed amounts, unknown/closed accounts, mixed currencies, cross-entity scope, invalid dates, future source availability and closed periods.

The 32-leg case accepts sixteen debit-one and sixteen credit-one legs. The 33-leg case rejects before aggregate balancing. A four-leg case sums two maximum 30-digit legs on each side; its exact total exceeds the per-leg limit and must still be retained.

Snapshot tests show an invalid final leg leaves the previous state unchanged. They also test no input mutation, no aliasing between the returned journal and stored result, original leg order, duplicate journal ID and recording-time regression. Full-output parity compares Python and TypeScript values, nulls, errors, ordering, complete commands and resulting states.

Run python tests/test_posting.py and python tests/run_parity.py from the package. Their results are recorded with the package. Pure-model acceptance remains distinct from actual database integration evidence.

Use the guided comparison

Open the self-contained lab. It begins with the complete three-leg allocation and exact totals visible. Step through its guided checks, then switch to the one-unit imbalance, wrong currency, unknown final account or authorized booking adjustment.

Change the first-leg amount from 10000 to 10001. The residual becomes 1 immediately; the modeled committed count remains zero. Reset restores the canonical scenario, amount, playback stage and speed. Static figures above retain the explanation when scripts are unavailable.

What this engine proves

It proves conformance to the selected posting contract and exact debit/credit equality. It does not prove business legitimacy, source authenticity, available funds, durable database behavior, repeated-business-request identity, regulatory recognition or independent tamper evidence.

Historical-example decision: not useful for these deterministic mechanics. Synthetic cases expose the relevant boundaries directly without inventing a real bank's customer event.

You can now construct a complete journal, validate its accounts, preserve its clocks and interpret the result. Continue with Append-Only Journal Enforcement to place this contract behind a controlled storage boundary. Idempotent Journal Posting and Correcting and Reversing Entry Algorithm then address retries and preserved corrections.

The canonical technical contract and primary references remain the factual source for this article.

References6 primary sources and evidence notes

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

R01 — GnuCash Accounting Concepts

  • Organization: GnuCash project.
  • Source/version: official v5 software guide, sections 2.1.3–2.1.4.
  • URL: Accounting Concepts.
  • Accessed: 2026-10-24.
  • Jurisdiction: general accounting mechanics.
  • Supports: normal debit/credit directions and equality.
  • Limitations: no banking compliance/recognition authority.

R02 — Federal Reserve H.8

  • Organization: Federal Reserve Board.
  • Source/version: statistical release, observed release date 2026-10-23.
  • URL: Commercial bank balance sheets.
  • Accessed: 2026-10-24.
  • Jurisdiction: United States; classification context only.
  • Supports: deposits displayed as bank liabilities.
  • Limitations: current URL changes; no statistics/customer event reused.

R03 — SIX currency list

  • Organization: SIX, ISO 4217 Maintenance Agency.
  • Source/version: official List One XML Pblshd 2026-09-17; USD 840 / EUR 978 selected rows.
  • URL: XML; maintenance context.
  • Accessed: 2026-10-24.
  • Jurisdiction: international currency reference.
  • Supports: pinned USD/EUR exponent 2.
  • Limitations: no claim all currencies have two decimals; review whitelist explicitly.

R04 — PostgreSQL Transactions

  • Organization: PostgreSQL Global Development Group.
  • Source/version: PostgreSQL 18 official documentation, section 3.4.
  • URL: Transactions.
  • Accessed: 2026-10-24.
  • Jurisdiction: technical database semantics.
  • Supports: all-or-nothing grouping and visibility.
  • Limitations: pure snapshot functions do not test database durability/concurrency.

R05 — RFC 3339

  • Organization: IETF/RFC Editor.
  • Source/version: standards-track RFC, July 2002; current page notes update RFC 9557.
  • URL: RFC 3339.
  • Accessed: 2026-10-24.
  • Jurisdiction: Internet representation.
  • Supports: timestamp/offset concepts.
  • Limitations: UTC milliseconds/no leap seconds are selected subset, not whole RFC.

R06 — Safe integer maximum

  • Organization: Ecma TC39.
  • Source/version: official living specification accessed on date.
  • URL: Number.MAX_SAFE_INTEGER.
  • Accessed: 2026-10-24.
  • Jurisdiction: language semantics.
  • Supports: Number safe integer maximum 9007199254740991.
  • Limitations: money strings/30 digit limits are implementation choices.

Historical decision: not useful for deterministic mechanics. All institution/customer/account/event/amount data is fictional and synthetic. Expected arithmetic is author-derived. No confidential or provider data appears.

posting-engine.ts
/** Exact journal validation and a pure committed-snapshot teaching model.
 * No database, network, sibling imports, implicit coercion, or BigInt JSON values.
 */
type RecordValue = Record<string, any>;
const COMMAND = ['schema_version','ledger_id','entity_id','journal_id','business_operation_id','currency','effective_at','source_available_at','booking_period','legs'];
const ACCOUNT = ['account_id','ledger_id','entity_id','currency','account_type','normal_side','status'];
const LEG = ['account_id','side','amount_minor'];
const CONTEXT = ['recorded_at','open_periods','allow_current_period_adjustment','adjustment_reason'];
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const AMOUNT = /^[1-9][0-9]{0,29}$/;
const TIME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/;
const PERIOD = /^[0-9]{4}-(0[1-9]|1[0-2])$/;
const NORMAL: Record<string,string> = {asset:'debit',expense:'debit',liability:'credit',equity:'credit',income:'credit'};
const currencies = new Set(['USD','EUR']);
const object = (x:any): x is RecordValue => x !== null && typeof x === 'object' && !Array.isArray(x) && (Object.getPrototypeOf(x) === Object.prototype || Object.getPrototypeOf(x) === null);
const exact = (x:any, fields:string[]) => object(x) && Object.keys(x).length === fields.length && fields.every(k => Object.hasOwn(x,k));
// Require complete matched-substring equality to make the strict wire boundary explicit.
const fullMatch = (pattern:RegExp, value:string) => pattern.exec(value)?.[0] === value;
const identifier = (x:any) => typeof x === 'string' && fullMatch(IDENTIFIER,x);
const period = (x:any) => typeof x === 'string' && fullMatch(PERIOD,x) && x.slice(0,4) !== '0000';
const copy = (x:any) => structuredClone(x);
// Match Python str.strip's explicit Unicode whitespace set for wire parity.
const trimReason = (x:string) => x.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,'');
function timestamp(x:any): boolean {
  if (typeof x !== 'string' || x.length !== 24 || !fullMatch(TIME,x) || x.slice(0,4) === '0000') return false;
  const parsed = new Date(x);
  return Number.isFinite(parsed.getTime()) && parsed.toISOString() === x;
}
function contextOk(x:any): boolean {
  return object(x) && ['recorded_at','open_periods'].every(k => Object.hasOwn(x,k)) && Object.keys(x).every(k => CONTEXT.includes(k))
    && timestamp(x.recorded_at) && Array.isArray(x.open_periods) && x.open_periods.every(period) && new Set(x.open_periods).size === x.open_periods.length
    && (!Object.hasOwn(x,'allow_current_period_adjustment') || typeof x.allow_current_period_adjustment === 'boolean')
    && (!Object.hasOwn(x,'adjustment_reason') || typeof x.adjustment_reason === 'string');
}
function accountsOk(x:any): boolean {
  if (!Array.isArray(x)) return false;
  const seen = new Set();
  for (const a of x) {
    if (!exact(a,ACCOUNT) || !['account_id','ledger_id','entity_id'].every(k => identifier(a[k])) || seen.has(a.account_id)
      || typeof a.currency !== 'string' || !currencies.has(a.currency) || typeof a.account_type !== 'string' || !Object.hasOwn(NORMAL,a.account_type)
      || a.normal_side !== NORMAL[a.account_type] || !['active','closed'].includes(a.status)) return false;
    seen.add(a.account_id);
  }
  return true;
}
function rejected(code:string, totals:any=null): any {
  return {status:'rejected',code,totals,movements:[],journal:null};
}
export function validateJournal(command:any, accounts:any, context:any): any {
  if (!accountsOk(accounts)) return rejected('INVALID_ACCOUNTS');
  if (!contextOk(context)) return rejected('INVALID_CONTEXT');
  if (!exact(command,COMMAND)) return rejected('INVALID_SCHEMA');
  if (command.schema_version !== 'd30-journal-v1') return rejected('UNSUPPORTED_SCHEMA');
  if (!['ledger_id','entity_id','journal_id','business_operation_id'].every(k => identifier(command[k]))) return rejected('INVALID_IDENTIFIER');
  if (typeof command.currency !== 'string' || !currencies.has(command.currency)) return rejected('UNSUPPORTED_CURRENCY');
  if (!timestamp(command.effective_at) || !timestamp(command.source_available_at)) return rejected('INVALID_TIMESTAMP');
  if (command.effective_at > context.recorded_at) return rejected('FUTURE_EFFECTIVE_TIME');
  if (command.source_available_at > context.recorded_at) return rejected('FUTURE_SOURCE_AVAILABILITY');
  if (!period(command.booking_period)) return rejected('INVALID_SCHEMA');
  if (!context.open_periods.includes(command.booking_period)) return rejected('PERIOD_CLOSED');
  if (command.booking_period !== command.effective_at.slice(0,7) && !(context.allow_current_period_adjustment === true && trimReason(context.adjustment_reason ?? ''))) return rejected('UNAUTHORIZED_PERIOD_ADJUSTMENT');
  if (!Array.isArray(command.legs) || command.legs.length < 2 || command.legs.length > 32) return rejected('INVALID_LEGS');
  const lookup = new Map<string,RecordValue>(accounts.map((a:RecordValue) => [a.account_id,a]));
  const raw = new Map<string,bigint>();
  let debit = 0n, credit = 0n;
  for (const leg of command.legs) {
    if (!exact(leg,LEG)) return rejected('INVALID_SCHEMA');
    if (!identifier(leg.account_id)) return rejected('INVALID_IDENTIFIER');
    if (!['debit','credit'].includes(leg.side)) return rejected('INVALID_SIDE');
    if (typeof leg.amount_minor !== 'string' || !fullMatch(AMOUNT,leg.amount_minor)) return rejected('INVALID_AMOUNT');
    const account = lookup.get(leg.account_id);
    if (!account) return rejected('UNKNOWN_ACCOUNT');
    if (account.status !== 'active') return rejected('ACCOUNT_CLOSED');
    if (account.ledger_id !== command.ledger_id || account.entity_id !== command.entity_id) return rejected('ACCOUNT_SCOPE_MISMATCH');
    if (account.currency !== command.currency) return rejected('CURRENCY_MISMATCH');
    const amount = BigInt(leg.amount_minor);
    if (leg.side === 'debit') debit += amount; else credit += amount;
    raw.set(leg.account_id,(raw.get(leg.account_id) ?? 0n) + (leg.side === 'debit' ? amount : -amount));
  }
  if (raw.size < 2) return rejected('DISTINCT_ACCOUNTS_REQUIRED');
  const totals = {debit_minor:debit.toString(),credit_minor:credit.toString(),residual_minor:(debit-credit).toString()};
  if (debit !== credit) return rejected('UNBALANCED_JOURNAL',totals);
  const movements = [...raw].map(([account_id,value]) => ({account_id,raw_delta_minor:value.toString(),normal_delta_minor:(lookup.get(account_id)!.normal_side === 'debit' ? value : -value).toString()}));
  return {status:'accepted',code:'VALID_JOURNAL',totals,movements,journal:copy(command)};
}
export function initialState(accounts:any, ledger_id='CB-GENERAL', entity_id='CB-A'): any {
  if (!accountsOk(accounts) || !identifier(ledger_id) || !identifier(entity_id)) throw new Error('Invalid trusted initial state');
  return {ledger_id,entity_id,accounts:copy(accounts),journals:[]};
}
export function postJournal(state:any, command:any, context:any): any {
  const base = copy(state);
  const fail = (code:string,totals:any=null) => ({...rejected(code,totals),state:base});
  if (!exact(state,['ledger_id','entity_id','accounts','journals']) || !identifier(state.ledger_id) || !identifier(state.entity_id) || !accountsOk(state.accounts) || !Array.isArray(state.journals)) return fail('INVALID_STATE');
  let previous:string|null = null;
  for (let i=0;i<state.journals.length;i++) {
    const j = state.journals[i];
    if (!object(j) || j.sequence !== String(i+1) || !timestamp(j.recorded_at) || j.ledger_id !== state.ledger_id || j.entity_id !== state.entity_id || (previous !== null && j.recorded_at < previous)) return fail('INVALID_STATE');
    previous = j.recorded_at;
  }
  if (object(command) && (command.ledger_id !== state.ledger_id || command.entity_id !== state.entity_id)) return fail('SCOPE_FORBIDDEN');
  const decision = validateJournal(command,state.accounts,context);
  if (decision.status === 'rejected') return {...decision,state:base};
  if (previous !== null && context.recorded_at < previous) return fail('RECORDED_TIME_REGRESSION');
  if (state.journals.some((j:RecordValue) => j.journal_id === command.journal_id)) return fail('JOURNAL_ID_CONFLICT');
  const committed = {...copy(command),sequence:String(state.journals.length+1),recorded_at:context.recorded_at,adjustment_reason:command.booking_period !== command.effective_at.slice(0,7) ? trimReason(context.adjustment_reason ?? '') : ''};
  base.journals.push(copy(committed));
  return {...decision,status:'committed',code:'POSTED',journal:committed,state:base};
}
Full-height labposting 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.