Skip to content

Double-entry engine (developer deep dive)

The invariants, helpers, and state machine that enforce correct double-entry postings in the app. Read this before touching anything that writes to JournalEntry / JournalLine / LedgerEntry, or before adding a new voucher type.

Audience: developers maintaining the finance layer. Assumes basic double-entry accounting knowledge (debits = credits, normal sides, reversal semantics).


1. Invariants

# Invariant Where enforced
I1 Every JournalEntry has at least 2 JournalLine rows. src/lib/api.ts:1926-1927
I2 Σ debit = Σ credit on every entry (within ±0.01 rounding tolerance). src/lib/api.ts:1929-1933
I3 debit > 0 on at least one line (no all-zero balanced entries). src/lib/api.ts:1931
I4 Every JournalLine has a non-null accountId. src/lib/api.ts:1926 (filters lines without one) + FK JournalLine.accountId → Account.id ON DELETE RESTRICT (supabase/migrations/20260113130121_remote_schema.sql:1076)
I5 Line currency matches the account's configured currency, if both are set. resolveFinancialAccountPostingAccount() in src/lib/financeOperations.ts:180-184
I6 An entry cannot land in a locked or closed AccountingPeriod. API: assertAccountingPeriodAllowsPosting() in src/lib/api.ts:164-183; DB: enforce_accounting_period_lock trigger in supabase/migrations/20260418150000_journal_period_lock_trigger.sql
I7 A journal has exactly one reversal at most (no duplicate reversals of the same entry). API pre-check at src/lib/api.ts:21925-21928; post-insert duplicate detector + rollback at :21945-21975
I8 A reversal voucher cannot itself be reversed. src/lib/api.ts:21890if (original.isReversal) throw ...
I9 Reversals are only valid on approved originals (not pending or rejected). src/lib/api.ts:21907-21924
I10 A user cannot self-approve / self-reject / self-reverse their own journal (maker-checker). src/lib/api.ts:22030-22039, :22079-22088, :21894-21902. Override: finance.journals.approve_own / reverse_own.
I11 Control-account postings (Customer / Agent / Supplier) must go through per-party sub-ledgers (CUS-* / AGR-* / AGP-* / SUP-*). Convention; enforced by the resolver helpers in src/lib/api.ts. No DB constraint.
I12 An account with isGroup = true must not be the target of a JournalLine. validateFinancialAccountPosting() indirectly (most group accounts are allowDebit = allowCredit = false).
I13 Voucher numbers are unique per session and monotonically derived from createdAt date + 6 hex of entry id. src/lib/api.ts:1943-1957.

I2 is load-bearing

If |Σ debit - Σ credit| > 0.01 ever lands in the DB, every downstream report (Trial Balance, P&L, Balance Sheet) is silently wrong. The caller-side validator rejects before insert, but a direct SQL INSERT from psql bypasses it. A BEFORE-INSERT trigger that validates balance across both JournalEntry + JournalLine is tracked as <!-- TODO: verify — add a trigger that computes SUM(debit) - SUM(credit) over NEW.entryId and raises on mismatch. -->.


2. Enforcement layers

Three defensive layers wrap the invariants. The order matters because the outer layers short-circuit the inner:

flowchart TD
    A[Caller POST /finance/journals] --> B[Zod / TS validation at handler boundary]
    B --> C[createJournalWithLines invariants I1-I4]
    C --> D[assertAccountingPeriodAllowsPosting I6 - API]
    D --> E[INSERT JournalEntry]
    E --> F[DB trigger enforce_accounting_period_lock I6 - DB]
    F --> G[INSERT JournalLines]
    G --> H[Return entry]
    style C fill:#dff
    style F fill:#fdd
Layer Covers Code
Handler boundaryrequirePermission, body shape Auth, request shape src/lib/api.ts:22107-22124 (the create path)
createJournalWithLines — invariants I1-I4, voucher number, period pre-check Balance, minimum lines, entryDate normalisation, FinanceConfig-driven prefix src/lib/api.ts:1887-2009
Posting helpersvalidateFinancialAccountPosting, resolveFinancialAccountPostingAccount I5 currency match, allowDebit/allowCredit/allowNegativeBalance, group-vs-leaf src/lib/financeOperations.ts:80-194
DB trigger — period lock I6 backstop if caller bypasses the API (service-role, psql) supabase/migrations/20260418150000_journal_period_lock_trigger.sql
DB CHECK / FK — referential integrity I4 via FK supabase/migrations/20260113130121_remote_schema.sql:1076

Zod / runtime schemas live alongside the posting-path handlers; see src/lib/financeOperations.ts:80+ for the validator pattern applied to every write.


3. Key helper — resolveFinancialAccountPostingAccount

src/lib/financeOperations.ts:151-194. Single entry point for resolving a FinancialAccount (bank/cash/card/wallet) into its matching GL leaf while enforcing I5 currency consistency.

const { financialAccount, glAccount } = await resolveFinancialAccountPostingAccount(deps, {
  accountId,
  amount,
  transactionType: 'credit',       // or 'debit'
  now,
  postingCurrency: 'INR',          // MUST be passed — see warning
  excludeReferenceType,            // for balance checks on re-posts
  excludeReferenceId,
});

Under the hood it:

  1. Calls validateFinancialAccountPosting():
  2. Rejects if the account doesn't exist.
  3. Rejects if the requested side (debit / credit) is disabled on the account (allowDebit / allowCredit).
  4. Rejects if the post would drive the balance negative AND allowNegativeBalance is false.
  5. Compares postingCurrency to Account.currency. Mismatch → throws "Currency mismatch: cannot post X to <account> (Y)".
  6. Returns { financialAccount, glAccount } — the caller uses glAccount.id as the accountId on the JournalLine.

Pass postingCurrency or you'll get a warning

When postingCurrency is an empty string, the helper logs a warning and skips the currency check rather than throwing. Comment in the source:

We don't throw here because tightening would silently break every legacy caller we haven't migrated yet — instead, emit a warning so production logs surface the remaining gaps and we can mop them up iteratively.

Every new call must pass an explicit postingCurrency — do not inherit the legacy silent-skip behaviour.


4. Posting flow — state machine

stateDiagram-v2
    [*] --> draft: UI composer (client-only)
    draft --> pending: POST /finance/journals
    pending --> approved: POST /finance/journals/:id/approve
    pending --> rejected: POST /finance/journals/:id/reject
    approved --> reversed: POST /finance/journals/:id/reverse (creates a new isReversal entry)
    pending --> [*]
    rejected --> [*]
    reversed --> [*]
State In DB as Posts to live balances? Can transition to?
draft Not persisted (UI only) No pending (on submit)
pending JournalEntry.status = 'pending' No — most reports filter to approved approved, rejected
approved status = 'approved' Yes (reversed via a sibling entry)
rejected status = 'rejected' No, terminal
reversed Original stays approved; sibling entry has isReversal = true, reversalOfEntryId = <orig> Sibling entry posts immediately (auto-approved)

4.1 What's immutable once posted

  • Lines (JournalLine rows) are not edited after insert. If a posted entry is wrong, reverse it and post a corrected entry.
  • voucherNo is stable.
  • referenceType / referenceId are stable.
  • currency / originalAmount / fxRateUsed — pinned at posting time for audit.
  • createdBy — never overwritten.

Only status mutates (pending → approved / rejected) and reversalOfEntryId is set on a reversal sibling (not on the original).

4.2 Default status and why it's pending

createJournalWithLines defaults status: 'pending' (src/lib/api.ts:1973). Exceptions that auto-approve:

  • isReversal = true'approved' immediately (a reversal of a live entry shouldn't deadlock in the approval queue).
  • Explicit status: 'approved' passed by the caller — used by system-correction handlers that need synchronous posting.

The DB column default is aligned via supabase/migrations/20260418110000_journal_entry_status_default_pending.sql so any writer that bypasses the API helper still produces pending rows by default.


5. Voucher → journal translation

Every voucher form produces exactly one journal entry via createJournalWithLines. The translation is deterministic.

Example 1 — customer pays ₹1,000 by bank transfer

POST /finance/vouchers/customer-on-account-receipt
{ customerId, amount: 1000, sourceAccountId: <bank-id>, ... }

Produces:

JournalEntry { voucherNo: 'RV-20260418-A1B2C3', referenceType: 'customer_on_account', status: 'approved' }
JournalLine  { accountId: 1010 Bank Account,        debit: 1000, credit: 0 }
JournalLine  { accountId: CUS-<customerId>,         debit: 0,    credit: 1000 }

Example 2 — supplier invoice ₹10,000 + ₹1,800 GST

POST /suppliers/:id/transactions
{ amount: 11800, gstRate: 18, gstAmount: 1800, category: 'hotel', ... }

Produces:

JournalEntry { voucherNo: 'PV-...', referenceType: 'supplier_transaction', status: 'pending' }
JournalLine  { accountId: 5200 Hotel Expenses,     debit: 10000, credit: 0 }
JournalLine  { accountId: 1400 GST Input Credit,   debit: 1800,  credit: 0 }
JournalLine  { accountId: SUP-<supplierId>,        debit: 0,     credit: 11800 }

Example 3 — contra (bank → petty cash) of ₹5,000

POST /finance/vouchers/contra
{ fromAccountId: <bank>, toAccountId: <cash>, amount: 5000 }

Produces:

JournalEntry { voucherNo: 'CT-...', referenceType: 'contra_transfer', status: 'pending' }
JournalLine  { accountId: 1000 Cash,         debit: 5000, credit: 0 }
JournalLine  { accountId: 1010 Bank Account, debit: 0,    credit: 5000 }

6. Reversal mechanics

A reversal is a new entry, not a delete. src/lib/api.ts:21930-1943:

const reversal = await createJournalWithLines({
  referenceType: original.referenceType ?? 'journal_reversal',
  referenceId: original.referenceId ?? original.id,
  memo: `Reversal of ${original.voucherNo || original.id}${original.memo ? ' · ' + original.memo : ''}`,
  createdBy: body?.createdBy ?? null,
  entryDate: body?.entryDate ?? null,
  reversalOfEntryId: original.id,
  isReversal: true,
  lines: (original.lines || []).map((line) => ({
    accountId: line.accountId,
    debit: Number(line.credit ?? 0),     // flip
    credit: Number(line.debit ?? 0),     // flip
  })),
});

Key properties:

  • Flipped lines. Each line's debit/credit are swapped — net effect on every account is exactly zero when original + reversal both land.
  • Preserves voucher audit. Both rows remain in the GL; Trial Balance nets to zero; the reversal is discoverable via reversalOfEntryId.
  • Uses the REV prefix. Voucher number pattern REV-YYYYMMDD-<6hex>.
  • Auto-approves. See §4.2.
  • One reversal max. The handler refuses a second reversal of the same original (I7).
  • Respects the period lock. Reversal's entryDate is checked by the same trigger — a back-dated reversal into a locked period is blocked (comment in supabase/migrations/20260418150000_journal_period_lock_trigger.sql:22-30).

7. Concurrency guard (conditional UPDATE pattern)

PR #113 introduced the UPDATE ... WHERE status = 'pending' + .select() pattern to prevent two simultaneous approvals from double-posting. Codebase-wide pattern — applied on approve, reject, and reverse.

Pattern

const { data: updated, error: updateErr } = await supabase
  .from('JournalEntry')
  .update({ status: 'approved' })
  .eq('id', entryId)
  .eq('status', 'pending')          // conditional
  .select('id,status');             // returns ONLY rows that matched

if (updateErr) throw new ApiError(400, updateErr);
const affected = Array.isArray(updated) ? updated.length : updated ? 1 : 0;

if (affected === 0) {
  // Lost the race — re-fetch and respond idempotently OR 409.
  const { data: current } = await supabase
    .from('JournalEntry')
    .select('id,status')
    .eq('id', entryId)
    .maybeSingle();

  if (!current) throw new ApiError(404, ...);
  if (current.status === 'approved') {
    return { ok: true, message: 'Already approved', entryId, alreadyApplied: true };
  }
  throw new ApiError(409, { message: `Cannot approve: journal is now ${current.status}.` });
}

await logAudit('finance.voucher.approve', 'journal_entry', entryId, { previousStatus: entry.status });

Why it works:

  • The conditional eq('status', 'pending') means only the first caller's UPDATE matches. The losing caller affects zero rows.
  • .select() returns the affected rows — zero is an unambiguous lost-race signal, not merely "UPDATE succeeded but matched nothing".
  • The lost caller re-fetches to distinguish:
  • Already approved (idempotent — return 200 with alreadyApplied: true).
  • Moved to an unexpected terminal state — 409.
  • Audit is written only on the winning path. The losing caller has no state change to log.

For reversals

Concurrency for reversals is trickier because createJournalWithLines isn't idempotent on its own. The handler does:

  1. Pre-check: reversalOfEntryId = <orig> must not exist.
  2. Insert reversal.
  3. Post-insert: re-query all reversals of the original. If > 1 now exists, pick the earliest-created as the winner (deterministic tiebreaker on id if timestamps collide). Roll back the losing row's JournalLine and JournalEntry inserts.

Source: src/lib/api.ts:21945-21975.


8. Idempotency pattern

Same-action repeat is a no-op. Every finance write honours this shape:

  1. Fetch current state. SELECT status FROM JournalEntry WHERE id.
  2. Short-circuit if already in the target state.
  3. status === 'approved' on an approve call → return { ok: true, alreadyApplied: true }.
  4. status === 'rejected' on a reject call → same.
  5. Reject if in a conflicting terminal state. Can't approve a rejected entry (409 with actionable message).
  6. Attempt the transition via the conditional UPDATE pattern.
  7. On affected = 0, re-fetch and decide: idempotent success or 409.

Benefits:

  • A double-clicked "Approve" button never double-audits.
  • A network retry on the client does the safe thing.
  • The UI can safely show a toast on success without worrying whether the user pressed twice.

Idempotency-key patterns (request-level hashes) exist elsewhere in the codebase for voucher form submissions — see src/lib/api.ts:2044 and the comment block around it.


9. FX capture

For foreign-currency postings, four fields pin the audit trail:

Field Level Purpose
JournalEntry.currency Entry Source currency ('SAR', 'USD').
JournalEntry.originalAmount Entry Pre-conversion total.
JournalEntry.fxRateUsed Entry Rate multiplied into the INR amount at posting time (NUMERIC(18,8)).
JournalLine.originalDebit / .originalCredit / .originalCurrency / .fxRate Line Per-line source-currency audit.

Migrations:

  • supabase/migrations/20260412050000_journal_entry_currency.sql — entry-level currency.
  • supabase/migrations/20260412060000_journal_original_amount.sqloriginalAmount.
  • supabase/migrations/20260418060325_journal_fx_rate.sqlfxRateUsed.
  • supabase/migrations/20260418150000_journal_line_original_amounts.sql — per-line columns.

All nullable. null means "INR → INR, no conversion happened".

Never edit fxRateUsed after posting

The rate is pinned so auditors can reconstruct the original amount without consulting the (potentially later-mutated) source contract's exchangeRate column. If the source rate is wrong, reverse + re-post — do not UPDATE.


10. Posting paths index

Every place in src/lib/api.ts that calls createJournalWithLines is a posting path. Categorised summary (find the line numbers via grep 'createJournalWithLines' src/lib/api.ts):

Category Handlers
Bookings Sale posting, GST on booking, booking reversal, booking adjustment, booking commission, cancellation
Group invoices Issue (debit CUS-*, credit revenue + GST payable), cancel (reversing credit-note)
Payments Customer payment verified, agent on-account receipt, agent receipt allocate, customer on-account, supplier refund receipt
Supplier transactions Supplier invoice booked, supplier payment, TDS withheld
Contra Bank → bank / bank → cash / cash → bank
Cross-ledger settlement Customer credit against supplier credit (B2B partner close-out)
Inventory Hotel assignment, airline block purchase, ground transfer, food
Opening balances Account opening-balance import, per-account opening balance
Manual /finance/journals POST
System corrections Rare; explicitly pass status: 'approved'

Every one of these must maintain I1-I10. When adding a new path:

  1. Resolve account IDs via the helpers (do NOT hardcode codes in handler logic — go through FinanceConfig + ensureFinanceAccount).
  2. Build a lines: [] array that balances.
  3. Call createJournalWithLines — let it enforce I1-I4 and I6.
  4. If the write is user-triggered, add a logAudit(...) after.
  5. If the entity type is stateful (approvable/reversable), use the conditional UPDATE + re-fetch pattern from §7.

11. Cross-references

  • Chart of accounts — the account catalog this engine consumes.
  • Journals — endpoint-level reference.
  • Maker-checker — I10 in detail.
  • Audit trail — what gets logged at each state transition.
  • src/lib/financeOperations.ts — helpers (validate, resolve, supplier postings, TDS interaction).
  • src/lib/financePosting.ts — supplier-expense account resolution
  • posting helpers.
  • src/lib/api.finance.test.ts — invariant tests.
  • src/lib/api.finance.audit.test.ts — approval / maker-checker behavioural tests.