Skip to content

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 createJournalWithLines unless the handler explicitly passes status: '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 = true row whose reversalOfEntryId points at the original. The original keeps status='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:

  1. ≥ 2 lines with a non-null accountId (:1926-1927).
  2. Balanced|debit − credit| ≤ 0.01 and debit > 0 (:1929-1933).
  3. Period openassertAccountingPeriodAllowsPosting(entryDate) (:1935).
  4. Voucher number generated from FinanceConfig prefixes (:1940-1957).

Input fields worth calling out:

  • entryDate — used as createdAt on 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 the REV prefix.
  • 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 — adds JournalEntry.fxRateUsed numeric(18,8). Header-level rate.
  • supabase/migrations/20260418150000_journal_line_original_amounts.sql — adds JournalLine.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:

  1. Filter self-authored ids into skipped[] (maker-checker).
  2. Conditional UPDATE with .in('id', approvableIds).eq('status', 'pending').
  3. Any id that didn't come back in the SELECT is added to skippedIds with reason concurrent_transition.
  4. 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):

  1. Pre-check: the original must be isReversal=false, status='approved', and have no existing reversal row.
  2. Insert the reversal via createJournalWithLines (auto-approved).
  3. Re-query reversalOfEntryId = entryId after 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.


  • 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.