Skip to content

Maker-checker

Nobody approves their own work. The person the system records as approver is always the signed-in user, never a name that arrived in a request.

Rules: ACC-020 (maker ≠ checker), ACC-021 (conflicting duties), FIN-032 (vouchers and payments), LC-010 (booking approvals).


1. Where it applies

Action Function The refusal
Verify a receipt verify_payment "you recorded this payment, so someone else must verify it"
Approve a refund approve_refund "you requested this refund, so someone else must approve it"
Approve or reject a voucher approve_journal_entries skipped with reason: 'maker_checker_self_approval'
Reverse a voucher post_journal_reversal not refused; the original's maker's reversal posts pending, never approved
Approve the reversal of a voucher you made approve_journal_entries skipped with reason: 'maker_checker_original_maker' — "you created the voucher this reverses (ACC-020)"
Decide a B2B cancellation decide_b2b_cancellation the filer cannot decide
Ops approval of a booking ops_decide_booking "you cannot approve or send back a booking you created (LC-010)"
Finance approval of a booking finance_decide_booking "you cannot approve, reject or send back a booking you created (LC-010)"
Approve a passenger's cancellation approve_passenger_cancellation (20261004170000); the trigger "BookingPassenger_cancellation_guard" refuses a browser write of the approval "you cannot approve a cancellation you requested (ACC-020)"
Approve a whole booking's cancellation trigger on Booking (bl_booking_guard) "you cannot approve a cancellation you requested (ACC-020)"
Reverse a voucher on lines that do not mirror it post_journal_reversal → fin_lines_mirror_original (20261004160000) not refused; posts pending for a second person, whoever makes it (FIN-032)
Post a B2B cancellation voucher post_decision_journal (20261004210000) not refused; posts approved only at the size the decision recorded, otherwise pending for a finance approver
Approve a seat release approve_seat_release, plus a table CHECK "the person who requested this release cannot approve it (ACC-020)"
Decide a ticket exception decide_ticket_exception "You requested this exception — another approver must decide it"

All of these raise insufficient_privilege from the database (or skip the row, in a batch). The browser shows the same message first, but the browser is not what stops it.

Reversals (since 20261006120000)

A reversal is approved only by someone who made neither the voucher it reverses nor the reversal. The database checks this when the reversal is approved (approve_journal_entries), not when it is made:

  • Making a reversal is not refused. The person who made a voucher may still ask for its reversal — and often does without thinking of it as one: cancelling a group invoice they issued, editing or deleting a supplier transaction they recorded, deleting a booking. Their reversal never posts on its own; it waits pending.
  • Approving it is. The maker of the original is skipped with maker_checker_original_maker — "Maker-checker: you created the voucher this reverses (ACC-020). Ask a different person." — and the maker of the reversal with maker_checker_self_approval, unless they hold the super admin's finance.journals.approve_own.
  • The website's Reverse action on a voucher still turns its maker away up front (finance.journals.reverse_own to override). That is guidance on the screen; the control is the approval check above.
  • Cancellation credits. A passenger cancellation's credit rides on the cancellation decision (requester ≠ approver) and keeps that rule.

Seat release is checked twice on purpose

SeatRelease carries CHECK ("approvedBy" IS NULL OR "approvedBy" <> "requestedBy"). Even a direct write that somehow avoided the function cannot record the same person on both sides of the decision.

2. Segregation of duties in finance

ACC-021, decided 2026-09-18: no single person both records and verifies money.

Role Records Verifies Approves
CASHIER receipts — —
ACCOUNTANT receipts receipts recorded by someone else —
FINANCE_MANAGER receipts receipts refunds, journals
CEO / GM — — high-value refunds and journals (finance.approvals.high_value)

CASHIER used to hold finance.payments.verify — it was granted in the original seed. It was removed by apply_role_bundles(), which converges each role's finance.* grants onto an exact list and deletes anything else. supabase/tests/access_model.sql fails the build if a cashier ever holds a finance permission beyond finance.view and finance.payments.record.

An exception is a time-limited, recorded grant by the super admin — never a permanent bundle.

3. The break-glass overrides

Two permissions let a holder approve or reverse their own journal:

Permission Effect
finance.journals.approve_own approve or reject a voucher you created
finance.journals.reverse_own reverse a voucher you created

Both are on the super-admin-only list (fin_super_admin_only_permissions()), so no other role can pick them up through a role bundle — including CEO and GM. Using approve_journal_entries with the override still writes the audit row; the override changes who may, not whether it is recorded.

This is a change from the April model

These two overrides used to be seeded to CEO, GM and IT_ADMIN, and those three roles held every permission. Since ACC-011/ACC-012 (decided 2026-09-17) SUPER_ADMIN is the only role with full rights; CEO and GM hold a leadership bundle and IT_ADMIN holds system settings only. There is no role-name bypass anywhere in the code — the super admin holds every permission because every row is granted to it explicitly, by a trigger that also grants each newly created permission.

4. What bulk approval does with a self-authored voucher

approve_journal_entries takes a list of ids and a decision. It never fails the whole batch because of one row; it returns {decision, processedIds, alreadyAppliedIds, skipped} and each skipped row carries a reason:

not_found · already_<status> · maker_checker_self_approval · original_not_approved (a reversal ahead of its original) · maker_checker_original_maker (you made the voucher this reverses) · unbalanced · above_approval_limit · period_locked · refused. maker_checker_original_maker, above_approval_limit and period_locked carry a message in plain words, which the website and the app show as it is.

More than one id also requires finance.journals.bulk_approve. Rejecting requires finance.journals.reject (or .approve) and a reason of at least three characters.

5. What it does not cover

  • "Last materially edited". LC-010 says the person who last materially edited a booking should also be barred from approving it. Only the creator is tracked today.
  • A second approver above ₹2,00,000. Recorded on the ApprovalLimit row, not built — see Numbering and approval limits.
  • Approval routing by value. A request above a limit is refused at approval time rather than routed to the right approver's queue. The approvals inbox shows everything the user may decide (ACC-030 is still a working default).

6. Where to look

Concern Path
Payment and refund maker-checker supabase/migrations/20260918110000_money_integrity.sql
Journal and reversal controls supabase/migrations/20260919100000_finance_journal_controls.sql
Role bundles and the override list supabase/migrations/20260919100200_finance_role_bundles.sql
Reversal maker-checker at approval supabase/migrations/20261006120000_finance_guards_in_the_database.sql
Booking approvals supabase/migrations/20260918120000_booking_lifecycle.sql
Tests supabase/tests/finance_controls.sql, money_integrity.sql, access_model.sql, booking_lifecycle.sql, finance_guards_in_the_database.sql