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 withmaker_checker_self_approval, unless they hold the super admin'sfinance.journals.approve_own. - The website's Reverse action on a voucher still turns its maker away up front
(
finance.journals.reverse_ownto 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
ApprovalLimitrow, 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 |