Day 4: Ledger Engine and Financial Truth

ledger transactions reconciliation architecture

Today we defined what "financial truth" means for Accelerate Finance. No new features. No UI polish. Just correctness. After this sprint, money movement is unambiguous, auditable, and AI-readable.

The Core Problem

Financial software has a fundamental question to answer: Where does truth live?

In Sprint 2, we had a subtle bug. The currentBalance field on accounts was stored directly—but what if it drifted from reality? What if a transaction update didn't trigger a recalculation? The balance would lie.

Today we fixed that with a simple rule:

Account Balance = initialBalance + sum(all transactions)

Always. No exceptions. Derived, not stored.

The LedgerService: Domain Boundary

We introduced a LedgerService—the single source of truth for all financial operations. This is not just a helper. It's a domain boundary.

What LedgerService Does

  • + Creates transactions (income, expense)
  • + Creates transfers (two linked transactions)
  • + Creates split transactions (one parent, multiple categorized children)
  • + Calculates derived balances (always authoritative)
  • + Manages reconciliation state
  • + Syncs balance caches (so UI stays fast)

What Repositories Do NOT Do

  • - Compute balances (that's business logic)
  • - Enforce transfer invariants
  • - Validate split sums
  • - Any financial logic—just persistence

How Transfers Work

A transfer is money moving between accounts. The question: how do you represent this?

We chose the two-transaction model:

  • One transaction leaves the source account (negative amount)
  • One transaction enters the destination (positive amount)
  • Both share a transferGroupId
  • Deleting one deletes both (enforced by LedgerService)

This means transfers never double-count. Each account's balance is correct independently. The ledger sums correctly whether you look at one account or all accounts.

How Splits Work

A split transaction breaks one payment into multiple category allocations. Example: a $100 grocery run that's 60% food, 40% household supplies.

// Parent transaction (holds the total)
{
  amount: -10000,  // -$100
  splitGroupId: "abc123",
  splitParentId: null,  // This IS the parent
  categoryId: null      // No category on parent
}

// Child 1: Groceries
{
  amount: -6000,   // -$60
  splitGroupId: "abc123",
  splitParentId: "parent-id",
  categoryId: "groceries-id"
}

// Child 2: Household
{
  amount: -4000,   // -$40
  splitGroupId: "abc123",
  splitParentId: "parent-id",
  categoryId: "household-id"
}

Key invariant: sum(children) must equal parent. The LedgerService enforces this at creation time.

Why Reconciliation Exists

Reconciliation is the process of verifying transactions against your bank statement. It answers: "Does my ledger match reality?"

We added a reconciledAt timestamp to every transaction:

  • null = unreconciled (not yet verified)
  • Timestamp = when it was marked as verified

Reconciliation does NOT affect balance calculation. All transactions count. But reconciliation gives you:

  • Confidence — "These transactions are real, not typos"
  • Audit trail — "I verified this on Dec 20"
  • Import safety — "Don't duplicate reconciled transactions"

The Account Model Correction

We kept currentBalance on the Account model, but with explicit documentation:

currentBalance is a CACHE

It exists for performance. The LedgerService is the authoritative source. If the cache drifts, recalculate it. The UI shows cached values for speed, but the ledger calculates truth.

We added auditWorkspaceBalances() to detect and fix drift—a diagnostic tool for debugging.

Explicit Enums

Every "type" field now has an explicit enum with documented semantics:

ACCOUNT_TYPES = ['checking', 'savings', 'credit', 'cash', 'investment', 'loan', 'other']
TRANSACTION_TYPES = ['income', 'expense', 'transfer']
CATEGORY_TYPES = ['income', 'expense']
VIEW_TYPES = ['ledger', 'budget', 'report', 'dashboard', 'custom']

Plus helper functions: isAssetAccount(), isLiabilityAccount() for net worth calculations.

Ledger Invariants

The LedgerService guarantees these invariants:

1. Account balance = initialBalance + sum(transactions)

2. Transfers create exactly two transactions with equal absolute amounts

3. Split children sum to parent amount

4. No orphan transactions (every tx has valid account)

5. No floating point—all amounts are integers (cents)

6. Deleting a transfer deletes both sides

What We Built Today

  • LedgerService — Domain boundary for all financial operations
  • Transaction refinements — payee field, transferGroupId, splitGroupId, reconciledAt
  • Transfer support — Two linked transactions that move together
  • Split support — Parent/child model with category allocation
  • Reconciliation — Mark/unmark transactions as verified
  • Explicit enums — Type-safe, documented type fields
  • Ledger UI — Functional view with date filtering, reconciliation toggle, edit/delete

Financial truth is now defined. Balances are derived, not stored. Transfers don't double-count. Splits sum correctly. Reconciliation persists. The ledger works offline. No repository contains business logic.

This foundation means we can trust the numbers. AI can trust the numbers. Users can trust the numbers.

— The Accelerate Finance Team