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
AccountingPeriodlock — 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, withactorId = 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
skippedwithreason: '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-21902fires before the reversal voucher is created. - Override permission is
finance.journals.reverse_own, separate fromapprove_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 newJournalEntrywithisReversal = 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
reversalOfEntryIdmatch 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 === trueon 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
- Maker submits journal →
createdBy = <maker-id>, statuspending. - Maker tries to approve → 403.
- Maker pings a second approver (someone with
approvals.approvewho is not the maker themselves). - Second approver approves → 200,
actorId = <second-approver-id>, journal flips toapproved, 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
AuditLogrows whereactorId == <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'screatedByis distinct from the reversal'screatedBy, OR that the reverser holdsfinance.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.