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

  • Serviceethica-api/src/finance/journal-entries.service.ts:325 (reverseJournal) + :400 (_markAsReversed, the only allowed mutation on a posted entry)
  • Controllerethica-api/src/finance/journal-entries.controller.tsPOST /finance/journals/:id/reverse (approver permission)
  • Frontend hookethica-erp/services/finance.ts:327 (useReverseJournal)
  • Frontend pageethica-erp/app/(auth)/u/finance/accounting-transactions/[id]/reverse/page.tsx

Behaviour

  1. Caller passes originalId, reason, and the actor (from session). Reason is required — empty/whitespace rejects with 400.
  2. Original entry must be posted. Drafts can be edited directly, not reversed.
  3. Original must not itself be a reversal (metadata.reversalOf absent) — disallows chains.
  4. Original must not already have been reversed (metadata.reversedBy absent) — 409 Conflict if so.
  5. New entry properties:
    • sourceModule: 'manual'
    • reference: REV-<original.reference>
    • narration: Reversal of <original.reference>: <reason>
    • date: today (ISO YYYY-MM-DD)
    • Lines: original lines cloned with debitAmountcreditAmount swapped; account, narration, shariahTag, currency, fxRate preserved
    • metadata.reversalOf = original.id
    • Inherits fundId, clientId, clientType, entityId from original
  6. After the new entry is posted (with hash chain linkage), the original's metadata.reversedBy is set to the new id — atomic within the same Sequelize transaction.
  7. 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 payload
    • 400 — missing reason / draft entry / original is itself a reversal
    • 404 — original entry not found
    • 409 — original already reversed (response includes the existing reversedBy id)

Tests

  • Round-trip: original Dr A 100 / Cr B 100 → reversal posts Dr B 100 / Cr A 100 and both balance.
  • Hash chain still verifies after a reversal — previousHash of reversal matches the prior tail's currentHash.
  • Second reverse-attempt on the same original returns 409.
  • Reversing a reversal returns 400.
  • Reversing a draft entry returns 400.