Real-group accounting test — departure 29 Aug 2026 (19 days, 6E)
One real Umrah departure was taken out of the operations workbooks and re-entered into a local preview copy of the system, step by step, by the staff roles who would really do each step — ops buys the block, sales/B2B takes the bookings, ops approves, finance approves, the cashier records the money, the finance manager verifies it. Then the books the system produced were compared with the books the company's own spreadsheet produced.
The departure: 26 pilgrims travelled, 1 cancelled, sold through 5 sub-agents, on two IndiGo series blocks (30 seats bought, 3 free of charge), two hotels billed in Saudi riyals, ₹28.9 lakh billed and ₹16.35 lakh collected.
No passenger names, passport numbers, phone numbers or dates of birth appear below. Money figures, counts and partner (business) names do.
Environment: local preview Supabase stack + npm run dev -- --mode preview.
Nothing was run against the production project. Line references are to
integration/ops-upgrade as on disk and will drift; search by handler name.
Reproduce with:
node scripts/dev/extract-group-workbook.mjs --dir "<workbook dir>" \
--group "29Aug-19D-6E-A" --out /tmp/g.json --summary /tmp/g.md
node scripts/dev/replay-group.mjs --input /tmp/g.json \
--password '<preview password>' --log /tmp/replay.json
Verdict
No. The accounting does not come out right, and the gap is not small.
On this departure the company made ₹1,77,895 on ₹27.6 lakh of sales — a 6.4% margin. The system, fed the same facts through its own screens and routes, reported a profit of ₹18,22,726 — a 67.9% margin, ten times too high — and its general ledger contained no sales, no receivable, no GST and no supplier cost at all. The only thing in the ledger was the ₹16.35 lakh of money received, posted against the sub-agents as if the company owed them that money.
Three things caused it, in order of severity:
- Nothing the application posts reaches the general ledger. An RLS policy
shape on
JournalEntry/JournalLinedenies every insert from the app, for every user, including a full super-admin. Sales, purchases, receivable and GST all fail. Only the twoSECURITY DEFINERpayment functions get through, which is why receipts posted and nothing else did. - When that posting fails, the booking is saved anyway. The code catches the error, writes a line to the browser console and commits the booking. Six approved bookings worth ₹28.9 lakh produced zero journal entries and nobody was told.
- The cost of the air seats — the single largest cost, ₹13.5 lakh, 52% of the
whole departure — never entered the system at any point. The block could not
be created by the ops role, could not be linked to the group by any operational
role, and the group P&L therefore shows
flights: 0.
Separately, the workbooks themselves do not agree with each other: the airline block cost ₹15,50,300 but only ₹13,50,860 is charged to this departure, and the missing ₹1,99,440 of bought-and-unsold seats appears nowhere. Charge it here and the departure made a loss of ₹21,545, not a profit of ₹1,77,895.
1. What the business's own books say
From GROUP RECORD 2026-27.xlsx, sheet 29Aug-19D-6E-A, cross-checked against
BLOCK DATA, HOTEL DATA, PAYMENT REGISTER, Financial Summary,
BLOCK RECORD 2026-27.xlsx (INDIGO) and HOTEL BOOKING 2026-27.xlsx
(ALLOTMENT, BOOKING).
| Measure | Workbook |
|---|---|
| Pilgrims confirmed / cancelled | 26 / 1 |
| Mix | 16 male, 10 female, 0 child, 0 infant |
| Gross billed (sum of the PRICE column, 27 rows) | ₹28,91,501 |
| Less: cancelled pilgrim removed from sales | −₹1,15,000 |
| Less: company service charge retained (₹700 × 25) | −₹17,500 |
| Net group sales | ₹27,59,001 |
| Receipts recorded (4 rows in PAYMENT REGISTER) | ₹16,35,000 |
| Outstanding | ₹11,24,001 |
| Total group expenses | ₹25,81,106 |
| Gross profit / margin | ₹1,77,895 / 6.44% |
| SAR → INR contract rate used throughout | 26.0 |
Cost ladder as the company records it:
| Line | Amount | Basis |
|---|---|---|
| Airline tickets | ₹13,50,860 | 18 seats @ ₹51,010 + 8 @ ₹53,010 + ₹8,600 cancellation charge |
| Umrah visas | ₹3,05,100 | 27 visas @ ₹11,300 (includes the cancelled pilgrim) |
| Hotel Makkah (Manarat Al Misk) | ₹2,73,780 | SAR 10,530 @ 26 |
| Hotel Madinah (Marjan Hotels) | ₹2,54,280 | SAR 9,780 @ 26 |
| Food (Gowhar + Muneer catering) | ₹2,64,654 | SAR 6,728 + SAR 3,451 @ 26 |
| Rawad local transport | ₹64,750 | |
| Incidentals (Zamzam, laundry, Taif bus, Badr taxi, driver) | ₹52,065 | SAR 2,002.50 @ 26 |
| BRN charges, 3 pax | ₹10,217 | |
| Reconciliation adjustment & tax | ₹5,400 | |
| Total | ₹25,81,106 |
Receipts, by sub-agent:
| Sub-agent | Pax | Gross billed | Received | Outstanding |
|---|---|---|---|---|
| ABE ZUM ZUM | 16 | ₹15,88,001 | ₹9,15,000 | ₹6,73,001 |
| MOHAMMAD FAYAZ MIR (TA) | 4 | ₹4,80,000 | ₹4,00,000 | ₹80,000 |
| BATA ZAWAR | 3 (1 cancelled) | ₹3,23,500 | ₹0 | ₹3,23,500 |
| G N M TRAVELS | 2 | ₹2,80,000 | ₹1,00,000 | ₹1,80,000 |
| MUJTUBA OFFICE | 2 | ₹2,20,000 | ₹2,20,000 | ₹0 |
1.1 Where the workbooks contradict each other
These are findings about the source data, before any software is involved. The extraction tool detects and prints all five.
| # | Contradiction | Size |
|---|---|---|
| W1 | The two blocks cost ₹15,50,300 (20 × ₹51,010 + 10 × ₹53,010, per the INDIGO sheet's own TOTAL AMOUNT column) but only ₹13,50,860 is charged to the departure. 2 seats on each block went unsold. |
₹1,99,440 unaccounted |
| W2 | Makkah contract rack rate is SAR 140 (ALLOTMENT); the departure is costed at SAR 130. |
SAR 10/night |
| W3 | Madinah contract rack rate is SAR 400; the departure is costed at SAR 230 / 250 / 220 on three different lines. | SAR 150–180/night |
| W4 | Madinah catering: SAR 3,691 × 26 = ₹95,966, but the sheet carries ₹89,726 — the SAR 240 take-away line is in the SAR total and out of the INR total. | ₹6,240 |
| W5 | HOTEL DATA files Makkah as 4 rooms @ SAR 140 for 18 pax; the group sheet costs 6 rooms @ SAR 130 for 26 pax; HOTEL BOOKING files it twice (4 rooms for the "-A" group, 2 for a "-B" group). Madinah is filed with rooms blank and a total of 0. |
2 rooms × 12 nights |
Two more, not machine-detectable:
- W6 — F.O.C is ambiguous in the source.
NO.OF SEATS 20, F.O.C 2can mean "20 bought, 2 of them free" (18 payable) or "20 payable plus 2 free" (22 seats). TheINDIGOsheet resolves it as the second (TOTAL AMOUNT = 20 × rate, FOC tracked separately,SEATS REMAINING 2) but the group P&L charges exactly 18 seats, which is the first reading. The whole ₹1,99,440 question (W1) turns on this and the sheets never state it. - W7 — the cancellation is recorded two ways. The narrative on the group sheet
says ₹27,000 was recovered (₹10,000 cancellation fee + ₹17,000 visa charges);
the
Financial Summarysheet says "Less: Cancellation Full Refund −₹1,15,000". The cost side still carries that pilgrim's ₹11,300 visa and ₹8,600 ticket cancellation charge either way.
2. Expected vs actual — the headline comparison
Everything in the "System" column was read back out of the running preview through
its own reports (/finance/trial-balance, /finance/group-profitability,
/groups/:id/financial-summary, /finance/gst-summary, /inventory/drift).
2.1 Revenue and money in
| Line | Workbook | System | Difference |
|---|---|---|---|
| Gross billed, 27 pilgrims | ₹28,91,501 | ₹28,91,501 | ✅ 0 |
| Pilgrim mix | 16 M / 10 F, 26 travelling | 16 M / 10 F, 26 travelling | ✅ 0 |
| Per-sub-agent split | 5 partners, exact | 5 partners, exact | ✅ 0 |
| GST billed to customers | ₹0 (never charged) | ₹1,43,214.54 | ❌ +₹1,43,214.54 |
| Total invoiced | ₹28,91,501 | ₹30,34,715.54 | ❌ +₹1,43,214.54 |
| Receipts recorded and verified | ₹16,35,000 | ₹16,35,000 | ✅ 0 |
| Net sales after cancellation | ₹27,59,001 | ₹26,83,001 | ❌ −₹76,000 |
| Outstanding receivable | ₹11,24,001 | ₹10,48,001 (group report) / ₹13,99,715 (invoices) | ❌ two different answers |
The gross billing, the pilgrim mix and the sub-agent split all came through exactly right — the operational data model holds this departure faithfully. Everything downstream of it is wrong.
2.2 Cost
| Cost line | Workbook | System | Difference |
|---|---|---|---|
| Airline tickets | ₹13,50,860 | ₹0 | ❌ −₹13,50,860 |
| Hotel Makkah | ₹2,73,780 | ₹2,43,360 | ❌ −₹30,420 |
| Hotel Madinah | ₹2,54,280 | ₹1,95,000 | ❌ −₹59,280 |
| Food / catering | ₹2,64,654 | ₹0 | ❌ −₹2,64,654 |
| Umrah visas | ₹3,05,100 | ₹3,05,100 | ✅ 0 |
| Transport | ₹64,750 | ₹64,750 | ✅ 0 |
| Incidentals | ₹52,065 | ₹52,065 | ✅ 0 |
| BRN charges | ₹10,217 | ₹0 | ❌ −₹10,217 |
| Reconciliation adjustment | ₹5,400 | ₹0 | ❌ −₹5,400 |
| Total cost | ₹25,81,106 | ₹8,60,275 | ❌ −₹17,20,831 |
| Margin | ₹1,77,895 (6.44%) | ₹18,22,726 (67.94%) | ❌ +₹16,44,831 |
The hotel differences are explained precisely and are not conversion errors — the system converted SAR to INR exactly as the business does:
Makkah : 6 rooms × 12 nights × SAR 130 @ 26.0 = ₹2,43,360 (system)
+ 1 room × 9 nights × SAR 130 @ 26.0 = ₹ 30,420 (workbook only)
workbook = ₹2,73,780
Madinah: 6 rooms × 5 nights × SAR 250 @ 26.0 = ₹1,95,000 (system)
+ 1 room × 8 nights × SAR 230 @ 26.0 = ₹ 47,840 (workbook only)
+ 1 room × 2 nights × SAR 220 @ 26.0 = ₹ 11,440 (workbook only)
workbook = ₹2,54,280
The rate of 26.0 SAR/INR came from the workbook's own CONVERSION cell and was
entered as the block/hotel exchangeRate (the contract rate, FIN-034). The
system's market table holds SAR→INR at 22.22; using that instead would have
understated the land cost by a further ₹1.3 lakh, so entering the contract rate is
essential and easy to forget — there is no warning when it is left blank on a
foreign-currency row.
2.3 The general ledger
| GL account | Expected | Actual in the system |
|---|---|---|
| Accounts Receivable (Dr) | ₹30,34,715.54 | ₹0 |
| Package Revenue (Cr) | ₹28,91,501 | ₹0 |
| GST Payable (Cr) | ₹1,43,214.54 | ₹0 |
| Bank (Dr) | ₹16,35,000 | ₹16,35,000 ✅ |
| Sub-agent ledgers | net Dr ₹12,56,501 (they owe us) | net Cr ₹16,35,000 (we owe them) ❌ |
| Airline payable (Cr) | ₹15,50,300 | ₹0 |
| Hotel payables (Cr) | ₹5,28,060 | ₹0 |
| Visa / food / transport payables | ₹6,86,569 | ₹0 |
The whole trial balance is five lines:
| Code | Account | Debit | Credit |
|---|---|---|---|
| 1010 | Bank Account | ₹16,35,000 | |
| AGR-… | ABE ZUM ZUM | ₹9,15,000 | |
| AGR-… | MOHAMMAD FAYAZ MIR (TA) | ₹4,00,000 | |
| AGR-… | MUJTUBA OFFICE | ₹2,20,000 | |
| AGR-… | G N M TRAVELS | ₹1,00,000 | |
| Total | ₹16,35,000 | ₹16,35,000 |
It balances, and it is meaningless. No unbalanced vouchers exist (0 found —
createJournalWithLines genuinely enforces balance), but there are almost no
vouchers at all. The Profit & Loss report is completely empty: income: [],
expenses: [], netProfit: 0. Because the sale was never invoiced into the
ledger, each sub-agent's receipt lands as a naked credit, so the books state that
Alhuda owes its sub-agents ₹16.35 lakh.
2.4 GST
| Question | Answer |
|---|---|
| What did the system charge? | ₹1,43,214.54 across 6 invoices |
| On what base? | 4.953% of the ₹28,91,501 gross — i.e. effectively on the full selling price |
| What is it supposed to be? | 5% of computeBookingGroundMargin() (src/lib/api.ts:6092) — GST on the margin |
| Why the difference? | The margin is computed from the group's recorded cost. Because the flight cost is ₹0 and the food cost is ₹0, the "margin" is almost the whole selling price. Missing cost silently inflates the tax. |
| Does it match how the business bills? | No. The workbook bills a single all-in package price with no GST line. The only tax references anywhere are ₹5,400 "reconciliation adjustment & tax" and the ₹17,500 service charge "including tax". |
| Did any GST reach the GST return? | No. /finance/gst-summary returns outputGst: 0, inputGst: 0. The ₹1.43 lakh sits on Booking.gstAmount and on the invoices but never enters the ledger, so it is invisible to GSTR-1 and GSTR-3B. |
This is the worst of both worlds: customers are shown ₹1.43 lakh of GST the business did not intend to charge, and the tax authority would see ₹0.
[CA] Whether this departure should carry 5% on gross or 18% on margin is a question for the company's Chartered Accountant. The defect is that the system answers it by accident, from whatever cost data happens to have been captured.
2.5 Inventory
| Check | Result |
|---|---|
| Inventory drift report for this group's blocks and hotels | Clean — 0 items, 0 oversold risk ✅ |
| Airline block seat consumption | Not testable. The blocks were never linked to the group (defect D3), so no seat was ever drawn. |
| Hotel room-nights consumed vs bought | 6 rooms × 12 nights (Makkah) and 6 × 5 (Madinah) allotted and fully assigned ✅ |
| Unsold / released seats (4 seats, ₹1,99,440) | Nowhere to record. See gap G1. |
3. Defects found, ranked
D1 — Nothing the application posts can reach the general ledger (P0, blocker)
JournalEntry and JournalLine each have exactly one INSERT policy and it is
RESTRICTIVE. PostgreSQL requires at least one permissive policy to pass
before restrictive policies are even considered, so with no permissive partner
every insert is denied — regardless of the user's permissions. Demonstrated
directly: a scratch table with a single restrictive WITH CHECK (true) INSERT
policy rejects an insert; adding a permissive policy lets the identical insert
through.
Consequence: the block purchase journal, the hotel purchase journal, the supplier
bill journal and the booking revenue journal all fail. Only record_payment and
verify_payment post, because they are SECURITY DEFINER and bypass RLS.
How it happened: 20260415200000_permission_aware_rls.sql:261,278 created the
policies AS RESTRICTIVE, by design, to AND with the pre-existing permissive
staff-write policies. 20260418113627_finance_rls_tighten.sql:38-39,76-77 then
dropped those permissive partners ("JournalEntry insert authenticated",
"JournalLine insert authenticated") and re-created the permission policies as
permissive (:54, :88). The two migrations disagree, and the earlier one is
written to be idempotent and re-runnable. The live preview database shows
JournalEntry/JournalLine back at RESTRICTIVE while LedgerEntry — the third
table in the same 20260418113627 migration, untouched by 20260415200000 — is
correctly permissive. That asymmetry is the fingerprint of the earlier migration
having been replayed over the later one.
Reproduce:
select polname, polpermissive, polcmd from pg_policy
where polrelid = 'public."JournalEntry"'::regclass;
-- journalentry_perm_insert | f | a <- restrictive, and the only INSERT policy
POST /inventory/quota-blocks →
400 new row violates row-level security policy for table "JournalEntry".
Ten other tables are in the same shape and should be checked together:
BookingPassengerGroundService, BookingPassengerMeal, GroupInvoice,
GroupTemplate, InventoryDriftRepair, JournalAttachment, JournalTemplate,
VisaGroup, exchange_rate_snapshots. (InventoryDriftRepair_no_insert is
deliberate; the rest are not obviously so.) GroupInvoice being in this list is
why no credit note could be issued for the cancellation (§4).
Fix: make the permission policies permissive (as 20260418113627 already
intends), or restore a permissive staff-write partner. Add a db-test asserting
that an authenticated user with finance.create can insert a JournalEntry —
there is currently no test that would have caught this.
D2 — A booking commits with no revenue when its journal fails (P0)
src/lib/api.ts:6126-6136. The booking journal is posted inside a try, and the
catch re-throws only balance errors:
if (isJournalBalanceError(journalErr)) throw journalErr;
console.warn('Failed to post booking journal entry', journalErr);
The comment above it names the risk exactly — "the booking row is about to commit
with NO revenue recognition — an invisible GL hole" — and then lets every
non-balance error through, explicitly including "transient RLS". An RLS denial is
not transient; it is permanent for every user who lacks finance.create.
Result in this test: 6 approved bookings, ₹28,91,501, zero journal entries, no error shown to anyone. The booking was approved by ops and by finance, and an invoice was raised, all on top of a sale that does not exist in the books.
Reproduce: sign in as SALES_EXEC or B2B_MANAGER, create a booking, then
select count(*) from "JournalEntry" where "referenceType"='booking' → 0.
Fix: re-throw, or write the failure to a queue the finance dashboard shows. Silent is not an option for revenue.
D3 — No operational role can finish setting up a departure (P0)
groups.edit is granted only to ADMIN_HR and SUPER_ADMIN. Nine routes in
src/lib/api.ts require it, including linking a flight to a group
(:16418), adding a group expense (:16561), marking the group leader and moving
a passenger between groups. OPS_MANAGER and SALES_MANAGER hold groups.create and
can create a group they then cannot touch.
docs/PERMISSIONS.md:99 describes the permission as "Edit group, reassign
passengers, link/unlink flights, misc expenses" and §6 lists those as Groups-page
actions — so the intent is clearly operational, and the grant table contradicts it.
This is why the group P&L shows flights: 0: no operational role could attach
either block.
Reproduce: sign in as OPS_MANAGER → POST /groups (succeeds) →
POST /groups/:id/flights → 403 Permission denied: groups.edit.
The permission drift test (src/test/permissions.matrix.test.ts) passes, because
it checks that referenced permissions exist in the matrix — not that any role can
actually complete a workflow.
D4 — A failed block purchase leaves the stock behind with no payable (P1)
POST /inventory/quota-blocks (src/lib/api.ts:27278) inserts the
AirlineQuotaBlock row and then calls createQuotaBlockFinanceEntries
(:26940) to post the purchase journal and the supplier payable. There is no
transaction around the pair. When the journal insert failed (D1), the block row
survived.
Observed: 3 AirlineQuotaBlock rows in the preview — 20, 20 and 10 seats at
₹51,010/₹53,010 — representing ₹25.7 lakh of purchased inventory, with 0
rows in JournalEntry and 0 in SupplierTransaction for referenceType =
'quota_block'. The airline is owed money that appears nowhere.
Fix: wrap block creation and its finance entries in one database function, the
way create_booking and record_payment already are.
D5 — POST /hotels cannot create a hotel at all (P1, blocker)
src/lib/api.ts:23610 deliberately omits availableRooms, roomsAllocated,
bedsAllocated and bedsAvailable from the insert payload, with a comment saying
they are derived by recompute_hotel_counters() (INV-013). But
HotelInventory.availableRooms and bedsAvailable are NOT NULL with no
column default, and the seeding trigger HotelInventory_seed_counters is
AFTER INSERT. The NOT NULL check fires first, so the row never lands.
Reproduce: POST /hotels with any body →
400 null value in column "availableRooms" of relation "HotelInventory" violates
not-null constraint. Confirmed with a bare SQL insert too, so it is not an API
problem — the route as written can never succeed.
Existing hotels in any environment were seeded with explicit counters, which hides this completely until someone tries to add a property.
Fix: a column default of 0 plus a BEFORE INSERT seeding trigger, or set the
counters in the payload.
D6 — Operations cannot assign seats or hotel rooms (P1)
POST /sales/bookings/:bid/passengers/:pid/flights (:7842) and
.../hotels (:8097) both require bookings.edit, granted to SALES_MANAGER,
SALES_EXEC, B2B_MANAGER, ADMIN_HR and SUPER_ADMIN — not to OPS_MANAGER or
OPS_EXEC. Seat allocation and the rooming list are operations work.
Reproduce: sign in as OPS_MANAGER →
POST /sales/bookings/:bid/passengers/:pid/flights → 403 bookings.edit.
D7 — Recording a supplier bill needs a permission no finance role holds (P2)
POST /suppliers/:id/transactions (:26251) — the route that turns a hotel or
catering cost into a payable — is gated on suppliers.edit, held by OPS_MANAGER,
OPS_EXEC, ADMIN_HR and SUPER_ADMIN. FINANCE_MANAGER and ACCOUNTANT do not have it,
so the finance team cannot record a supplier invoice; and when ops tries, the
journal half fails on D1. Neither side can complete the transaction.
D8 — Cancelling a pilgrim leaves the invoice and the receivable untouched (P1)
After the real cancellation was requested by sales and approved by finance:
| Field | Before | After |
|---|---|---|
Booking.totalAmount |
₹3,23,500 | ₹3,23,500 (unchanged) |
Booking.balanceAmount |
₹3,39,581.33 | ₹3,39,581.33 (unchanged) |
Invoice.balanceDue |
₹3,39,581.33 | ₹3,39,581.33 (unchanged) |
Credit note (GroupInvoice) |
— | none created |
BookingPassenger.cancellationRefundAmount |
— | ₹1,15,000 |
Booking.status |
APPROVED | PARTIALLY_CANCELLED |
Two of three pilgrims on that booking are now cancelled (₹2,08,500) and the
customer is still invoiced for all three. issuePassengerCancellationCreditNote()
is called (src/lib/api.ts:8961) but it writes a GroupInvoice, which is one of
the tables whose only INSERT policy is restrictive (D1) — so the credit note fails
silently too. The receivable is overstated by ₹2,08,500 plus its GST.
D9 — A group created through the API has no cancellation policy, so every cancellation refunds 100% (P1)
cancel-preview for the real cancelled pilgrim returned:
The business retained ₹27,000 (₹10,000 cancellation fee + ₹17,000 visa
charges) and had already spent ₹19,900 of it (₹11,300 visa + ₹8,600 ticket
cancellation). The system offers a full ₹1,15,000 refund because POST /groups
(:15068) never sets cancellationPolicyId and there is no fallback to a default
policy. Nothing warns the operator that a group has no policy attached.
D10 — GET /sales/bookings/:id returns passengers in an unstable order (P3)
Two runs of the same script returned the same booking's passengers in different orders, so an index-based "cancel the third passenger" cancelled the wrong pilgrim. It bit this audit (the driver now matches on passport number instead) and it will bit any UI that renders a list and posts back by position.
D11 — tripType is not validated on block creation (P3)
src/lib/api.ts:27311 does tripType: body?.tripType ?? 'one_way' with no
whitelist, while the equivalent FIT route at :28923 correctly does
['one_way','return','multi_city'].includes(...). Sending anything else surfaces
a raw Postgres message —
new row violates check constraint "AirlineQuotaBlock_tripType_check" — to the
user. (The accepted value for a return trip is return, not round_trip.)
D12 — The preview stack is 6 migrations behind the repo (P2, process)
supabase_migrations.schema_migrations holds 301 rows against 307 migration files;
20260921100000 through 20260921140000 are unapplied. Combined with the D1
policy inversion, this preview environment does not match what the repo would
build, so a click-through on it is not evidence about a release. Worth a
scripts/check-migrations.mjs mode that diffs applied versions against the
directory and fails loudly.
4. What the business tracks and the software has nowhere to put
| # | The business fact | Why it does not fit |
|---|---|---|
| G1 | F.O.C seats. "20 seats, 2 free of charge" is how every block is bought. | POST /inventory/quota-blocks has no focSeats input — the column exists and stayed 0 after sending it. FOC is modelled only as a cancellation attribute (AirlineBlockCancellation.isFoc). The unsold-seat position (4 seats, ₹1,99,440 on this departure) has nowhere to live either. |
| G2 | The company's own group code, 29Aug-19D-6E-A — on every workbook, receipt and WhatsApp message. |
TravelGroup.groupCode is generated by a trigger (UMR-29AUG-A-19D). The real code can only go in metadata and is not searchable as an identity. |
| G3 | One all-in price per pilgrim, negotiated per person and per sub-agent (₹93,000 to ₹1,40,000 on this one departure). | The group rate sheet is a shared 4-component card (flight/hotel/visa/ground). Real prices survive only as per-passenger overrides, and the rate sheet ends up holding an average that matches nobody. Worse, PAX-030 then rejects those overrides unless the person entering the booking holds group_pricing.edit — a sales executive cannot enter a single booking on this departure. |
| G4 | Discounts. The PRICE / TOTAL / RECEIVED / DISCOUNT / BALANCE columns run per pilgrim, and the payment register has its own discount column. | Booking and BookingPassenger have no discount field. Discount exists only on Quotation. A reduction has to be faked as a lower rate, losing the fact that a discount was given. |
| G5 | The "BOOKED BY" sub-agent and the fact that one sub-agent pays one lump sum for pilgrims spread across two airline blocks. | Agent + Booking.agentId hold the attribution correctly ✅, but a Payment attaches to exactly one bookingId. ABE ZUM ZUM's single ₹9,15,000 receipt covers two bookings and had to be split by hand. |
| G6 | Per-pilgrim room type. This departure had a DOUBLE for a couple and a QUAD for four, inside the same sub-agent's booking. | roomType is a Booking-level field. There is no BookingPassenger.roomType, so per-pilgrim allocation is invisible until the hotel-assignment stage and never appears on the price view. |
| G7 | The same hotel at several rates and durations on one departure — Makkah also took 1 room × 9 nights, Madinah 1 × 8 nights @ SAR 230 and 1 × 2 nights @ SAR 220 for a rescheduled pilgrim. | HotelInventory holds one pricePerNight and one date range per row. Each variation needs a separate inventory row the business does not think of as separate stock — which is exactly the ₹89,700 of hotel cost the system lost. |
| G8 | Pilgrims with a single-part legal name. 11 of 27 on this departure have a blank passport surname field. | create_booking (supabase/migrations/20260918120000_booking_lifecycle.sql:1441) hard-rejects a passenger without both names. Staff must invent a split that then will not match the passport, the PNR or the visa. |
| G9 | A visa's end state. The workbook records "ISSUED" and nothing else. | The system requires a walk through APPLIED → UNDER_PROCESS → SENT_TO_EMBASSY → ISSUED. Replaying history invents four transition timestamps that never happened; there is no "record the end state as at a date" entry point. |
| G10 | Receipts with no date, mode or instrument. The PAYMENT REGISTER has only Group, B2B, Total Received, Discount and a blank Txn ID. | Not a software gap — a source-data gap, but it makes bank reconciliation impossible and forced every receipt in this test to be dated to the departure date. Worth fixing at the spreadsheet end before migration. |
| G11 | Two different retentions on a cancellation — a ₹10,000 service fee and ₹17,000 of pass-through visa cost, with different tax treatment. | CancellationPolicySlab takes a single charge percentage. The split cannot be expressed. |
| G12 | Food/catering as a per-pilgrim, per-day service with adjustments — "less 3 days for 4 pilgrims, −SAR 348", "add 3 days for 4 pilgrims, +SAR 348", a ₹13,500 accommodation top-up and a ₹10,750 rescheduling fee for one pilgrim. | There is a GroupFoodAssignment table and meal fields, but nothing that accepts day/pilgrim adjustments; all ₹2,64,654 of catering had to go in as unstructured misc expense (and in this run went in as nothing at all). |
5. What the system made harder than the spreadsheet
- Buying a block took a super-admin. The one role the permission matrix names for it could not do it, and even the super-admin's attempt left stock with no payable.
- Six roles were needed to enter one departure — ops, admin-HR, sales manager, B2B manager, sales exec, cashier, finance manager — where the spreadsheet takes one person. Three of those hand-offs exist only to work around D3, D6 and D7, not because the business separates those duties.
- A sales executive is locked out of their own bookings once a rate sheet exists, because every pilgrim's price is individually negotiated (G3).
- Room allocation had to be planned before data entry. Without an explicit
roomShareGroupper pilgrim the API gives each pilgrim their own room and the 6-room allotment fills after 6 pilgrims. The spreadsheet just says "6 rooms". - The cancellation needed two people and produced no credit note, where the spreadsheet records the whole thing in one remarks cell.
- Nothing told anyone it had gone wrong. Six bookings approved, an invoice raised for each, and an empty ledger behind all of them — with no error, no banner and no exception report. The spreadsheet's arithmetic is contradictory (§1.1) but at least it is visible.
What genuinely worked
Worth saying plainly, because it is most of the operational model:
- Gross billing, pilgrim counts, gender/age mix and the five-way sub-agent split reproduced exactly.
- SAR → INR at the contract rate of 26.0 was applied correctly and the group
expense report shows its own working (
6 rooms × 12 nights × 130 SAR @ 26). - Maker-checker held. The cashier who recorded a receipt was refused when trying to verify it. The accountant was refused when approving journals reserved for the finance manager.
- Idempotency held. A second
ops-approveon the same booking returnedalreadyApproved: truerather than approving twice. - The inventory drift report was clean (0 items, 0 oversold) and hotel room and bed counters tracked assignment correctly (INV-013).
- No unbalanced vouchers. Every journal that was written balanced.
- Receipts reconciled to the paisa: ₹16,35,000 recorded, verified and banked.
6. Recommended order of work
- D1 — fix the
JournalEntry/JournalLinepolicy shape and add a db-test that an authenticatedfinance.createuser can insert a journal. Audit the other ten restrictive-only tables at the same time,GroupInvoicefirst. - D2 — stop swallowing journal failures on booking creation.
- D3, D6, D7 — reconcile the permission grants with the workflows in
docs/PERMISSIONS.md§6, and add a test that walks a whole departure as the roles the matrix names. - D4, D5 — make block purchase atomic; make
POST /hotelsable to insert. - D8, D9 — reduce the invoice on cancellation, and give every group a cancellation policy.
- G1, G3, G4, G6 — the four data-model gaps that touch money directly: FOC seats, per-pilgrim pricing, discounts, per-pilgrim room type.
- [CA] — settle the GST question (5% on gross vs 18% on margin) before any real billing, and decide the F.O.C convention (W6) so the ₹1,99,440 of bought-and-unsold seats lands somewhere.
Rerun after the P0 fixes (19 Sep 2026)
The same departure, entered again from scratch, on a preview database rebuilt
from all 312 migrations on integration/ops-upgrade and then emptied of
demo business data. Everything above is left as it was written on 18 Sep so the
before/after is visible; this section reports only what the second run found.
Two things changed about the method, and both are deliberate:
- A third source of truth arrived. The owner supplied the group's account
ledger out of the company's Busy accounting software — account
EXP AUG 29 UMR GRP 19D ( 26-27 ), 1 Apr – 30 Sep 2026, opening 0, closing ₹25,87,546 Dr across 14 entries. That is the real book of account and it is treated here as authoritative above the operations workbooks. Where Busy and the workbooks disagree, both figures are shown. - No group rate sheet was set. Setting one silently switches a group into the invoice-based revenue model, where creating a booking posts no revenue at all — which is most of why the first run's ledger was empty. defect R14 in §10 below measures that on a throwaway group instead of letting it swallow the test.
Reproduce:
supabase db reset --workdir <preview>
node scripts/dev/seed-demo.mjs # logins + roles + grants
psql "$PREVIEW_DB" -f scripts/dev/purge-business-data.sql
node scripts/dev/extract-group-workbook.mjs --dir "<workbook dir>" \
--group "29Aug-19D-6E-A" --out /tmp/g.json
node scripts/dev/replay-group.mjs --input /tmp/g.json \
--password '<preview password>' --log /tmp/replay.json
Verdict
Much closer, and one structural hole remains.
The books now exist, they balance, and they are recognisably this departure: revenue ₹28,91,501, the five sub-agent ledgers to the rupee, ₹16,35,000 of receipts, ₹23,49,254 of supplier payables, the hotels converted from SAR at the contract rate of 26.0 to the paisa, the cancellation reversed, the ₹27,000 retention kept as income and the pilgrim's seat back on the block. The trial balance is 19 lines and ₹79,65,517 a side, not five lines and ₹16 lakh. Nothing failed silently anywhere.
Two things are still wrong, and the first is the larger:
- The cost of a flown airline block never becomes an expense. ₹15,50,300 goes to Stock-in-Hand when the seats are bought and nothing takes it out again when the aircraft leaves. After a completely flown departure, Stock-in-Hand still holds ₹15,08,730 and the company Profit & Loss reports a ₹15,75,255 profit on a departure that made ₹2.3 lakh. Hotels have the matching entry and work; blocks have none for a group departure.
- No operational or sales role can complete a single money-touching step.
22 escalations to a super-admin in one departure, and 21 of them are the same
cause: the handler posts its journal from the browser under the acting user's
RLS, and
finance.createis held by nobody in operations, sales or ticketing. The FIN-030 fix turned this from silence into a clear refusal with a clean rollback — the operator is now told, and still cannot act.
Beneath that the numbers reconcile exactly, in both directions, and every difference has a named cause (§8.3).
7. The Busy ledger, read line by line
Read with openpyxl; the truncated narrations and the offset final rows were
re-read from the file. The arithmetic in two narrations does not match the
amounts, and the amounts win.
| # | Date | Type | Voucher | What | Amount |
|---|---|---|---|---|---|
| 1 | 18 Aug | Sale | SL/3/26-27 |
Air, 18 pax, PNR P6Q8QI, SXR–JED–SXR | ₹9,19,980 |
| 2 | 21 Aug | Sale | CS/95/2026-27 |
Umrah visa 20 pax + Rawad transport ₹64,750 | ₹2,92,750 |
| 3 | 26 Aug | SlRt | SR/9/26-27 |
Sales return — the cancelled pilgrim's ticket, PNR H796KF | −₹44,210 |
| 4 | 26 Aug | Sale | SL/11/26-27 |
Air, 9 pax, PNR H796KF | ₹4,77,990 |
| 5 | 27 Aug | Sale | SL/13/26-27 |
Umrah visa, 4 pax | ₹45,600 |
| 6 | 27 Aug | Sale | SL/14/26-27 |
Umrah visa, 2 pax | ₹22,800 |
| 7 | 27 Aug | Sale | SL/15/26-27 |
Umrah visa, 1 pax — the cancelled pilgrim | ₹11,400 |
| 8 | 27 Aug | Jrnl | JR/420/26-27 |
BRN, 3 pax — Rawad Holidays Visa | ₹10,217 |
| 9 | 12 Sep | Jrnl | JR/437/26-27 |
Hotel Makkah: 6R×12N@130 + 1R×9N@130 = SAR 10,530 | ₹2,73,780 |
| 10 | 16 Sep | Jrnl | JR/452/26-27 |
Five "on a/c Hameed Makkah" lines = SAR 2,002.50 | ₹52,065 |
| 11 | 16 Sep | Jrnl | JR/453/26-27 |
Food Makkah — Gowhar, SAR 6,728 | ₹1,74,928 |
| 12 | 16 Sep | Jrnl | JR/454/26-27 |
Food Madinah — Muneer Ah Parray, SAR 3,691 | ₹95,966 |
| 13 | 16 Sep | Jrnl | JR/455/26-27 |
Hotel Madinah: 6R×5N@250 + 1R×8N@230 + 1R×2N@220 = SAR 9,780 | ₹2,54,280 |
| Closing balance | ₹25,87,546 Dr |
Regrouped the way the business thinks about it:
| Category | Busy | Detail |
|---|---|---|
| Air, net of the return | ₹13,53,760 | 18 @ ₹51,110 + 9 @ ₹53,110 − ₹44,210 |
| Umrah visas, 27 pax | ₹3,07,800 | 27 @ ₹11,400 (see the note below) |
| Rawad ground transport | ₹64,750 | inside CS/95 |
| BRN, 3 pax | ₹10,217 | |
| Hotel Makkah | ₹2,73,780 | SAR 10,530 @ 26.0 |
| Hotel Madinah | ₹2,54,280 | SAR 9,780 @ 26.0 |
| Food Makkah | ₹1,74,928 | SAR 6,728 @ 26.0 |
| Food Madinah | ₹95,966 | SAR 3,691 @ 26.0 |
| Incidentals | ₹52,065 | SAR 2,002.50 @ 26.0 |
| Total | ₹25,87,546 |
7.1 Where Busy and the operations workbooks disagree
| # | Line | Workbook | Busy | Difference | Who is right |
|---|---|---|---|---|---|
| B1 | Air | ₹13,50,860 | ₹13,53,760 | ₹2,900 | The ticket vouchers carry ₹51,110 / ₹53,110 a seat; the INDIGO block sheet carries ₹51,010 / ₹53,010. A flat ₹100 a ticket that no sheet explains, plus ₹300 on the cancelled seat. |
| B2 | Umrah visa | ₹3,05,100 (27 × 11,300) | ₹3,07,800 (27 × 11,400) | ₹2,700 | Busy's own narrations say 20*11300 and 4 * 11300, but 292,750 − 64,750 = 228,000 = 20 × 11,400, and 45,600 = 4 × 11,400. The amounts are at ₹11,400; the narrations are stale. Again ₹100 a head. |
| B3 | Food, Madinah | ₹89,726 | ₹95,966 | ₹6,240 | Busy is right and W4 above is confirmed. SAR 3,691 × 26 = ₹95,966. The workbook's INR total drops the SAR 240 take-away line that its own SAR total includes. |
| B4 | Reconciliation adjustment & tax | ₹5,400 | — | ₹5,400 | Busy has no such entry. It is a plug in the workbook. |
| B5 | Hotels, transport, incidentals, BRN, food Makkah | — | — | ₹0 | Identical to the rupee in both. |
Net: Busy ₹25,87,546 vs workbook ₹25,81,106 = ₹6,440, being +2,900 +2,700 +6,240 −5,400.
7.2 How Busy records the cancellation, and how the system does
Busy books the cancelled pilgrim as a sales return against the air purchase —
SR/9, credit ₹44,210 — and leaves that pilgrim's visa (SL/15, ₹11,400) in
the cost. It carries no separate penalty line; the penalty is the ₹8,900
difference between the ₹53,110 ticket and the ₹44,210 credited back.
The system books the same event as three lines:
Dr 1245 Cancellation Refund Receivable ₹44,410
Dr 6200 Cancellation & Rescheduling ₹ 8,600
Cr 1310 Stock-in-Hand ₹53,010
The net effect on cost is the same idea and differs only by the ₹100-a-seat rate gap (B1) and ₹300 of penalty: Busy takes ₹44,210 off cost, the system takes ₹53,010 out of stock and puts ₹8,600 into expense, leaving ₹44,410 as a receivable. The difference in shape matters, and the system's shape is the better one: it says explicitly that the airline owes ₹44,410 and keeps that claim visible until the money arrives, where Busy has already assumed it.
7.3 The shapes Busy uses that the system cannot hold
Not accounting errors — the business's own way of writing these costs down, and what happens to it in the system.
| Busy's shape | In the system |
|---|---|
Air booked per passenger with the PNR in the narration (MR TBA X 18 PAXS PNR:P6Q8QI) |
Air is a block of seats at a contract rate; cost to the departure is derived from seats assigned. The PNR lives on GroupFlight. Reconcilable, but per-ticket pricing (the ₹100 of B1) has nowhere to sit. |
Visa booked as count × rate with the MOFA application number (20*11300 + TRNP@64750) |
A free-text group expense with one amount. Neither the pax count, the per-visa rate, nor the application number is a field. Busy also mixes visa and transport in one voucher; the system needs two rows. |
Hotels as rooms × nights × SAR rate (6-rooms ×12 nights@130SR) |
Exactly this. GroupHotelAssignment holds rooms, nights and rate and the expense report prints the working back. The one place the two systems agree without translation. |
Costs paid on account through a named person (ON A/C HAMEED MAKKAH, five lines) |
No notion of a person carrying group cash. The five lines went in as five group expenses credited to Sundry Creditors. Hameed's running balance does not exist. |
Catering with day/pilgrim adjustments in the narration (22 P x 11.50 D x 29sr = 7337 −348 −261) |
One amount, one description. Confirmed again — see G12 above. |
8. Expected vs actual
8.1 Revenue and money in
| Line | Workbook | Busy | System | Verdict |
|---|---|---|---|---|
| Gross billed, 27 pilgrims | ₹28,91,501 | — | ₹28,91,501 | ✅ exact |
| Pilgrim mix | 17 M / 10 F, 26 travelling | — | 16 M / 10 F travelling, 27 on the manifest, 1 cancelled | ✅ exact |
| Sub-agent split | 16 / 4 / 3 / 2 / 2 | — | 5 partners, exact | ✅ exact |
| Visas | 27 issued | — | 26 ISSUED, 1 CANCELLED | ✅ exact |
| GST charged to customers | ₹0 | ₹0 | ₹0 | ✅ (but see R12) |
| Cancellation credit | −₹1,15,000 (Financial Summary) / −₹88,000 (the narrative) | −₹44,210 (against cost) | −₹88,000 | follows the narrative |
| Service charge retained (₹700 × 25) | −₹17,500 | — | nowhere to put it | ❌ gap G13 |
| Net sales | ₹27,59,001 | — | ₹28,03,501 | +₹44,500 |
| Receipts recorded and verified | ₹16,35,000 | — | ₹16,35,000 | ✅ exact |
| Outstanding receivable | ₹11,24,001 | — | ₹11,68,501 (GL and group report agree — one answer, not two) | +₹44,500 |
The whole ₹44,500 is two things and nothing else: ₹27,000 the business retained (the system keeps it as revenue the sub-agent still owes; the workbook writes the sale off in full) and ₹17,500 of service charge the workbook deducts from sales and the system has no field for.
Per sub-agent, straight off the trial balance:
| Sub-agent | Workbook billed | System | Workbook received | System | Workbook outstanding | System |
|---|---|---|---|---|---|---|
| ABE ZUM ZUM | ₹15,88,001 | ₹15,88,001 | ₹9,15,000 | ₹9,15,000 | ₹6,73,001 | ₹6,73,001 |
| MOHAMMAD FAYAZ MIR (TA) | ₹4,80,000 | ₹4,80,000 | ₹4,00,000 | ₹4,00,000 | ₹80,000 | ₹80,000 |
| BATA ZAWAR | ₹3,23,500 | ₹3,23,500 | ₹0 | ₹0 | ₹3,23,500 | ₹2,35,500 |
| G N M TRAVELS | ₹2,80,000 | ₹2,80,000 | ₹1,00,000 | ₹1,00,000 | ₹1,80,000 | ₹1,80,000 |
| MUJTUBA OFFICE | ₹2,20,000 | ₹2,20,000 | ₹2,20,000 | ₹2,20,000 | ₹0 | ₹0 |
Four of five to the rupee. BATA ZAWAR differs by exactly the ₹88,000 refund, which is the cancellation working correctly — the workbook never took it off.
8.2 Cost, line by line
| Cost line | Busy | Workbook | System — group cost report | System — general ledger |
|---|---|---|---|---|
| Airline seats | ₹13,53,760 | ₹13,50,860 | ₹13,42,260 | ₹0 expense — ₹15,50,300 sits in Stock-in-Hand |
| Ticket cancellation charge | (inside the return) | ₹8,600 (inside air) | ₹0 — see defect R8 | ₹8,600 in 6200 |
| Umrah visas | ₹3,07,800 | ₹3,05,100 | ₹3,05,100 | inside 5000 Purchase A/c |
| Hotel Makkah | ₹2,73,780 | ₹2,73,780 | ₹2,73,780 | ₹2,73,780 in 5200 ✅ |
| Hotel Madinah | ₹2,54,280 | ₹2,54,280 | ₹2,54,280 | ₹2,42,840 in 5200 (₹11,440 stuck — defect R3) |
| Food Makkah | ₹1,74,928 | ₹1,74,928 | ₹1,74,928 | ₹1,74,928 in 5300 ✅ |
| Food Madinah | ₹95,966 | ₹89,726 | ₹95,966 | ₹95,966 in 5300 ✅ |
| Ground transport | ₹64,750 | ₹64,750 | ₹64,750 | inside 5000 |
| Incidentals | ₹52,065 | ₹52,065 | ₹52,065 | inside 5000 |
| BRN, 3 pax | ₹10,217 | ₹10,217 | ₹10,217 | inside 5000 |
| Reconciliation adjustment | — | ₹5,400 | — | — |
| Total cost | ₹25,87,546 | ₹25,81,106 | ₹25,73,346 | ₹12,28,246 |
The group cost report is ₹14,200 under Busy, and every rupee of it is named: ₹8,600 of airline penalty the report cannot see (R8), ₹2,900 of per-ticket rate (B1) and ₹2,700 of visa rate (B2). It is ₹7,760 under the workbook: the same ₹11,500 of air, plus ₹6,240 more food because the system carries the correct Madinah figure (B3), less the ₹5,400 plug (B4). The system reproduces the workbook's air and visa rates, because those are the rates on the block contract and the group sheet that were fed to it; it reproduces Busy's food, because that is the arithmetic the SAR working actually gives.
Hotels reconcile perfectly, all five lines, and the report prints its own
working — 6 rooms × 12 nights × 130 SAR @ 26 — which is exactly how JR/437
narrates it.
8.3 Margin, both ways
As the company's own sheet computes it — only the seats that flew are charged to the departure:
| Net sales | Cost | Margin | % | |
|---|---|---|---|---|
| Workbook | ₹27,59,001 | ₹25,81,106 | ₹1,77,895 | 6.44% |
| Busy | ₹27,59,001 | ₹25,87,546 | ₹1,71,455 | 6.21% |
| System (Group P&L) | ₹28,03,501 | ₹25,73,346 | ₹2,30,155 | 8.21% |
The system is ₹58,700 above Busy, and it is four things:
+ ₹27,000 the retention booked as revenue the sub-agent owes, not written off
+ ₹17,500 service charge with no field, so never deducted from sales
+ ₹11,500 air: ₹8,600 penalty the group report can't see + ₹2,900 of rate
+ ₹ 2,700 visa rate (₹11,300 on the sheet vs ₹11,400 in Busy)
─────────
₹58,700
With the bought-but-unsold seats charged to the departure. 30 seats were bought, 26 flew, 1 was cancelled and refunded, 3 were never used:
| Net sales | Cost incl. unsold seats | Margin | |
|---|---|---|---|
| Workbook | ₹27,59,001 | ₹25,81,106 + ₹1,99,440 | −₹21,545 |
| Busy | ₹27,59,001 | ₹25,87,546 + ₹1,96,540 | −₹25,085 |
| System | ₹28,03,501 | ₹25,73,346 + ₹1,55,030 | +₹75,125 |
Which does the system produce, and why? The first one — ₹2,30,155 — and it
produces it without being asked. computeGroupCostBreakdown() charges
pricePerSeat × seats actually assigned, so unused seats are simply not a cost
of the departure. The unsold seats are not lost: they are still on the balance
sheet at ₹1,55,030 (3 seats — 2 × ₹51,010 + 1 × ₹53,010) inside Stock-in-Hand.
That is correct while seats are still sellable, and wrong the moment the
aircraft leaves, because nothing ever writes them off. The system will never
show the second answer on its own, and there is no way to make it: the only
route that takes seats out of stock is an airline-cancellation filing, which
expects a refund and a penalty, not a write-off.
The system's residual is ₹1,55,030 rather than the workbook's ₹1,99,440 because the system is holding ₹44,410 as a receivable from the airline; add it back and the two agree.
8.4 The general ledger
Trial balance, in full — 19 lines, ₹79,65,517 a side, balanced:
| Code | Account | Debit | Credit |
|---|---|---|---|
| 1010 | Bank Account | ₹16,35,000 | |
| 1245 | Cancellation Refund Receivable | ₹44,410 | |
| 1310 | Stock-in-Hand | ₹20,78,360 | ₹5,69,630 |
| 2100 | Sundry Creditors – Suppliers | ₹4,32,132 | |
| 4000 | Booking / Sales Revenue | ₹88,000 | ₹28,91,501 |
| 5000 | Purchase A/c | ₹4,32,132 | |
| 5200 | Hotel Expenses | ₹5,16,620 | |
| 5300 | Food / Meal Expenses | ₹2,70,894 | |
| 6200 | Cancellation & Rescheduling Charges | ₹8,600 | |
| AGR-… ×5 | the five sub-agents | ₹28,91,501 | ₹17,23,000 |
| SUP-… ×5 | IndiGo, both hotels, both caterers | ₹23,49,254 | |
| Total | ₹79,65,517 | ₹79,65,517 |
Against the first run's five lines and ₹16,35,000, and against its books saying Alhuda owed its sub-agents ₹16.35 lakh, this is a different system.
Profit & Loss — populated, and structurally wrong:
| Amount | |
|---|---|
| Booking / Sales Revenue | ₹28,03,501 |
| Food / Meal Expenses | ₹2,70,894 |
| Purchase A/c (visa, transport, BRN, incidentals) | ₹4,32,132 |
| Hotel Expenses | ₹5,16,620 |
| Cancellation & Rescheduling Charges | ₹8,600 |
| Net profit | ₹15,75,255 |
₹15.75 lakh of profit on a departure that made ₹2.3 lakh, because ₹15,08,730 of cost is parked in Stock-in-Hand (₹14,97,290 of flown air seats and ₹11,440 of hotel whose consumption entry was refused). Those are defects R1 and R3 in §10 below.
8.5 GST
| Question | Answer |
|---|---|
| What did the system charge? | ₹0. Each booking was entered with gstEnabled: false, which is how the business bills — one all-in package price, no GST line. |
What does /finance/gst-summary report? |
outputGst: 0, inputGst: 0, netPayable: 0 — consistent with the ledger for the first time. |
| What would have happened by default? | FinanceConfig still has gstEnabled = true, gstRate = 5. Left alone it would have charged 5% of computeBookingGroundMargin(), which is the ₹1,43,214.54 of run 1. |
| Can the group carry that decision? | Not through the API. POST /groups ignores defaultGstEnabled / defaultGstRate; the choice has to be repeated on every booking. |
8.6 Inventory and the audit trail
| Check | Result |
|---|---|
| Inventory drift | 0 items, 0 oversold risk ✅ |
| Block A (P6Q8QI) | 20 bought / 18 allocated / 2 available ✅ |
| Block B (H796KF) | 10 bought / 8 allocated / 1 cancelled-charged / 1 available ✅ |
| Seat returns on cancellation | allocated 9 → 8, available 1 → 2 ✅ |
| Hotel rooms and beds | all 5 allotments assigned, 27 pilgrims roomed, 0 assignment refusals (6 failed in run 1) |
| Audit trail | 500 rows at the read limit, 9 distinct actors, actions attributed correctly — ops 68, ticket desk 23, visa officer 108, cashier 12, finance manager 219, accountant 10, sales 14, CEO 6, super-admin 40 |
9. What the P0 fixes demonstrably repaired
| First run | Now |
|---|---|
D1 — JournalEntry / JournalLine INSERT restrictive-only, every posting denied |
rls_restrictive_only_commands() returns 0 rows on a database built from the 312 migrations. 33 vouchers posted. |
| D2 — 6 bookings worth ₹28,91,501 committed with no journal and no warning | 6 booking attempts refused with "Booking BK-0000n was not created: its revenue entry could not be posted … Nothing was saved", 0 orphan bookings. Retried by an account that can post, all 6 posted. |
| D3 — no operational role could link a flight | OPS_MANAGER linked both blocks and made all five hotel assignments; the ticket desk assigned all 27 seats. groups.edit / bookings.edit grants confirmed. |
| D4 — a failed block purchase left ₹25.7 lakh of stock with no payable | Both refused purchases left orphanRowsLeftBehind: 0. The rollback fires. |
D5 — POST /hotels could never insert |
5 hotel inventory rows created, including the awkward 1-room lines. |
| D7 — no finance role could record a supplier bill | The ACCOUNTANT recorded both catering bills (₹1,74,928 and ₹95,966) without escalating. suppliers.edit grant confirmed. |
| D8 — cancelling a pilgrim left the invoice and receivable untouched | Booking.balanceAmount ₹3,23,500 → ₹2,35,500; Invoice.balanceDue ₹2,35,500; an ₹88,000 reversal voucher posted against the booking's revenue voucher. |
| D9 — every cancellation offered a 100% refund | cancel-preview returned the group's policy by name, chargePercent: 23.48, charge ₹27,002, refund ₹87,998 — not policy: null, refundAmount: 115000. |
| D10 — unstable passenger order | Still matched on passport number, so not re-tested. |
| D12 — the preview was 6 migrations behind | Rebuilt from all 312. |
And the queue itself works: the one posting that genuinely could not be rolled
back (defect R3 below) arrived in PostingFailure with its full payload — rooms,
nights, rate, both account codes, the assignment id — rather than a console
line.
10. What is still broken
R1 — A flown airline block never becomes a cost of sale (P0)
createQuotaBlockFinanceEntries (src/lib/api.ts, POST /inventory/quota-blocks)
posts the purchase as perpetual inventory:
and its own comment says the expense "only fires when seats are consumed via
the third-party-sale COGS journal or the cancellation flow". A group
departure is neither. Assigning a passenger to a GroupFlight writes
BookingPassengerFlight and nothing else. After this departure flew, the only
credit to Stock-in-Hand from the air side was ₹53,010 — the cancelled seat,
via the airline filing.
Hotels have exactly the entry that is missing: POST /hotels/assignments moves
rooms × nights × rate from 1310 to 5200. Blocks need the same on seat
assignment, or a departure-close step.
Consequence: the company P&L overstates profit by ₹14,97,290 on one departure, and the balance sheet carries seats on an aircraft that has landed.
Reproduce: buy a block, link it, assign every seat, then
select sum(debit)-sum(credit) from "JournalLine" l join "Account" a on a.id=l."accountId" where a.code='1310'.
R2 — Nothing in operations or sales can post, so everything escalates (P0)
22 escalations to a super-admin. Twenty-one are one sentence: the handler posts
its journal from the browser under the acting user's RLS, and JournalEntry
INSERT requires finance.create.
| Route | Escalations | Permission the route asks for | Permission the posting needs |
|---|---|---|---|
POST /groups/:id/misc-expenses |
8 | groups.edit |
finance.create |
POST /sales/bookings |
6 | bookings.create |
finance.create |
POST /hotels |
5 | hotels.edit |
finance.create |
POST /inventory/quota-blocks |
2 | inventory.create |
finance.create |
POST /cancellation-policies |
1 | admin.edit |
— |
finance.create is held by ACCOUNTANT, FINANCE_MANAGER, CHARTERED_ACCOUNTANT,
CEO, GM and SUPER_ADMIN. It intersects bookings.create only at CEO / GM /
SUPER_ADMIN, and inventory.create only at CEO / GM / SUPER_ADMIN. The
September grants (20260922100100) fixed the route guards; the posting
guard behind them was not part of that migration, so a sales executive still
cannot take a booking and an operations manager still cannot buy a block.
This is the same root cause as D1/D3 wearing different clothes, and it will not
be fixed by another grant: giving finance.create to sales would let sales
write arbitrary journals. The posting belongs in a SECURITY DEFINER function
that checks the business permission — the shape create_booking and
record_payment already use.
Reproduce: sign in as OPS_MANAGER → POST /groups/:id/misc-expenses with a
sourceAccountId → 400 The expense was not saved: its accounting entry could
not be posted — new row violates row-level security policy for table
"JournalEntry".
R3 — Operations allocating rooms leaves the cost in stock (P1)
Same cause, worse symptom, because here the operational record succeeds and
only the money fails. POST /hotels/assignments saves the assignment, then
posts Dr 5200 / Cr 1310; when operations does it the posting is refused and the
row goes to PostingFailure:
operation hotel_stock_consumption
description 1 room(s) × 2 night(s) were assigned to a group but the
stock-consumption entry for 440.00 (in the hotel's contract
currency) did not post. The cost is still sitting in
Stock-in-Hand instead of Hotel Expense.
errorMessage new row violates row-level security policy for table "JournalEntry"
payload { rooms 1, nights 2, pricePerNight 220, checkIn 2026-09-02,
checkOut 2026-09-04, debitAccountCode "5200",
creditAccount "Stock-in-Hand (1310)", assignmentId … }
This run deliberately let operations make only the smallest of the five assignments so the defect is on the record with a real payload; the other four were made by an account that can post. Had operations done all five — which is what would happen in the office — the entire ₹5,28,060 of hotel cost would have stayed on the balance sheet, exactly as it did in the first run of this rerun (5 failures, Hotel Expenses ₹0).
The queue also has no retry. POST /finance/posting-failures/:id/resolve marks
the row resolved; it does not post the voucher. The only way to fix this one is
to delete the assignment and remake it as someone who can post.
R4 — Maker-checker deadlock on the cancellation reversal (P1)
finance.journals.approve is held by FINANCE_MANAGER, CEO, GM and
SUPER_ADMIN — not by the ACCOUNTANT, who is the obvious second pair of eyes
and who holds finance.create. Probed directly:
So the finance manager approves 27 of 29 vouchers, and is then correctly refused on the 2 they created themselves (ACC-020) — including the ₹88,000 cancellation reversal, which a cancellation approved by finance always produces. In the first pass of this rerun that voucher sat pending permanently: revenue stayed at ₹28,91,501, the receivable stayed at ₹3,23,500, and the cancellation showed correctly everywhere except the ledger. It cleared only once the CEO was brought in.
Either the accountant needs finance.journals.approve, or a cancellation
reversal needs to not be made by the person approving the cancellation.
R5 — The group cost report and the ledger are two unreconciled books (P1)
computeGroupCostBreakdown() reads GroupFlight, GroupHotelAssignment,
GroupFoodAssignment and GroupExpense. The ledger reads vouchers. Nothing
joins them, and they disagree by ₹13,45,100 on this departure
(₹25,73,346 vs ₹12,28,246).
Concretely: a supplier bill recorded through POST /suppliers/:id/transactions
creates the payable and the voucher and is invisible to the group's own cost
report. To get the ₹2,70,894 of catering into both, it had to be entered
twice — once as the bill the caterer is owed, once as a group expense — and
the second copy had to be entered without a sourceAccountId so it would not
post a second voucher. Nothing in the product warns that the two disagree, and
nothing stops the same cost being double-posted by someone who does not know
the trick.
R6 — Every group expense posts to 5000 Purchase A/c (P2)
POST /groups/:id/misc-expenses calls ensurePurchaseAccount() regardless of
category. ₹3,05,100 of visa, ₹64,750 of ground transport, ₹10,217 of BRN and
₹52,065 of incidentals land in one undifferentiated ₹4,32,132 line. 5400
Ground Transport Expenses — which exists in the chart — stays empty. The
category is a label on the group screen only, so the P&L cannot show the cost
ladder the business actually keeps, and Busy's per-supplier detail cannot be
reproduced.
R7 — A group expense with no sourceAccountId posts nothing, silently (P2)
Same route: the journal is posted only if (sourceAccountId && baseAmount > 0).
Without one the row is saved as a tracking entry and nothing reaches the ledger
— no error, no warning, no queue row. This is precisely how ₹4.3 lakh of visa,
transport and incidental cost stayed out of the books in the first run. The
field is optional and unremarkable in the payload; the consequence is total.
R8 — The airline penalty reaches the company P&L but not the departure (P2)
The ₹8,600 ticket cancellation charge posts to 6200 and shows in the P&L, but
computeGroupCostBreakdown() picks penalties up from AirlineCancellationSeat
rows that carry a groupId — and a groupId is only stamped when the
filing names a specific released seat. See R9: there is no released seat to
name, so the filing is against an anonymous "unallocated" seat and the charge
never reaches the departure that incurred it.
R9 — Cancelling a pilgrim returns the seat but writes no SeatRelease (P2)
The seat comes back correctly (allocated 9 → 8, available 1 → 2), but
GET /inventory/releases?blockId=… returns nothing, so the airline-filing
screen — which asks the user to pick from released seats — has nothing to
offer. The penalty can only be filed against an unallocated seat, losing the
link between the charge and the pilgrim who caused it, and taking R8 with it.
R10 — GET /groups/:id never reports the group's cancellation policy (P3)
The row is correct (TravelGroup.cancellationPolicyId is set, and
cancel-preview resolves the policy by name), but mapPublicGroup() hard-codes
cancellationPolicyId: null, so neither the create response nor the read tells
an operator which terms a departure is on. On a screen that now refuses to
cancel without a policy, that is the one field you need to see.
R11 — GET /inventory/quota-blocks/:id returns 405 (P3)
There is no single-block read; only the list route with ?search=. Worth
noting because it silently produced a false finding in the first pass of this
rerun — focSeats read back as null from a 405 response rather than from the
database. (It is in fact 0; G1 stands, see below.)
R12 — GST is still on by default and still not settable per group (P2)
Unchanged from run 1 except that this run switched it off per booking.
FinanceConfig.gstEnabled = true, gstRate = 5, POST /groups ignores
defaultGstEnabled. An operator entering this departure the obvious way would
still bill ₹1.43 lakh of GST the business does not charge, and it would still
never reach the GST return.
R13 — scripts/dev/seed-demo.mjs re-broke the D1 fix on every run (P1, process — fixed here)
Found while setting this rerun up, and it explains the fingerprint the first
audit could only describe. ensureRolesAndGrants() re-applies every migration
containing "RolePermission", in filename order, on a database that already
has all of them. 20260415200000_permission_aware_rls.sql is one of those, and
it drops the permissive JournalEntry / JournalLine write policies and
recreates them AS RESTRICTIVE. Replayed after 20260922100000, it undoes the
repair. A freshly reset, freshly seeded preview therefore sat with
rls_restrictive_only_commands() returning 7 rows — the exact state the
first audit found.
Fixed in this change: the seed re-applies 20260922100000 last and then asserts
rls_restrictive_only_commands() is empty, aborting the seed if it is not.
Anyone reproducing an "RLS denies everything" bug on a seeded preview should
check this first.
R14 — A group rate sheet silently switches revenue off (P1)
Probed on a throwaway group. POST /sales/bookings reads
skipLegacyBookingJournals = Boolean(groupPricingRow?.pricing): if the group
has a rate sheet, the booking posts no revenue journal at all, by design —
revenue is meant to arrive when a GroupInvoice is issued. Measured:
- booking created by B2B_MANAGER: succeeds (no escalation — it posts nothing so there is nothing to be refused);
- revenue journals for that booking: 0;
- and the group invoice that is supposed to carry the revenue bills the rate-sheet computation — ₹98,672.65 a head — not the ₹1,15,000 that pilgrim was actually sold.
So on a departure like this one, setting a rate sheet loses the sale twice: the booking posts nothing, and the invoice that would post something bills the wrong number. Nothing warns the user; the booking looks completely normal. This is a large part of why the first run's ledger was empty, and the first audit read it as D1 alone.
11. What the business tracks that still has nowhere to live
Re-checked against the fixes. Every one of these was raised above and is still true; the notes say what the rerun added.
| # | Fact | Still true? |
|---|---|---|
| G1 | F.O.C seats — "20 seats, 2 free" | Yes. focSeats: 2 and focSeats: 1 were sent on the two blocks; both stored 0. Confirmed from the database, not from a 405. |
| G2 | The company's own group code 29Aug-19D-6E-A |
Yes. Generated UMR-29AUG-A-19D; the real code lives in metadata. |
| G3 | One negotiated all-in price per pilgrim (₹93,000 – ₹1,40,000) | Yes, and worse than recorded. The rate sheet cannot hold it and setting one turns revenue off (R14). This run had to leave the rate sheet empty to get any books at all. |
| G4 | Discounts per pilgrim and per receipt | Yes. No field on Booking or BookingPassenger. |
| G5 | One sub-agent, one lump sum, two bookings | Yes. ABE ZUM ZUM's ₹9,15,000 covers two bookings across two blocks; Payment.bookingId takes one. Split by hand. |
| G6 | Per-pilgrim room type (a DOUBLE and a QUAD in one booking) | Yes. roomType is still Booking-level. |
| G7 | One hotel at several rates and durations | Yes — and now quantified. Two hotels needed five HotelInventory rows and five assignments. It does at least now work, and it recovered the ₹89,700 the first run lost. |
| G8 | Pilgrims with a single-part legal name | Yes. 11 of 27; a surname was invented for each. |
| G9 | A visa's end state | Yes, and the path is not what the first audit guessed. The real graph is NOT_STARTED → APPLIED → SENT_TO_EMBASSY → UNDER_PROCESS → ISSUED, and each hop needs a mandatory free-text reason — so recording "ISSUED" invents four timestamps and four reasons. |
| G10 | Receipts with no date, mode or instrument | Yes — a source-data gap, unchanged. |
| G11 | Two different retentions on one cancellation | Yes, and now measured. CancellationPolicySlab.chargePercent is numeric(5,2): the closest expression of ₹27,000 on ₹1,15,000 is 23.48% = ₹27,002. The exact figure needed a manager's refund override. The split between a ₹10,000 service fee (taxable) and ₹17,000 of pass-through visa recovery (not) still cannot be written down, and the retention sits in Package Revenue rather than a cancellation-fee income line. |
| G12 | Catering as a per-pilgrim, per-day service with adjustments | Yes, and Busy's narration shows how much is being lost: 22 P × 11.50 D × 29sr = 7337 − 348 (3d, 4 pax) − 261 (9d, 1 pax) = 6728 SR. All of that becomes one amount and one sentence. |
| G13 | The ₹17,500 service charge retained (₹700 × 25) | New. The workbook deducts it from group sales. There is no "retained by the company" line on a group, so it stays in revenue — ₹17,500 of the ₹44,500 gap in §8.1. |
| G14 | A token price for the group leader — one pilgrim is billed ₹1 | New. It rides through fine (it is why gross billing ends in …501), but nothing marks the row as a complimentary place, so headcount-based costing charges a full seat, bed and visa against a ₹1 sale. |
12. Recommended order of work, revised
- R1 — move a flown block's seat cost out of Stock-in-Hand. Mirror what
POST /hotels/assignmentsalready does, on seat assignment or at departure close. Until this lands, the P&L cannot be shown to anyone. - R2 — move the four browser-side postings (booking, block, hotel, group
expense) into
SECURITY DEFINERfunctions gated on the business permission, the waycreate_bookingandrecord_paymentare. This is one change that removes 21 of the 22 escalations and R3 with them. - R14, R7 — the two silent no-ops: a rate sheet that switches revenue off,
and a group expense that posts nothing without a
sourceAccountId. Both look like success to the operator. - R4 — give the ACCOUNTANT
finance.journals.approve, or stop the cancellation approver from being the maker of the reversal. - R5, R6 — reconcile the group cost report with the ledger, and post group expenses to the account their category names.
- R8, R9 — write a
SeatReleasewhen a pilgrim's seat comes back, so the airline penalty can be filed against the pilgrim and reach the departure. - G1, G3, G4, G6, G13, G14 — the data-model gaps that touch money: FOC seats, per-pilgrim pricing, discounts, per-pilgrim room type, retained service charge, complimentary places.
- [CA] — three questions for the Chartered Accountant, sharpened by the Busy ledger: (a) 5% on gross vs 18% on margin, still unanswered and still answered by accident; (b) whether the ₹17,000 of visa charges retained on a cancellation is revenue or a cost recovery; (c) the ₹100 a head on both air and visa in Busy that neither operations sheet explains (B1, B2).
Third run — after the posting moved into the database (19 Sep 2026)
Same departure, same script, same workbooks. Rebuilt from all 316 migrations
on fix/posting-in-db-functions, seeded, emptied of demo business data, and
replayed:
node scripts/dev/replay-group.mjs --input <group.json> \
--app http://127.0.0.1:5280 --password '<preview password>' \
--log /tmp/scratch/replay-after.json
Escalations: 22 → 1
| Route | Before | After | Permission it now demands |
|---|---|---|---|
POST /groups/:id/misc-expenses |
8 | 0 | groups.edit (create_group_misc_expense) |
POST /sales/bookings |
6 | 0 | bookings.create (post_booking_revenue) |
POST /hotels |
5 | 0 | hotels.create (create_hotel_inventory) |
POST /inventory/quota-blocks |
2 | 0 | inventory.create (create_quota_block) |
POST /cancellation-policies |
1 | 1 | admin.edit — unchanged, and not a posting |
| Total | 22 | 1 |
The one that remains is the one that was never about posting: a sales manager
cannot write a cancellation policy because that route is gated on admin.edit.
The other 21 are gone. An operations executive bought both blocks, created all
five hotel rows, made every hotel assignment and entered all eight group
expenses; the ticket desk bought inventory; sales and B2B took all six
bookings — each posting their own money, none of them holding finance.create.
PostingFailure came back { open: 0, rows: [] }. The one row the second
run produced — R3, ₹11,440 of Madinah hotel cost stuck in Stock-in-Hand because
operations could not post the consumption entry — cannot happen now: the
assignment and its entry are one transaction.
One workaround inside the script was retired with it. Run 2 could only let
operations make the smallest of the five hotel assignments, so that the
failure was on the record with a real payload; the other four were hard-coded
to a super-admin, because otherwise the whole ₹5,28,060 of hotel cost would
have stayed on the balance sheet and there would have been no books to read.
Those four were never counted as escalations — the script chose the account up
front rather than after a refusal — so they do not appear in the 22. They are
gone anyway: scripts/dev/replay-group.mjs now assigns all five as
operations, which is what happens in the office.
One more cause, found by running this
Moving the journal into the database got the operations manager one step further and then stopped at a second refusal wearing the same clothes:
That is PostgREST's way of reporting a refused write to a caller who asked for
.single(). It turned out to be two causes wearing the same message, both
of them "the first time only", which is exactly the shape of bug that hides
until a new supplier or a new environment appears:
- The ledger head. Buying the first block from a supplier has to create
1310 Stock-in-Hand and that supplier's own
SUP-…payable, andAccountINSERT is not open to operations either. Fixed in20260922120300_posting_accounts.sql:fin_ensure_posting_accountcreates it as the system for anyone holding a permission whose work has to post, returns an existing account untouched, and never restates one. - Finance Settings.
ensureSharedFinanceHeadLinks()links the heads it resolved back intoFinanceConfig, which needsfinance.config.edit— a right operations does not have and should not need in order to buy seats. The link is a convenience for the settings screen, not part of any posting, so it is now best-effort.
Both were found only by running this, on a database rebuilt from nothing. The second block had been succeeding all along, because the first one — escalated to a super-admin — had already done the first-time work.
R4 — the reversal nobody could approve
The ₹88,000 credit posted approved, with no human maker, and the CEO was not involved:
| Second run | Third run | |
|---|---|---|
| Cancellation credit | pending for ever until the CEO was brought in | approved on posting |
| Its maker | the finance manager who approved the cancellation | none — the function |
| Vouchers needing the CEO | 2 | 1 |
The one voucher that still needs the CEO is an airline_cancellation_filing
the finance manager genuinely created themselves, correctly refused to them by
ACC-020. That is the ordinary consequence of finance.journals.approve being
held by four roles and not by the ACCOUNTANT — an owner decision
(20260919100200_finance_role_bundles.sql), not a deadlock, and not this
change's to make.
What this run did not change
Everything in §10 other than R2, R3 and R4 is unchanged and still true — including R1 (a flown block's cost never leaving Stock-in-Hand), R5, R6, R7, R8, R9 and R14, and every gap in §11. The 15 gaps this run reported are the same 15.
Third run (19 Sep 2026) — the whole departure again, on integration/ops-upgrade
The section above is a branch check: one change (fix/posting-in-db-functions),
316 migrations, and only the questions that change could answer. This is the
whole test again — the same 27 pilgrims, the same workbooks, the same Busy
ledger — against integration/ops-upgrade as it now stands, 320 migrations,
with every posting fix merged.
supabase db reset --workdir <sbint> # 320 migrations
node scripts/dev/seed-demo.mjs # logins only
psql -f scripts/dev/purge-business-data.sql # empty the demo business data
node scripts/dev/replay-group.mjs --input <group.json> \
--app http://127.0.0.1:5180 --log <run>.json
Run three times end to end from a rebuilt database. Every figure below was identical on all three.
Verdict
No. The general ledger is missing ₹13,42,260 — the entire flown cost of the
air this departure sold — and it is missing it silently. Every seat
assignment tried to post Dr 5100 / Cr 1310 and every one of the 27 was
refused by row-level security; the refusals were queued rather than shown, and
the queue's new Retry button cannot clear a single one. The group's own cost
report still reads ₹25,81,946 and a margin of ₹2,21,555, which is close to the
truth; the company profit and loss reads ₹12,92,921 of profit on the same
departure, which is not. Two books, and the reconciliation block that was
supposed to explain the difference reports ₹18,78,920 unexplained.
The parts that were fixed are genuinely fixed, and they are not small: revenue posts for what was billed, expenses reach the head their category names, the cancellation is correct to the rupee, GST can finally be switched off per departure, and inventory drift is zero. But the one number the owner would ask for first — what did this departure cost — is wrong in the ledger by more than half.
13. Expected vs actual, against Busy and the workbooks
Busy account "EXP AUG 29 UMR GRP 19D ( 26-27 )", closing ₹25,87,546 Dr.
"Ledger" is the trial balance the app itself prints; "Cost report" is
GET /groups/:id/expenses and /financial-summary.
13.1 Money in
| Line | Busy | Workbook | System ledger | System cost report |
|---|---|---|---|---|
| Billed, 27 pilgrims | — (cost account) | ₹28,91,501 | ₹28,91,501 Cr 4000 | ₹28,91,501 |
| Receipts banked | — | ₹16,35,000 | ₹16,35,000 Dr 1010 | ₹16,35,000 |
| Cancellation credit note | SR/9 −₹44,210 (air) |
₹1,15,000 reversed | ₹88,000 Dr 4000 | ₹88,000 |
| Receivable | — | ₹11,24,001 | ₹11,68,501 (five agent ledgers) | ₹11,68,501 |
Billed and banked agree to the rupee with the workbook — the first time in three runs that both sides of the sale are right at once. The receivable differs by ₹44,500 because the two treat the cancellation differently: the workbook strikes the whole ₹1,15,000 sale out of receivables and then deducts ₹17,500 of "service charges retained", while the system keeps the sale and credits ₹88,000 of it, leaving the ₹27,000 the policy retains inside revenue. The system's shape is the defensible one. The ₹17,500 in the workbook is a third number for the same retention — the departure record says ₹10,000 + ₹17,000, Busy implies ₹8,900, the summary sheet says ₹17,500 — and no software change will settle which is right.
13.2 Cost, line by line
| Line | Busy | Workbook | System ledger | Where it went |
|---|---|---|---|---|
| Air, flown | ₹13,53,760 | ₹13,50,860 | ₹0 | still in 1310 Stock-in-Hand |
| Air, seats never sold | — | ₹1,99,440 uncharged | ₹1,55,030 | 5150, pending approval |
| Umrah visa, 27 pax | ₹3,07,800 | ₹3,05,100 | ₹3,05,100 | 5500 ✓ |
| Ground transport | ₹64,750 | ₹64,750 | ₹1,27,032 combined | 5400 |
| BRN, 3 pax | ₹10,217 | ₹10,217 | (inside 5400) | 5400 |
| Incidentals ("on a/c Hameed") | ₹52,065 | ₹52,065 | (inside 5400) | 5400 |
| Hotel Makkah | ₹2,73,780 | ₹2,73,780 | ₹5,28,060 combined | 5200 ✓ |
| Hotel Madinah | ₹2,54,280 | ₹2,54,280 | (inside 5200) | 5200 |
| Food Makkah | ₹1,74,928 | ₹1,74,928 | ₹5,41,788 | 5300 — counted twice |
| Food Madinah | ₹95,966 | ₹89,726 | (inside 5300) | 5300 |
| Ticket cancellation charge | inside SR/9 |
— | ₹8,600 | 6200 ✓ |
| Reconciliation plug | — | ₹5,400 | — | Busy has none either |
| Total cost | ₹25,87,546 | ₹25,81,106 | ₹15,10,580 |
Reading down the "where it went" column is the whole story of this run. Five of the eleven lines are now exactly right and in the right account — visa, both hotels, the ticket-cancellation charge, and the transport/BRN/incidentals group, which together match Busy to the rupee at ₹1,27,032. One line is double. One line — the largest — is absent.
Air. Busy and the workbook both charge this departure about ₹13.5 lakh of tickets. The ledger charges nothing. The 1310 Stock-in-Hand account closes at ₹14,97,290 Dr on approved vouchers (₹13,42,260 once the two write-off vouchers are approved), against a departure that flew on 29 August and came home on 16 September. See §15.1.
Food. ₹2,70,894 of catering is in 5300 twice — once as the supplier's bill and once as the departure's cost — because the business genuinely has to record it in both places (gap 12, unchanged since run 1) and, since R7 was fixed, both now post. The group cost report counts it once; the company P&L counts it twice. See §15.3.
13.3 GST
| Run 2 | Run 3 | |
|---|---|---|
FinanceConfig.gstEnabled |
true, 5% | true, 5% (unchanged) |
| Settable on the departure | no | yes — defaultGstEnabled / defaultGstRate |
| Bookings created with GST | on, per-booking override needed | off, inherited from the departure |
| Output GST on this group | as configured | ₹0 |
R12 is fixed. The departure was created with defaultGstEnabled: false and no
booking named GST at all; all six came out with GST off, while the rate-sheet
probe booking on a throwaway group with no default set still inherited the
company's 5%. That is exactly the behaviour asked for, and it is the difference
between billing ₹28,91,501 and billing ₹30,36,076 of tax nobody collected.
13.4 Margin, and both unsold-seat treatments
| Amount | |
|---|---|
| Workbook gross profit | ₹1,77,895 |
| Workbook profit if the whole block is charged | −₹21,545 |
Cost report margin, unsoldSeatTreatment: 'central' |
₹2,21,555 |
Cost report margin, unsoldSeatTreatment: 'departure' |
₹2,21,555 |
| What the departure treatment implies in the ledger | ₹66,525 |
| Company P&L profit on the same departure | ₹12,92,921 |
The write-off changes the margin by nothing. Both seats were written off
successfully — 2 on P6Q8QI (₹1,02,020) and 1 on H796KF (₹53,010),
₹1,55,030 in all, correctly tagged to the departure, correctly refused when
offered with no reason. But computeGroupCostBreakdown() builds the cost
report from operational records and a write-off is not one, so the margin was
₹2,21,555 before the write-off and ₹2,21,555 after it, under either setting.
The only place the ₹1,55,030 appears is as a line in the reconciliation block
saying the two books differ by it. Nothing in the product computes the
₹66,525 that the departure setting is supposed to mean.
The ₹1,55,030 is also not the workbook's ₹1,99,440. The system writes off
the block's own availableSeats — 3 seats — while the workbook's gap is the
difference between 30 seats bought and the 26 charged, which includes the 3
free-of-charge seats the system still cannot record (gap 2, gap 3).
14. Escalations: 1 claimed, 3 measured, and 27 that never surface
| Route | Run 2 | Fix team | Run 3 | Why |
|---|---|---|---|---|
POST /groups/:id/misc-expenses |
8 | 0 | 0 | create_group_misc_expense |
POST /sales/bookings |
6 | 0 | 0 | post_booking_revenue |
POST /hotels |
5 | 0 | 0 | create_hotel_inventory |
POST /inventory/quota-blocks |
2 | 0 | 0 | create_quota_block |
POST /cancellation-policies |
1 | 1 | 1 | admin.edit, never a posting |
POST …/write-off-unsold |
n/a | not tested | 2 | new; not in a SECURITY DEFINER function |
| Escalations | 22 | 1 | 3 | |
| Seat assignments refused silently | n/a | not tested | 27 | same cause, never shown to anyone |
The fix team's 1 is right for the four routes they moved. It is not the whole count, because two things were merged in the same batch and neither went through the same door:
- The unsold-seat write-off (
20260922130100) posts withcreateJournalWithLines()from the browser, so it needsfinance.create. The operations manager holdsinventory.writeoff.approve— the permission the route itself demands — and is then refused by theJournalEntryinsert policy. Both blocks had to be escalated to a super admin. - Seat consumption (commit
d478e9f, "a seat flown is a seat costed", FIN-035) does the same, 27 times, and swallows the refusal intoPostingFailureinstead of returning it. Nobody is refused anything on screen. The seats are assigned, the block counters move, and the cost quietly does not exist. This is worse than an escalation: an escalation gets someone's attention.
Counting only what a user sees, the answer is 3. Counting what the same missing right actually cost the books, it is 30.
15. What is still wrong, ranked by the money at stake
T1 — A flown seat's cost never leaves Stock-in-Hand (P0, ₹13,42,260)
The headline fix of this branch does not work for the role that does the work.
src/lib/api.ts:8264 posts the seat-consumption voucher with
createJournalWithLines() — a browser insert into JournalEntry, whose only
INSERT policy is auth_user_has_permission('finance.create'). Operations does
not hold it and should not: that is the entire premise of moving posting into
the database. All 27 assignments were refused with
and queued by recordPostingFailure() at src/lib/api.ts:8306.
The retry cannot clear them. retry_posting_failure()
(supabase/migrations/20260922120200_posting_failure_retry.sql:81) re-posts
payload.retry.vouchers, and the payload written at src/lib/api.ts:8317
carries a description and the source facts but no voucher blob, so all 27
retries return "This entry did not record the accounting lines it tried to
post, so it cannot be retried automatically." The queue went 27 → 27.
Fix: route this posting through fin_post_system_voucher gated on
inventory.edit the way create_hotel_assignment already is, in the same
transaction as the assignment. Same change for the write-off at
src/lib/api.ts:29345.
T2 — The group P&L and the ledger still do not reconcile (P0, ₹18,78,920)
"reconciliation": { "reportTotal": 2581946, "ledgerTotal": 703026,
"difference": 1878920, "reconciles": false,
"unexplained": 1878920 }
The block is honest about failing — it names the 27 unposted entries and the
₹1,55,030 write-off — but the two named reasons are ±₹1,55,030 and cancel out,
leaving the entire difference unexplained. computeGroupLedgerReconciliation()
(src/lib/api.ts:15727) can only compare against what carries the departure
tag, and almost nothing does:
| Voucher type | Tagged / total |
|---|---|
group_expense |
10 / 12 |
unsold_seat_write_off |
2 / 2 |
booking (revenue) |
0 / 7 |
hotel_assignment_consumption |
0 / 5 |
hotel_purchase |
0 / 5 |
booking_cancellation |
0 / 1 |
airline_cancellation_filing |
0 / 1 |
quota_block |
0 / 2 |
| Total | 12 / 39 |
20260922140000_system_voucher_group_tag.sql added the column to the INSERT
correctly, and SystemVoucher.groupId exists at src/lib/api.ts:2798. It is
the callers that never set it: of 47 voucher literals in src/lib/api.ts,
three do. The ones that matter here are
src/lib/api.ts:25797 (hotel_assignment_consumption),
src/lib/api.ts:6533 (booking),
src/lib/api.ts:9673 (booking_cancellation) and
src/lib/api.ts:29876 (airline_cancellation_filing).
T3 — Catering is in the company P&L twice (P1, ₹2,70,894)
R7 said a group expense with no payer posted nothing. It now credits Sundry
Creditors — correct in itself, and the cause of a new error. The business must
record a bought-in cost twice, because computeGroupCostBreakdown() reads
GroupExpense and never SupplierTransaction (gap 12): once as the supplier's
bill, once as the departure's cost. Before R7 the second one silently posted
nothing, which accidentally kept the P&L right. Now both post:
5300 Gowhar Makkah catering · SAR 6728 @ 26 (Busy JR/453) 174,928
5300 Group expense — Gowhar Makkah catering … payer not stated 174,928
5300 Muneer Ah Parray Madinah catering · SAR 3691 @ 26 (Busy JR/454) 95,966
5300 Group expense — Muneer Ah Parray Madinah … payer not stated 95,966
Fix the duplication, not the posting: make a supplier bill against a departure be the departure's cost.
T4 — The write-off cannot be done by the role that holds the permission (P1, ₹1,55,030)
src/lib/api.ts:29345, cause as T1. The route's own guard
(requirePermission('inventory.writeoff.approve')) passes and the RLS policy
then refuses. Everything else about this route is right: it computes the seats
from the block's own availability, refuses without a reason, tags the
departure, is idempotent on (blockId, groupId), and holds the loss centrally
for a shared block.
T5 — A departure's vouchers cannot be listed anywhere (P2)
No journal-reading route selects groupId —
src/lib/api.ts:33338 and src/lib/api.ts:33745 both stop at
referenceType. The tag exists in the database and drives one total in one
reconciliation block; there is no screen, list or export on which a departure's
own vouchers can be seen. An auditor asking "show me this group's ledger"
still cannot be answered.
T6 — The unposted queue's Retry does not fit its most common occupant (P2)
Covered in T1. The retry works — supabase/tests/posting_failures.sql passes
14 assertions — for failures that stored a voucher blob. The one operation that
fails on a real departure stores none. Either recordPostingFailure always
stores the vouchers it tried, or the queue should say plainly which rows it can
never retry.
T7 — scripts/dev/seed-demo.mjs undid two more migrations (P1, process — fixed here)
R13 again, twice more, both found by running this:
agents.delete → IT_ADMIN.PERMISSION_BACKFILLhard-coded the grant when20260430140000_restrict_agent_delete_to_it_admin.sqlwas the rule.20260919100200_finance_role_bundles.sqllater made it super-admin-only and revoked it; the backfill runs last and put it straight back, failing ACC-012 on every seeded stack. The entry is gone and the backfill now refuses to grant anything infin_super_admin_only_permissions().- A resurrected
record_paymentoverload. The grant replay re-runs old migrations, and one of them re-createsrecord_paymentwith its pre-valueDate13-argument signature.CREATE OR REPLACEdoes not replace a different argument list — it adds one. Every call that did not name the 14th argument then failed "function public.record_payment(…) is not unique": on a seeded preview no receipt could be recorded at all. The seed now snapshotspg_procbefore the replay and drops any signature that appears after it.
The seed also asserts ACC-012 itself now, so the next stale grant is caught by the thing that caused it.
T8 — The database suites only pass on a pristine database (P2, process)
23 of 23 pass on a database built from the 320 migrations alone. Eight fail
on the same migrations once seed-demo.mjs has run — the demo rows become
part of assertions written over whole tables ("no archived block on the
utilisation board", "no active user without MFA"). Nothing is wrong with the
product; the runbook did not say so. docs/operations/testing.md now does.
16. What the fixes demonstrably repaired
Measured, not assumed — each of these was a defect in run 2 and is not one now.
| Was | Now |
|---|---|
| R2 — 22 escalations; operations could post nothing | 4 routes at 0; ops bought both blocks, created all five hotel rows, made all five assignments and entered all eight expenses |
| R3 — ₹11,440 of hotel cost stuck in stock, 1 queued failure | All five assignments posted, ₹5,28,060 out of stock, 0 failures |
| R4 — cancellation reversal nobody could approve | Reversal posts approved, no human maker, 1 voucher to the CEO instead of 2 |
| R6 — every expense to 5000 Purchase A/c | 5300 food, 5400 transport, 5500 visa — and 5400 matches Busy to the rupee |
| R7 — expense with no payer posted nothing | Posts, crediting 2100 Sundry Creditors (and see T3) |
| R12 — GST on by default, not settable per departure | defaultGstEnabled on the departure, inherited by every booking |
| R14 — a rate sheet silently switched revenue off | A priced booking posts revenue either way; the probe booking posted 1 revenue voucher |
| FIN-037 — unsold seats had no route at all | ₹1,55,030 written off to 5150, reason required, departure tagged, idempotent |
And the things that were right in run 2 are still right: the trial balance
balances at ₹82,47,851 across 20 accounts; inventory drift is 0; the
audit trail carries 1,089 rows across 9 real actors (ops manager 105, visa
officer 108, finance manager 233 — not one super-admin doing everything);
create_booking still rejects a passenger without both name parts; the cashier
still cannot verify their own receipt.
The cancellation, checked line by line
Policy ₹10,000 + ₹17,000 visa on a ₹1,15,000 price. All five checks pass:
| Check | Result |
|---|---|
| Seat returns to the block | H796KF available 1 → 2, allocated 9 → 8 ✓ |
| Credit note | 1 reversal voucher, Dr 4000 ₹88,000, posted approved ✓ |
| Receivable reduced | booking balance ₹3,23,500 → ₹2,35,500 ✓ |
| Refund cap | cash refund of ₹88,000 against ₹0 received refused — "exceeds the refundable balance of 0 (PRC-030)" ✓ |
| ₹27,000 retained | ₹1,15,000 − ₹88,000 stays in 4000 ✓ — but as package revenue, not as a cancellation fee (gap 14) |
The slab can only hold 23.48%, which computes ₹27,002; the approval rounds the refund to ₹88,000 so the retention lands on ₹27,000 exactly. Right answer, reached by rounding rather than by saying what the business means (gap 1).
17. What the business still cannot record at all
Unchanged from run 2 — 15 gaps, the same 15. The ones that move money:
- Free-of-charge seats. 3 of the 30 seats bought were free.
focSeatsis still stored asnull, which is why the write-off is ₹1,55,030 and the workbook's gap is ₹1,99,440. - Per-pilgrim pricing. ₹93,000 to ₹1,40,000 on one departure. A group rate sheet is one four-component card, so this departure cannot have one.
- A retained service charge as its own thing. ₹10,000 of fee and ₹17,000 of pass-through visa cost are taxed differently and are one percentage here.
- The operator's own group code.
29Aug-19D-6E-A— on every workbook, receipt and WhatsApp message — lives inmetadata. - A person carrying group cash. Busy's five "on a/c Hameed Makkah" lines went in as five expenses to Sundry Creditors. Hameed has no balance.
- Per-pilgrim room type, hotel rate/date variation (5 lines, 5 rows), a lump receipt across two bookings, and a single-part legal name — 11 of 27 pilgrims here — which staff must split by hand.
18. Recommended order of work
- T1 — seat consumption through
fin_post_system_voucher, in the assignment's transaction. One change; ₹13,42,260; without it there is no ledger worth reading. - T4 — the write-off through the same door. Same change, same file.
- T2 — set
groupIdon the ~10 voucher builders a departure is made of. Mechanical, and it is what makes the reconciliation block mean anything. - T3 — one record for a supplier bill against a departure, not two.
- T6 — store the voucher blob on every posting failure, or say which rows can never be retried.
- T5 — select
groupIdin the journal reads and give a departure a ledger screen. - Then the run-2 list that is still untouched: R5 (now T2), R8, R9, R10, R11, and the money-touching gaps in §17.
Nothing here should go to the owner as a profit figure until T1 lands.
Fourth run — after the posting became one path (19 Sep 2026)
Same departure, same workbooks, same Busy ledger. Rebuilt from all 322
migrations on fix/postings-one-path, seeded, emptied of demo business data,
and replayed:
supabase db reset --workdir <preview> # 322 migrations
scripts/db-test.sh # against the pristine database
node scripts/dev/seed-demo.mjs # logins + demo business data only
psql -f scripts/dev/purge-business-data.sql
node scripts/dev/replay-group.mjs --input <group.json> --app http://127.0.0.1:5380
Verdict
Yes, for the question this round was asked. The flown cost of the air is in the books — ₹13,42,260 in 5100 Airline Block Purchases, on 27 vouchers, each tagged to the departure that took the seat. Stock-in-Hand closes at nil once the two unsold-seat write-offs are approved. The unposted-entries queue is empty. The group cost report and the general ledger agree on ₹25,81,946 with nothing unexplained. One escalation remains in the whole departure and it is not a posting.
Set against Busy's ₹25,87,546, the departure's cost is ₹5,600 short, and every rupee of the difference was already named in run 3 and is in the source data, not the software: ₹2,900 of per-ticket rate and ₹2,700 of per-visa rate that Busy's amounts carry and the operations sheets do not (B1, B2). The margin is ₹2,21,555 against Busy's ₹1,71,455, and the ₹50,100 gap is the same ₹5,600 plus the ₹27,000 retention the system keeps as revenue and the ₹17,500 service charge it has no field for — all three already documented, none of them new, none of them a posting defect.
19. The five numbers
| Measure | Run 3 | Run 4 | Target |
|---|---|---|---|
| Permission refusals on operational work | 30 (3 seen, 27 silent) | 0 | 0 |
PostingFailure open |
27 | 0 | 0 |
| Stock-in-Hand for the departed group | ₹14,97,290 | ₹0 | 0 |
Group reconciliation unexplained |
₹18,78,920 | ₹0 | 0 |
| Departure cost — ledger / report | ₹15,10,580 / ₹25,81,946 | ₹25,81,946 / ₹25,81,946 | Busy ₹25,87,546 |
| Departure margin | ₹12,92,921 (company P&L) | ₹2,21,555 | Busy ₹1,71,455 |
On Stock-in-Hand, precisely. Counting approved vouchers only it closes at
₹1,55,030, because the two unsold-seat write-offs are still waiting for their
second approver — which is FIN-032 working, not a hole. Counting every voucher
that is not rejected it is ₹0.00: every seat this departure bought has left
stock, either as a cost of sale or as a write-off. The reconciliation names the
₹1,55,030 explicitly as vouchers_pending_approval rather than leaving it to be
noticed.
Escalations: 1, and it is run 3's one that was never about posting — a
sales manager cannot write a cancellation policy, because that route is gated
on admin.edit. The unsold-seat write-off, which needed a super admin twice in
run 3, was done by the operations manager who holds its permission.
Every expense head is fully attributed: 5100 ₹13,42,260, 5200 ₹5,28,060, 5300 ₹2,70,894, 5400 ₹1,27,032, 5500 ₹3,05,100 and 6200 ₹8,600 — all of it tagged to the departure, none of it anywhere else. The trial balance balances at ₹94,25,237 a side.
The database suites pass 24 of 24 on a pristine stack. On the same stack
after the seed and the replay, 4 fail — audit_trail, bookings_paging,
incidents, role_dashboards — which is T8 and is down from 8; each is an
assertion written over a whole table that demo rows then join.
20. Every voucher-building site in src/lib/api.ts
The reason this defect survived three rounds of fixing is that nobody could see
the whole set. It is 48 call sites, they look identical, and nothing said which
of them was entitled to post from the browser. So here it is, and
src/test/posting.one-path.test.ts now fails the build when it changes.
20.1 Moved to the one operational door — 27 sites
Each goes through fin_post_operational_vouchers(operation, …), which checks
the permission the route already demanded. Nothing was granted to anybody.
| Voucher | Route | Operation | Permission |
|---|---|---|---|
flight_seat_consumption |
POST /sales/bookings/:b/passengers/:p/flights |
flight_seat_consumption |
bookings.edit |
flight_seat_consumption_reversal |
DELETE the same, and passenger cancellation |
flight_seat_consumption_reversal |
bookings.edit, bookings.cancel.approve |
booking_adjustment (passenger transfer) |
POST /sales/bookings/:id/passengers/:id/transfer |
booking_adjustment |
booking.transfer |
booking_adjustment (amount edit) |
PATCH /sales/bookings/:id |
booking_adjustment |
bookings.edit |
booking_adjustment_commission ×2 (up, down) |
PATCH /sales/bookings/:id |
booking_adjustment |
bookings.edit |
booking_adjustment (receivable reassigned) |
PATCH /sales/bookings/:id |
booking_adjustment |
bookings.edit |
booking_commission_reassignment |
PATCH /sales/bookings/:id |
booking_adjustment |
bookings.edit |
booking_adjustment_commission (new agent) |
PATCH /sales/bookings/:id |
booking_adjustment |
bookings.edit |
group_invoice_credit_note |
passenger / booking cancellation approval | group_invoice_credit_note |
bookings.cancel.approve |
group_expense (delete reversal) |
DELETE /groups/:id/misc-expenses/:id |
group_expense_reversal |
groups.edit |
hotel_purchase_reversal |
PATCH /hotels/:id |
hotel_purchase_reversal |
hotels.edit |
hotel_purchase (re-post at the new cost) |
PATCH /hotels/:id |
hotel_purchase |
hotels.edit |
hotel_purchase (delete reversal) |
DELETE /hotels/:id |
hotel_purchase_reversal |
hotels.delete |
hotel_assignment_consumption_reversal |
DELETE /hotels/assignments/:id |
hotel_assignment_consumption_reversal |
hotels.edit |
ground_transfer_assignment |
POST /inventory/ground-transfers/:id/assign |
ground_transfer_assignment |
inventory.edit |
ground_transfer_assignment (delete reversal) |
DELETE the same |
ground_transfer_assignment |
inventory.edit |
unsold_seat_write_off |
POST /inventory/quota-blocks/:id/write-off-unsold |
unsold_seat_write_off |
inventory.writeoff.approve |
quota_block_adjustment |
PATCH /inventory/quota-blocks/:id |
quota_block_adjustment |
inventory.edit |
fit_adjustment |
PATCH /inventory/fit/:id |
fit_adjustment |
inventory.edit |
food_assignment_consumption |
POST /food/assignments |
food_assignment_consumption |
food.edit |
food_assignment_consumption_reversal |
DELETE /food/assignments/:id |
food_assignment_consumption_reversal |
food.edit |
b2b_third_party_sale + _cogs |
POST /inventory/third-party-sale |
b2b_third_party_sale |
inventory.edit |
food_purchase |
POST /food |
food_purchase |
food.create |
food_purchase (delete reversal) |
DELETE /food/:id |
food_purchase_reversal |
food.delete |
Plus the seven that already went through a SECURITY DEFINER create in
20260922120000 — booking revenue, group expense, hotel inventory, hotel
assignment, quota block, FIT, and the cancellation reversal — which are
unchanged.
20.2 Left on the browser path — 21 sites, and why
createJournalWithLines posts as the acting user, so JournalEntry INSERT is
checked against their finance.create. For all of these that is the correct
gate: it is the permission the route already demands, and the work is finance's
own.
| Voucher | Route | Gate |
|---|---|---|
| a manual voucher | POST /finance/journals |
finance.create |
| a voucher reversal | POST /finance/journals/:id/reverse |
finance.journals.reverse |
| the remainder-reversal helper | (internal, finance paths) | finance.journals.reverse |
| the B2B decision-journal fallback | POST /finance/b2b-cancellations/:id/… |
finance.cancellations.approve_b2b |
customer_on_account |
POST /finance/vouchers/customer-on-account |
finance.edit |
agent_on_account |
POST /finance/vouchers/agent-on-account |
finance.create |
supplier_refund_receipt |
POST /finance/vouchers/supplier-refund |
finance.create |
| a manual credit / debit note | POST /finance/vouchers/credit-note |
finance.create |
contra_transfer |
POST /accounts/:id/contra |
finance.create |
settlement_cross_ledger |
POST /finance/settlements/any-to-any |
finance.create |
| a hand-entered account transaction | POST /accounts/:id/transactions |
finance.create |
quota_block ×2 (payment, cancellation refund) |
POST /inventory/quota-blocks/:id/finance-events |
finance.create |
quota_block ×2 (ledger rebuild) |
POST /finance/ledger/rebuild |
finance.ledger.rebuild |
fit |
POST /inventory/fit/:id/finance-events |
finance.create |
airline_cancellation_filing |
both finance-events routes |
finance.create |
airline_cancellation_approval / _rejection |
POST /finance/airline-cancellations/:id/… |
finance.cancellations.approve_airline |
opening_stock / closing_stock |
POST /finance/stock-adjustment |
finance.create |
fx_revaluation |
POST /finance/fx-revaluation |
finance.edit |
One of these is a finding rather than a design. The airline cancellation
filing is ticketing work — the ticket desk owns blocks and PNRs (AIR §30) — but
it shares POST /inventory/quota-blocks/:id/finance-events with paying the
airline, and that one route is gated once, on finance.create. Moving the
filing to the operational door would have narrowed it, because the finance
manager who files it holds no inventory.edit. One gate cannot be right for
both halves of that route; splitting it is a permission change and was left for
whoever makes those.
21. What the four defects look like now
T1 — the flown seat (was ₹13,42,260, silently missing)
27 seat assignments, 27 vouchers, Dr 5100 / Cr 1310 at the contract rate, each
tagged to the departure without any caller setting the tag. The ticket desk
posted every one of them holding bookings.edit and no finance right. Nothing
was queued, so nothing needed retrying — and if something had been, the queued
row now carries the vouchers it tried, which is what T6 asked for.
T2 — the departure tag (was 12 of 39 vouchers)
| Voucher type | Run 3 | Run 4 |
|---|---|---|
flight_seat_consumption |
0 / 27 | 27 / 27 |
booking |
0 / 7 | 6 / 6 |
hotel_assignment_consumption |
0 / 5 | 5 / 5 |
group_expense |
10 / 12 | 10 / 10 |
booking_cancellation |
0 / 1 | 1 / 1 |
flight_seat_consumption_reversal |
— | 1 / 1 |
airline_cancellation_filing |
0 / 1 | 1 / 1 |
unsold_seat_write_off |
2 / 2 | 2 / 2 |
hotel_purchase |
0 / 5 | 0 / 5 — correct: stock, bought before any departure |
quota_block |
0 / 2 | 0 / 2 — correct, same reason |
payment |
0 / 4 | 0 / 4 — correct: cash is a balance-sheet movement |
Nothing that can be tagged is untagged, and nothing that should not be is.
T3 — catering (was ₹2,70,894 twice)
₹2,70,894 in 5300, once. The departure's copy of the caterer's bill names the
bill (GroupExpense.supplierTransactionId), posts nothing, and tags the bill's
own voucher to the departure. A second expense claiming the same bill is
refused by a unique index.
The underlying awkwardness is untouched and is still gap 12: the operator types
the cost twice, because computeGroupCostBreakdown() reads GroupExpense and
never SupplierTransaction. Nothing stops them typing it twice without the
link. What is fixed is that the books no longer double when they do it right.
T4 — the unsold-seat write-off
₹1,55,030 written off on two blocks by the operations manager, who holds
inventory.writeoff.approve. Run 3 escalated both to a super admin.
T5 — a departure's ledger
GET /groups/:id/ledger answers "show me this departure's vouchers", and
/finance/journals carries groupId on every row and filters on ?groupId=.
22. What is still true
Everything in §15 other than T1–T6, and every gap in §17. In particular:
- R8 / R9 are not fixed. A cancelled pilgrim's seat still comes back without
a
SeatReleaserow, so the airline filing is still made against an anonymous "unallocated" seat and the ₹8,600 penalty still cannot be tied to the pilgrim who caused it. What changed is only that the money now lands on the right departure: the filing voucher uses the same sole-use-block rule the cost report already used, so the two books agree instead of differing by ₹8,600. - R4 is unchanged and is an owner decision. The ACCOUNTANT still cannot approve a voucher, so the finance manager is both the usual maker and one of only four approvers, and two vouchers went to the CEO.
- R5 is closed, R6, R7, R12 and R14 stay closed, and gap 12 (a bought-in cost recorded in two places) is still the reason T3 existed.
- The three source-data disagreements (B1 ₹2,900 of per-ticket rate, B2 ₹2,700 of per-visa rate, B4 the ₹5,400 workbook plug) are unchanged and are not software.
Fourth run (20 Sep 2026) — a second departure, 12 Aug 2026 (18 days, 6E)
Every run above is the same departure. This is a different one, chosen because it does something 29 Aug does not: it buys seats that are not part of an airline block.
The departure: 16 pilgrims, ₹18,05,000 billed, none cancelled, one IndiGo series block (13 seats, no free-of-charge seats) and three pilgrims who bought "FIT ROUND WAY, VISA, GROUND" — an individual ticket on their own PNR, a visa and ground services, and no hotel and no meals. Two hotels in Saudi riyals at SAR 26 to the rupee.
Reproduce with:
node scripts/dev/extract-group-workbook.mjs --dir "<workbook dir>" \
--group "12Aug-18D-6E-A" --out /tmp/g.json --summary /tmp/g.md
node scripts/dev/replay-group.mjs --input /tmp/g.json \
--app http://127.0.0.1:5480 --password '<preview password>' \
--log /tmp/replay.json
Verdict
Yes. The departure's cost came out at ₹16,51,274 against the owner's
Busy closing figure of ₹16,51,274, the cost report reconciled to the
general ledger with unexplained 0, Stock-in-Hand ended at ₹0.00, and
nothing sat in the unposted-entries queue. No step needed a super-admin.
| Measure | Result |
|---|---|
| Operational refusals | 1 |
| Escalations to a super-admin | 0 |
Open PostingFailure rows |
0 |
| Stock-in-Hand (1310) after departure | ₹0.00 |
Cost report vs ledger — unexplained |
₹0 |
| Departure cost — system | ₹16,51,274.01 |
| Departure cost — Busy | ₹16,51,274 |
| Margin — system | ₹1,53,726 |
| Gross profit — departure sheet | ₹1,42,526 |
The one refusal is not this departure's: it is the ACCOUNTANT being unable to
approve a journal voucher (finance.journals.approve is held by four roles
and not by them), the same owner decision §22 records under R4.
The ₹11,200 between the system's margin and the sheet's gross profit is the retained service charge — ₹700 a head over 16 pilgrims. The sheet nets it off its own sales line; the customers were billed the full ₹18,05,000 and the charge is the company's own. Giving it a ledger head of its own is separate work and is not done here.
What the block-only model could not express
Before this run the loader could only seat a pilgrim from an airline block.
Putting the three individual travellers on the block overstated its allocation
by three seats, and the block's ₹7,28,130 left ₹1,98,122 of the ₹9,26,252
of ticket cost the sheet charges with no inventory behind it. The extractor
reported that as unrecoveredSeatCost of −₹1,98,122 — which reads as a
surplus and was nothing of the kind.
Nowhere in the workbooks states what those seats cost. The block register does not carry them and the P&L folds them into one TICKET COST line. So the figure is derived, and the extractor shows its arithmetic rather than presenting it as a reading:
ticket cost charged 926251.998 less block purchase 728130 = 198122,
over 3 part-package traveller(s) = 66040.67 per individual seat
The derivation is only attempted where the sheet itself marks someone as travelling on their own ticket. On a departure where the ticket charge exceeds the block and nobody is marked, the gap stays a contradiction rather than becoming a seat nobody bought. 29 Aug, which has no part-package travellers, is unchanged: its ₹1,99,440 of purchased-and-unsold seats still reports as exactly that.
What the loader now does for a part-package traveller
- Buys FIT inventory through
POST /inventory/fit— the same supplier as the block, and a purchase voucher (Dr 1310 Stock-in-Hand / Cr the supplier's payable) posted insidecreate_fit_inventory, so the row and its money go in together. Bought by the operations manager oninventory.create. - Links it to the departure with
POST /groups/:id/flights(fitId), the way a block is linked. - Takes the booking with
needsHotelandneedsMealsfalse andneedsTicket,needsVisa,needsGroundPackagetrue, read off the sheet's own STATUS column (PAX-034). - Seats them from the FIT row, which posts the seat's consumption entry (Dr 5100 / Cr 1310) exactly as a block seat does.
- Gives them no room. Since PAX-034 the database refuses one, correctly, so the loader must not ask — and the room plan counts only the pilgrims who bought a room.
Result: block 13 seats / 13 allocated / 0 available; FIT 3 / 3 / 0; 16 visas issued; 13 pilgrims in 4 contracted rooms (4,3,3,3); 3 with no room at all.
What FIT purchasing still cannot express
GET /inventory/fitreturns neitheravailableSeatsnorallocatedSeats. The database maintains both by trigger and refuses over-allocation on them (INV-012), and the block list route returns them. A FIT row cannot be read back through the app to see whether its seats are taken.- There is no write-off for unsold FIT seats.
POST /inventory/quota-blocks/:id/write-off-unsoldexists (FIN-037) and has no FIT counterpart, thoughUnsoldSeatWriteOffalready carries afitIdcolumn. Individual seats bought and not flown would stay in Stock-in-Hand for ever — the exact defect FIN-037 was written to close. This departure sold all three of its FIT seats; one that did not would have no way out. - One PNR and one price for the whole row. An individual ticket is
individual: each traveller has their own PNR and their own fare.
FITInventoryholds onepnrand onepricePerSeat, so N individual tickets can only be entered as one row of N identical seats. This departure has two PNRs written on the sheet for three travellers, and only the first can be recorded. - A purchase total that does not divide into whole paise cannot be entered
as what it was. Both the purchase and each seat's consumption post from
pricePerSeat, so ₹1,98,122 over three seats has to be rounded to ₹66,040.67 and the books carry ₹1,98,122.01. There is no total-price entry. GET /inventory/fit/:id/finance-eventsis documented and not implemented (docs/api/inventory.md); the route answers 405. FIT also has noinitial_payment_adjustment,initial_payment_reversalorfull_cancellationevent, all of which a block has.
Two assumptions the workbook does not settle
Recorded by the loader before anything is created, so the owner can overturn one and have the load redone:
- Two rows below the numbered pilgrims carry a name but no serial number, ₹50,000 received and −₹50,000 discount. They are treated as outside the group and no booking is created for them. That is what makes the sheet's own ₹17,93,800 sales figure reconcile; counting them takes it past that.
- The customers are billed the full ₹18,05,000. The ₹11,200 of retained service charge is the company's own charge and is not deducted from what they owe.