Skip to content

Maker-checker (segregation of duties)

A user who creates a journal entry cannot approve it, reject it, or reverse it. The maker must route the entry to a different approver. This page documents why, how the rule is enforced, when it can be overridden, and the exact edge cases around bulk approval and reversals.

Audience: auditors assessing SoD posture; finance leads who need to explain why "you can't self-approve" to a maker; developers extending the approval model.


1. Why

Segregation of duties is a classical internal-control primitive. One person posts the journal, a second person approves it. Without that split:

  • A single compromised account can create + approve a fraudulent journal and move money unobserved.
  • An honest mistake by the creator has no second-pair-of-eyes safety net before it hits the ledger.
  • Statutory auditors explicitly test for SoD in their controls review (Companies Act 2013 §143 internal-control testing; ICAI SA 315 risk assessment).

The rule is therefore default on, with narrow break-glass overrides reserved for super-admin roles.


2. What the app enforces

2.1 Creator captured on every journal

JournalEntry.createdBy is populated on every createJournalWithLines(...) call (src/lib/api.ts:1976). The current user UUID flows in from getCurrentUserId() on the handler boundary.

2.2 Approve / reject guard

src/lib/api.ts:22030-22039 (approve) and :22079-22088 (reject):

const actorId = await getCurrentUserId();
if (entry.createdBy && actorId && entry.createdBy === actorId) {
  const hasOverride = await userHasPermission(actorId, 'finance.journals.approve_own');
  if (!hasOverride) {
    throw new ApiError(403, {
      message: 'Maker-checker: you cannot approve your own journal entry. Ask a different approver.',
    });
  }
}

The same structure is mirrored on reject. Both return HTTP 403 with a message the UI surfaces directly.

2.3 Reverse guard

src/lib/api.ts:21894-21902:

const reverseActorId = await getCurrentUserId();
if (original.createdBy && reverseActorId && original.createdBy === reverseActorId) {
  const hasReverseOwn = await userHasPermission(reverseActorId, 'finance.journals.reverse_own');
  if (!hasReverseOwn) {
    throw new ApiError(403, {
      message: 'Maker-checker: you cannot reverse a journal entry you created. Ask a different approver.',
    });
  }
}

Uses a separate override permission (finance.journals.reverse_own vs approve_own). The distinction lets a super-admin unblock a stuck approval without also granting the ability to self-reverse live postings — the two risks have different blast radius.

2.4 Permission seeding

supabase/migrations/20260418150000_journal_maker_checker_overrides.sql inserts the two override permissions and grants them to exactly three roles:

WHERE r.name::text IN ('CEO', 'GM', 'IT_ADMIN')
  AND p.name IN ('finance.journals.approve_own', 'finance.journals.reverse_own')

Not ADMIN_HR. Not FINANCE_MANAGER. Not ACCOUNTANT. The migration comment is explicit: "ADMIN_HR has every other finance permission but is deliberately excluded from these two because HR is not an approver of record for the ledger."

No hardcoded role checks

The guard consults userHasPermission(..., 'finance.journals.approve_own') — the permission rows are the source of truth. There is no if role === 'CEO' bypass. Revoking the row via the PermissionsMatrix admin UI actually restricts the holder. See CLAUDE.md §Permissions.


3. Override mechanism

3.1 Who holds it

Permission CEO GM IT_ADMIN ADMIN_HR FINANCE_MANAGER ACCOUNTANT everyone else
finance.journals.approve_own - - - -
finance.journals.reverse_own - - - -

Source: docs/PERMISSIONS.md §5.1 and the seed migration listed above.

3.2 When to use it

  • A maker has gone on leave, nobody else has approval power, and a pending journal blocks downstream reporting. Super-admin approves with the override as a break-glass.
  • A test environment with one user seed needs to push journals through end-to-end without standing up a second account.

3.3 What it does not do

  • Does not bypass approvals.approve — the holder still needs the base permission to approve.
  • Does not bypass the AccountingPeriod lock — the period-lock trigger fires regardless.
  • Does not bypass the concurrency guard on UPDATE ... WHERE status = 'pending' — a lost race still returns 409.
  • Does not skip the audit row — the approval still lands in AuditLog, with actorId = createdBy. That same-actor pair is the exact pattern an auditor will flag to ask for a reason.

Self-approvals must be explained

Every AuditLog.actorId == JournalEntry.createdBy pair is a break-glass event. Super-admins should leave a memo on the journal explaining why a second approver wasn't available. Track these in period-end reviews.


4. Bulk approval interaction

POST /finance/journals/approve-bulk is guarded in a different shape: per-row filtering, not hard failure.

Source: src/lib/api.ts:21670-21724.

flowchart TD
    A[Bulk approve ids=X,Y,Z] --> B{Has approve_own override?}
    B -->|yes| E[Update all 3 to approved]
    B -->|no| C[Look up createdBy for X,Y,Z]
    C --> D[Skip any where createdBy == actorId]
    D --> F[Update remainder to approved]
    F --> G[Return approved, skipped with reasons]

Key behaviours:

  • Per-row filter. If Z was authored by the approver, Z is skipped with reason: 'maker_checker_self_approval' and the rest (X, Y) are approved. The whole batch is NOT rejected.
  • Override bypass. If the user holds finance.journals.approve_own, the self-authored filter is skipped entirely (:21680-21698).
  • Concurrency + maker-checker both reported. Rows lost to another concurrent approver land in skipped with reason: 'concurrent_transition'. The caller can tell the two skips apart.
  • Response shape: { ok: true, approved: n, approvedIds: [...], skippedIds: [...], skipped: [{id, reason}, ...] }.

Verification: src/lib/api.finance.audit.test.ts:415-437 — seeds one entry the approver created and one a different user created, asserts the first is skipped and the second approved.


5. Reversal edge case

Can the original maker reverse their own journal? No, by default.

  • Reverse guard at src/lib/api.ts:21894-21902 fires before the reversal voucher is created.
  • Override permission is finance.journals.reverse_own, separate from approve_own.
  • Re-approve scenario: if the original journal was approved by User B (not the maker User A), User A cannot reverse it either — the guard checks original.createdBy == reverseActorId, which is User A.

5.1 What a reversal actually does

  • src/lib/api.ts:21930-21943 — inserts a new JournalEntry with isReversal = true, reversalOfEntryId = original.id, and the debit/credit on each line flipped.
  • The reversal entry auto-approves on insert (src/lib/api.ts:1971-1975). Requiring a second approval on the reversal would deadlock the "approved-original, maker stuck with it" scenario.
  • Only one reversal per original — the handler checks for an existing reversalOfEntryId match and refuses a second (src/lib/api.ts:21925-21928), plus has a post-insert duplicate detector that rolls back the losing row on a concurrency race (:21945-21975).

5.2 Rules around the original

Reversal is only valid on an approved original. Pending or rejected originals cannot be reversed because they never posted to live balances (src/lib/api.ts:21907-21924):

  • status === 'pending' → 400 "Cannot reverse a journal with a pending approval. Wait for approval to complete, or reject the pending entry instead."
  • status !== 'approved' (e.g. rejected) → 400 "Cannot reverse a rejected journal. Only approved journals can be reversed."
  • isReversal === true on the original → 400 "Cannot reverse a reversal voucher."

6. Failure modes and user-facing errors

Scenario HTTP Message Where
Maker tries to approve own journal 403 Maker-checker: you cannot approve your own journal entry. Ask a different approver. src/lib/api.ts:22035-22038
Maker tries to reject own journal 403 Maker-checker: you cannot reject your own journal entry. Ask a different approver. src/lib/api.ts:22084-22087
Maker tries to reverse own journal 403 Maker-checker: you cannot reverse a journal entry you created. Ask a different approver. src/lib/api.ts:21898-21901
Approve race — lost 409 Cannot approve: journal is now <status>. src/lib/api.ts:22057
Reverse race — lost 409 This journal has already been reversed by a concurrent request. src/lib/api.ts:21973
Bulk self-authored rows in batch 200 with skipped[] reason: 'maker_checker_self_approval' src/lib/api.ts:21693

The UI surfaces these by popping a toast with the message field — no custom client-side branching on status code is required.


7. How a non-override role resolves the deadlock

  1. Maker submits journal → createdBy = <maker-id>, status pending.
  2. Maker tries to approve → 403.
  3. Maker pings a second approver (someone with approvals.approve who is not the maker themselves).
  4. Second approver approves → 200, actorId = <second-approver-id>, journal flips to approved, audit row written.

Day-to-day FINANCE_MANAGER and ACCOUNTANT roles both hold finance.* and approvals.approve — the standard pattern is one posts, the other approves.


8. Operational checklist for period-end review

  • Pull AuditLog rows where actorId == <JournalEntry.createdBy> for the period. Each is an override invocation — confirm the memo explains it.
  • Pull all reversals in the period (JournalEntry WHERE isReversal = true). For each, confirm the original's createdBy is distinct from the reversal's createdBy, OR that the reverser holds finance.journals.reverse_own.
  • Cross-check the bulk-approve responses logged in frontend telemetry for skipped: [{reason: 'maker_checker_self_approval'}] entries — these should be small in volume. Large volumes suggest a role configuration issue (a functional role is routinely posting + being asked to approve).

9. Cross-references

  • Overview §2 — short summary of maker-checker.
  • Journals §3 — endpoint-level detail.
  • Audit trail §3 — how approvals are logged.
  • Permissions matrix — role-by-role grants.
  • src/lib/api.finance.audit.test.ts:340-490 — the behavioural test suite that pins every path documented above.
  • supabase/migrations/20260418150000_journal_maker_checker_overrides.sql — override seed.
  • docs/PERMISSIONS.md §5.1 — role grants for the override permissions.