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:21890 — if (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 boundary — requirePermission, 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 helpers — validateFinancialAccountPosting, 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:
- Calls
validateFinancialAccountPosting(): - Rejects if the account doesn't exist.
- Rejects if the requested side (
debit/credit) is disabled on the account (allowDebit/allowCredit). - Rejects if the post would drive the balance negative AND
allowNegativeBalanceisfalse. - Compares
postingCurrencytoAccount.currency. Mismatch → throws"Currency mismatch: cannot post X to <account> (Y)". - Returns
{ financialAccount, glAccount }— the caller usesglAccount.idas theaccountIdon theJournalLine.
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 (
JournalLinerows) are not edited after insert. If a posted entry is wrong, reverse it and post a corrected entry. voucherNois stable.referenceType/referenceIdare 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
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
REVprefix. Voucher number patternREV-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
entryDateis checked by the same trigger — a back-dated reversal into a locked period is blocked (comment insupabase/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:
- Pre-check:
reversalOfEntryId = <orig>must not exist. - Insert reversal.
- Post-insert: re-query all reversals of the original. If > 1 now
exists, pick the earliest-created as the winner (deterministic
tiebreaker on
idif timestamps collide). Roll back the losing row'sJournalLineandJournalEntryinserts.
Source: src/lib/api.ts:21945-21975.
8. Idempotency pattern
Same-action repeat is a no-op. Every finance write honours this shape:
- Fetch current state.
SELECT status FROM JournalEntry WHERE id. - Short-circuit if already in the target state.
status === 'approved'on an approve call →return { ok: true, alreadyApplied: true }.status === 'rejected'on a reject call → same.- Reject if in a conflicting terminal state. Can't approve a rejected entry (409 with actionable message).
- Attempt the transition via the conditional UPDATE pattern.
- 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-levelcurrency.supabase/migrations/20260412060000_journal_original_amount.sql—originalAmount.supabase/migrations/20260418060325_journal_fx_rate.sql—fxRateUsed.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:
- Resolve account IDs via the helpers (do NOT hardcode codes in
handler logic — go through
FinanceConfig+ensureFinanceAccount). - Build a
lines: []array that balances. - Call
createJournalWithLines— let it enforce I1-I4 and I6. - If the write is user-triggered, add a
logAudit(...)after. - 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.