Reversal entry API (Phase 0 Task 0.3)
Status: ✅ Complete
Plan reference: Task 0.3 in 2026-04-30-FINANCE_MODULE_IMPLEMENTATION_PLAN.md (operations workspace root)
What it does
The ledger is append-only — entries posted once cannot be edited. To correct a mistake, an operator posts a reversal entry: a new journal whose lines mirror the original but with debit and credit swapped, balancing the original out. Both entries remain in the GL forever; the reversal is what reflects the correction.
This task added a first-class API + UI so the operation is single-click and audit-logged, rather than requiring an operator to hand-build the mirror entry.
Files
- Service —
ethica-api/src/finance/journal-entries.service.ts:325(reverseJournal) +:400(_markAsReversed, the only allowed mutation on a posted entry) - Controller —
ethica-api/src/finance/journal-entries.controller.ts—POST /finance/journals/:id/reverse(approver permission) - Frontend hook —
ethica-erp/services/finance.ts:327(useReverseJournal) - Frontend page —
ethica-erp/app/(auth)/u/finance/accounting-transactions/[id]/reverse/page.tsx
Behaviour
- Caller passes
originalId,reason, and the actor (from session). Reason is required — empty/whitespace rejects with400. - Original entry must be posted. Drafts can be edited directly, not reversed.
- Original must not itself be a reversal (
metadata.reversalOfabsent) — disallows chains. - Original must not already have been reversed (
metadata.reversedByabsent) —409 Conflictif so. - New entry properties:
sourceModule: 'manual'reference: REV-<original.reference>narration: Reversal of <original.reference>: <reason>date: today (ISOYYYY-MM-DD)- Lines: original lines cloned with
debitAmount↔creditAmountswapped;account,narration,shariahTag,currency,fxRatepreserved metadata.reversalOf = original.id- Inherits
fundId,clientId,clientType,entityIdfrom original
- After the new entry is posted (with hash chain linkage), the original's
metadata.reversedByis set to the new id — atomic within the same Sequelize transaction. - Returns the freshly loaded reversal entry.
Why a single transaction wraps two writes
The new entry creation and the original's metadata update must commit together. If we posted the reversal and then crashed before updating the original, we'd have a reversal pointing at an original that doesn't know it has been reversed — and a second reverse-attempt would succeed instead of being blocked by the reversedBy guard.
The only allowed mutation
_markAsReversed is the single exception to the append-only rule. It is private, called only from reverseJournal, and only sets one specific JSONB key. All other mutation paths on journal_entries should be considered forbidden — the hash chain depends on it.
Endpoint contract
POST /finance/journals/:id/reverse
- Permission:
FINANCE_JOURNAL_APPROVE - Body:
{ reason: string }(trimmed; min length 1) - Responses:
201— reversal entry payload400— missing reason / draft entry / original is itself a reversal404— original entry not found409— original already reversed (response includes the existingreversedByid)
Tests
- Round-trip: original
Dr A 100 / Cr B 100→ reversal postsDr B 100 / Cr A 100and both balance. - Hash chain still verifies after a reversal —
previousHashof reversal matches the prior tail'scurrentHash. - Second reverse-attempt on the same original returns
409. - Reversing a reversal returns
400. - Reversing a draft entry returns
400.