Journal entries
Manual and system-generated double-entry postings. Covers lifecycle, maker-checker, FX capture, concurrency guards, and the audit trail.
A JournalEntry is one voucher; its JournalLine rows spell out the
debits and credits. Every entry that hits the ledger goes through the
same state machine regardless of who initiated it (UI, booking posting,
voucher form, system-correction).
1. Lifecycle
stateDiagram-v2
[*] --> draft: UI composer
draft --> pending: Submit for approval
pending --> approved: /finance/journals/:id/approve
pending --> rejected: /finance/journals/:id/reject
approved --> reversed: /finance/journals/:id/reverse
pending --> [*]: never posted
rejected --> [*]: terminal
reversed --> [*]: reversal voucher inserted
- draft is UI-only. The API has no draft state — a journal only exists in the DB once the composer posts it.
- pending is the default for every manual + system-posted journal
created via
createJournalWithLinesunless the handler explicitly passesstatus: 'approved'(src/lib/api.ts:1904-1908). Reversals auto-approve (src/lib/api.ts:1971-1975). - approved lines hit live balances. Reports filter to approved-only in most places (e.g. receivables/payables aggregation).
- rejected is terminal. You cannot re-approve; create a fresh entry
instead (
src/lib/api.ts:22029). - reversed is not a distinct status on the original entry — the
system inserts a new
isReversal = truerow whosereversalOfEntryIdpoints at the original. The original keepsstatus='approved'.
2. Endpoints
All live in src/lib/api.ts, reachable through handleFinanceJournals
(src/lib/api.ts:21597 onward).
| Method | Path | Permission | Purpose |
|---|---|---|---|
GET |
/finance/journals |
finance.view |
Paginated list, filterable by referenceType + referenceId. |
GET |
/finance/journals/pending |
finance.view |
All pending journals for the approvals queue. |
GET |
/finance/journals/lines |
finance.view |
Raw lines (used by Dashboard P&L summary — cap at 20,000). |
GET |
/finance/journals/:id |
finance.view |
Voucher + lines + resolved createdBy + reference label. |
GET |
/finance/journals/:id/agent-receipt-summary |
finance.view |
Applied/unapplied totals for an agent_on_account voucher. |
POST |
/finance/journals |
finance.create |
Create a new manual journal (default status pending). |
POST |
/finance/journals/:id/approve |
approvals.approve |
Approve one journal. Maker-checker + concurrency guarded. |
POST |
/finance/journals/:id/reject |
approvals.approve |
Reject a pending journal. |
POST |
/finance/journals/:id/reverse |
finance.edit |
Post a reversal voucher. |
POST |
/finance/journals/approve-bulk |
approvals.approve |
Approve multiple pending journals; returns approvedIds, skippedIds, skipped. |
Create — createJournalWithLines
Shared helper at src/lib/api.ts:1887. Every write path that posts to
the GL routes through it. Validates:
- ≥ 2 lines with a non-null
accountId(:1926-1927). - Balanced —
|debit − credit| ≤ 0.01anddebit > 0(:1929-1933). - Period open —
assertAccountingPeriodAllowsPosting(entryDate)(:1935). - Voucher number generated from
FinanceConfigprefixes (:1940-1957).
Input fields worth calling out:
entryDate— used ascreatedAton the entry. Drives the period-lock check and any date-bucketed report.currency,originalAmount— header-level source currency + total.fxRateUsed— header-level FX rate pinned at posting time. See FX capture below.lines[].originalDebit/.originalCredit/.originalCurrency/.fxRate— per-line source-currency audit.isReversal— auto-approves and uses theREVprefix.reversalOfEntryId— back-link to the original entry.
3. Maker-checker
Three guards run before each write:
| Operation | Location | Guard |
|---|---|---|
| Approve | src/lib/api.ts:22030-22039 |
If entry.createdBy === actorId and the user does not hold finance.journals.approve_own, throws 403. |
| Reject | src/lib/api.ts:22079-22088 |
Same check. |
| Reverse | src/lib/api.ts:21894-21902 |
Same check, override is finance.journals.reverse_own. |
| Bulk approve | src/lib/api.ts:21675-21698 |
Filters self-authored ids into skipped[] with reason maker_checker_self_approval. Does not error. |
The overrides are super-admin only
Only CEO, GM, and IT_ADMIN hold finance.journals.approve_own
and finance.journals.reverse_own (see
supabase/migrations/20260418150000_journal_maker_checker_overrides.sql).
FINANCE_MANAGER and ACCOUNTANT never hold them — those users
must use a second pair of eyes on every journal they post.
4. FX rate capture
Two migrations add the audit trail for foreign-currency postings:
supabase/migrations/20260418060325_journal_fx_rate.sql— addsJournalEntry.fxRateUsed numeric(18,8). Header-level rate.supabase/migrations/20260418150000_journal_line_original_amounts.sql— addsJournalLine.originalDebit / originalCredit / originalCurrency / fxRate. Per-line source values.
Why pin the rate?
The INR line amounts are derived from the source row's
exchangeRate at posting time. If someone edits that rate later,
the interpretation of every historical voucher silently shifts.
Pinning fxRateUsed + per-line originals lets an auditor reconstruct
the source-currency posting without consulting the (potentially
mutated) source.
All four columns are NULL on pure-INR postings (no FX conversion
happened).
5. Concurrency guards
Every state-changing endpoint uses a conditional UPDATE + .select()
pattern so two simultaneous callers cannot both succeed.
Approve (single)
src/lib/api.ts:22042-22060:
const { data: updated } = await supabase
.from('JournalEntry')
.update({ status: 'approved' })
.eq('id', entryId)
.eq('status', 'pending') // conditional — only flip pending→approved
.select('id,status'); // reveals the rows actually touched
If updated.length === 0 the caller lost the race. The code re-fetches
the row:
- current status is
approved→ return{ ok: true, alreadyApplied: true }(idempotent). - current status is anything else → throw 409.
Crucially, the losing caller does NOT write an audit log row —
we only audit actual transitions (src/lib/api.ts:22059).
Reject (single)
Mirrors approve at src/lib/api.ts:22089-22104.
Approve-bulk
src/lib/api.ts:21707-21723:
- Filter self-authored ids into
skipped[](maker-checker). - Conditional UPDATE with
.in('id', approvableIds).eq('status', 'pending'). - Any id that didn't come back in the SELECT is added to
skippedIdswith reasonconcurrent_transition. - One audit row is written per actually-approved id (not per request).
Reverse
The reverse endpoint uses a two-phase approach
(src/lib/api.ts:21878-22004):
- Pre-check: the original must be
isReversal=false,status='approved', and have no existing reversal row. - Insert the reversal via
createJournalWithLines(auto-approved). - Re-query
reversalOfEntryId = entryIdafter insert. If > 1 row now exists, sort by(createdAt, id)and delete the losing row (the one we just inserted, if we lost). Throw 409.
This is the pattern introduced in PR #113 — it trades a small window of double-write for correctness without a DB-level unique constraint.
6. Idempotency
- Approve returns
{ ok: true, alreadyApplied: true }when the entry is already approved (src/lib/api.ts:22028, 22056). Safe to retry. - Reject returns the same shape when already rejected
(
:22077, 22100). - Reverse throws 400
"This journal has already been reversed"when an existing reversal row exists (:21928). Not idempotent by design — a second reverse call should surface as an error because the caller's assumption ("not yet reversed") is wrong.
7. Audit log
Every state transition writes one row to AuditLog via logAudit(...):
| Event | Action string | Entity |
|---|---|---|
| Create | finance.voucher.create |
journal_entry |
| Approve (single or bulk) | finance.voucher.approve |
journal_entry |
| Reject | finance.voucher.reject |
journal_entry |
| Reverse | finance.voucher.reverse |
journal_entry |
src/lib/api.finance.audit.test.ts verifies each path. Key assertions:
- Approve / reject write exactly one row per transition.
- Bulk approve writes one row per approved id, not per bulk call.
- Losing-race callers (concurrency 409) write zero rows.
- Reversal writes against the original entry id (not the reversal's
id), with metadata
{ reversalEntryId, voucherNo, referenceType, referenceId }.
Running the tests
npx vitest run src/lib/api.finance.audit.test.ts. The suite seeds
a minimal chart of accounts + finance config via the mock supabase
harness in src/test/harness/mockSupabase.ts.
8. Reversal semantics
src/lib/api.ts:21930-21943 builds the reversal by flipping each
original line's debit ↔ credit. The reversal inherits referenceType
and referenceId from the original so drill-downs still work.
You cannot reverse a pending journal
The handler explicitly rejects reversals against status='pending'
with a dedicated message (src/lib/api.ts:21914-21918). A pending
entry never posted to live balances; reversing it would drive
balances negative. Use /reject on the pending entry instead.
You cannot reverse a reversal
src/lib/api.ts:21890 guards against reversing an isReversal=true
entry. To undo a reversal, post a fresh journal.
9. Related files
src/pages/finance/Finance.tsx— Journal tab (value="journal"at L6408) and Approvals tab (value="approvals"at L9995).src/pages/finance/VoucherDetailDialog.tsx— drill-down dialog (approve / reject / reverse buttons).src/lib/api.finance.test.ts— broad behavioral coverage.src/lib/api.finance.audit.test.ts— audit-log assertions.