DEV Community

Alan Matthew
Alan Matthew

Posted on

How I Built an Accounting Engine in Pure TypeScript (And What Broke Along the Way)

Most developers write their first financial logic the same way: simple primitives, naive addition, and a quick check that everything sums to zero.

Then reality strikes.

A rounding error turns $0.01 into $0.010000000000000002. A debit gets inverted as a credit. A user enters a transposed value—$5,400 instead of $4,500—and your ledger quietly goes out of balance without throwing a runtime error.

To solve this systematically, I built the Accounting Equation Calculator as a specialized, zero-dependency engine. It models the core identity of double-entry accounting:

$$\text{Assets} = \text{Liabilities} + \text{Owner's Equity}$$

Building this taught me that turning a classic mathematical identity into a deterministic software engine requires handling floating-point arithmetic, dynamic state transitions, and diagnostic heuristics.


The Anatomy of the Equation

Every balance sheet boils down to three core concepts:

  • Assets ($A$): Economic resources controlled by an entity (cash, accounts receivable, equipment, inventory).
  • Liabilities ($L$): Outsider claims against those resources (loans, accounts payable, accrued payroll).
  • Owner's Equity ($E$): The residual interest belonging to shareholders after deducting liabilities ($E = A - L$). +-------------------------------------------------------------+ | DOUBLE-ENTRY EQUILIBRIUM | | | | TOTAL ASSETS = CLAIMS ON ASSETS | | [ Resources ] [ Creditors ] | | | + | | v [ Owners ] | | A = L + E | +-------------------------------------------------------------+ In real-world business cycles, equity is never static. It expands dynamically across an accounting period: In an accounting engine, an undetected micro-penny discrepancy cascades through every trial balance.

The Fix: Normalize all incoming values to integer cents (or micro-units) before executing any arithmetic, then convert back for presentation.
// Safe decimal-to-integer conversion
const toCents = (dollars: number): bigint => {
return BigInt(Math.round(dollars * 100));
};

const toDollars = (cents: bigint): number => {
return Number(cents) / 100;
};

Challenge 2: Designing the Engine
The engine needed to handle two distinct operational modes:

Solve Mode: Given any two known high-level components (e.g., Assets and Liabilities), derive the third.

Audit Mode: Given all parameters (including operational revenues and expenses), check parity and return precise discrepancy metrics.

Here is the complete, typed implementation:

export interface LedgerInput {
assets?: number | null;
liabilities?: number | null;
equity?: number | null;
revenue?: number;
expenses?: number;
drawings?: number;
}

export interface EngineDiagnostic {
isBalanced: boolean;
discrepancy: number;
transpositionSuspected: boolean;
signInversionSuspected: boolean;
}

export interface LedgerResult {
assets: number;
liabilities: number;
equity: number;
diagnostic: EngineDiagnostic;
}

export class AccountingEngine {
private static toCents(val: number = 0): bigint {
return BigInt(Math.round(val * 100));
}

private static toDollars(val: bigint): number {
return Number(val) / 100;
}

public static evaluate(input: LedgerInput): LedgerResult {
const rev = this.toCents(input.revenue ?? 0);
const exp = this.toCents(input.expenses ?? 0);
const draw = this.toCents(input.drawings ?? 0);

// Net operational expansion: Revenue - Expenses - Drawings
const netOperations = rev - exp - draw;

let a = input.assets !== null && input.assets !== undefined 
  ? this.toCents(input.assets) 
  : null;
let l = input.liabilities !== null && input.liabilities !== undefined 
  ? this.toCents(input.liabilities) 
  : null;
let e = input.equity !== null && input.equity !== undefined 
  ? this.toCents(input.equity) 
  : null;

// Case 1: Solve for Assets (A = L + E)
if (a === null && l !== null && e !== null) {
  const totalEquity = e + netOperations;
  a = l + totalEquity;
  return this.formatResult(a, l, totalEquity, 0n);
}

// Case 2: Solve for Liabilities (L = A - E)
if (l === null && a !== null && e !== null) {
  const totalEquity = e + netOperations;
  l = a - totalEquity;
  return this.formatResult(a, l, totalEquity, 0n);
}

// Case 3: Solve for Equity (E = A - L)
if (e === null && a !== null && l !== null) {
  const derivedTotalEquity = a - l;
  return this.formatResult(a, l, derivedTotalEquity, 0n);
}

// Case 4: Audit Mode (All variables supplied)
if (a !== null && l !== null && e !== null) {
  const totalEquity = e + netOperations;
  const discrepancy = a - (l + totalEquity);
  return this.formatResult(a, l, totalEquity, discrepancy);
}

throw new Error("Insufficient parameters: Supply at least two primary accounts.");
Enter fullscreen mode Exit fullscreen mode

}

private static formatResult(
a: bigint,
l: bigint,
e: bigint,
discrepancyCents: bigint
): LedgerResult {
const diff = this.toDollars(discrepancyCents);
const absDiffCents = discrepancyCents < 0n ? -discrepancyCents : discrepancyCents;

return {
  assets: this.toDollars(a),
  liabilities: this.toDollars(l),
  equity: this.toDollars(e),
  diagnostic: {
    isBalanced: discrepancyCents === 0n,
    discrepancy: diff,
    // Heuristic 1: Digit Transposition (Divisible by 9)
    transpositionSuspected: absDiffCents > 0n && (absDiffCents % 9n === 0n),
    // Heuristic 2: Sign / Side Inversion (Divisible by 2)
    signInversionSuspected: absDiffCents > 0n && (absDiffCents % 2n === 0n)
  }
};
Enter fullscreen mode Exit fullscreen mode

}
}

Challenge 3: Diagnostic Heuristics for Debugging
When a ledger doesn't balance, simply stating isBalanced: false is unhelpful to a user debugging dozens of transactions. The engine uses two classic accounting validation rules:

  1. The Rule of 9 (Transposition Detection) A transposition error occurs when adjacent digits are accidentally swapped during manual data entry (e.g., typing $8,100 instead of $1,800).

The mathematical property of base-10 numbers ensures that the difference between any number and its transposed counterpart is always evenly divisible by 9:

  1. The Rule of 2 (Debit/Credit Inversion)If an entry meant for the debit side is erroneously applied as a credit, the discrepancy between the two totals equals exactly twice the value of the misallocated transaction:

Takeaways:
Never use IEEE 754 floats for balance calculations. Converting to integer representations (cents) avoids floating-point inaccuracies.Accounting identities are state constraints. Enforcing $A = L + E$ acts as a structural validation layer across transactional operations.Domain heuristics provide better diagnostics. Adding algorithmic checks like the Rule of 9 and Rule of 2 helps pinpoint root causes instead of just flagging errors.If you are building an accounting feature or need to balance a ledger for coursework, you can test the production implementation on the Accounting Equation Calculator.How are you handling ledger validation and invariant checks in your financial web apps? Let's discuss patterns in the comments.

Top comments (1)

Collapse
 
alan-matthew profile image
Alan Matthew

If someone have any question, feel free to ask, thank you.