Skip to content

07 · Finance, accounting and tax

Statutory rules are marked LAW. Rates and thresholds change — each LAW rule names the provision so it can be re-checked each financial year. This file is not tax advice; the company's Chartered Accountant confirms every OPEN item.

Revenue recognition

FIN-001 · Package money before travel is an advance

Status: DECIDED 2026-09-17 (pending CA confirmation of FIN-002/003) · Owner: Finance · Source: Ind AS 115 / AS 9 Money received for a package before the tour is completed is recorded as Advances from Customers (a liability). It becomes revenue when the tour is COMPLETED (LC-041). Cancellations before travel reverse the advance, not revenue; cancellation charges kept become revenue when the cancellation is approved.

FIN-002 · Which standard applies

Status: OPEN · Owner: CA Ind AS 115 (listed companies, or net worth ≥ ₹250 crore and group companies) or AS 9 (others). Outcome is similar; disclosure differs.

FIN-003 · Principal or agent, per product

Status: OPEN · Owner: CA Where Alhuda carries the risk (pre-purchased airline blocks, leased hotel rooms, packages) it is principal and records the full price as revenue. Where it only arranges a supplier's service for a commission (e.g. individually booked tickets, visa-only service) it is agent and records only its commission/markup, at the point the service is delivered (e.g. ticket issue). Confirm the classification of each product line.

GST

FIN-010 · GST scheme for packages

Status: OPEN · Owner: CA · Source: CGST notifications for tour operator services Tour packages: 5% without input tax credit or 18% with input tax credit. Decide which the company uses; posting of supplier GST depends on it. Enforced by (numbering and reporting, pending the scheme decision): every document series (INV, RCP, BKG, GINV, AINV) is issued by one atomic counter under a row lock — fin_next_series_number with a permission-checked wrapper per series, none of them callable anonymously; the counters cannot be edited from the browser and only move forward through set_document_counter (finance.config.edit, reason, audited). The GST summary is computed in SQL by entryDate (IST) over approved lines including reversals, so cancellation GST comes off the output tax (finance_gst_summary). 20260919100400, 20260919100500, 20260919100600; test supabase/tests/finance_controls.sql.

FIN-011 · GST on advances is due on receipt

Status: LAW · Source: CGST Act s.13 (time of supply of services) For services, GST liability arises when an advance is received, even though revenue waits (FIN-001). A receipt voucher is issued for each advance (CGST Rules r.50); a refund voucher for each refund of an advance (r.51). Enforced by (refund voucher, partial): approve_refund issues a numbered refund voucher RFV-nnnnn for every approved refund (20260918110000_money_integrity.sql; supabase/tests/money_integrity.sql). Every receipt now carries the day the money was received, which dates its voucher (F1, 20260919100900). Receipt vouchers on advances and GST on advances: not yet (F5).

FIN-012 · Corrections after invoicing use credit / debit notes

Status: LAW · Source: CGST Act s.34 A price reduction or cancellation after a tax invoice is a credit note (increase: debit note) referencing the original invoice, within the statutory time limit. Invoices are never edited. Enforced by (partial): Invoice_guard trigger refuses edits to an issued invoice's amount, GST, number, booking and issue date (20260918110000); ensureBookingInvoice no longer overwrites amountDue and records invoice_amount_drift in the audit trail when the booking price moves. Automatic credit/debit note documents: not yet.

FIN-013 · E-invoicing

Status: LAW (if applicable) · Source: GST e-invoicing notifications Required when aggregate turnover exceeds the notified threshold (₹5 crore at time of writing). Confirm applicability.

TCS and TDS

FIN-020 · TCS on overseas tour packages

Status: LAW · Source: Income-tax Act 2025 s.394(1) (formerly 1961 Act s.206C(1G)); rate from 1 April 2026: 2%, no threshold When selling an overseas tour programme package (Umrah, Hajj, Ziyarat abroad) Alhuda must collect TCS from the buyer on receipt of payment, record the buyer's PAN, deposit it, file quarterly returns (Form 27EQ) and issue TCS certificates. Not implemented today.

FIN-021 · TDS on payments we make

Status: LAW · Source: Income-tax Act (contractor, commission, rent sections) TDS is deducted on supplier and agent payments per the supplier's TDS section and PAN status (existing TDS module). Enforced by (a payment to an airline recorded by ticketing, 20260929150000; supabase/tests/ticketing_pays_for_its_blocks.sql): record_airline_block_payment reads the supplier's tdsSection. A supplier set up for 194C, 194J or 194Q is refused for a caller without finance.tds.deduct — "TDS applies to this supplier; finance records this payment" — and, for a caller who holds it, the tax is deducted as the desktop's TDS path deducts it (createSupplierPaymentWithTdsPosting: Dr the supplier's ledger for the gross, Cr the bank for the net, Cr 2401 TDS Payable, the TDSDeduction row for the 26Q return), on the INR value at the contract rate, with the thresholds of TDSRate. A payment that should have had TDS is never posted without it. The desktop's own block page still posts a supplier payment without TDS on finance.create — that path is finance's and is unchanged here (FIN-044).

Ledgers and vouchers

FIN-030 · Every operational event has one posting rule

Status: PROPOSED · Owner: Finance Confirmation, passenger added/removed, price adjustment, cancellation, transfer, refund, supplier purchase, inventory consumption and tour completion each have exactly one documented posting rule, applied automatically in the same step as the operation (LC-020). No operational change without its entry; no silent failures. Enforced by (partial, Wave 1B + F1): payment receipts post inside verify_payment and refunds inside approve_refund, in the same transaction as the business change, approved on creation (20260918110000). F1: a receipt carries the day the money came in (Payment.receivedDate, IST, never future, open period) and that dates its voucher; one receipt over several bookings is recorded by record_payments_batch in a single transaction; supplier purchases and payments both post to the supplier's own SUP-* ledger, never the 2100 control account; the airline-block overpayment guard compares payments with the block cost instead of subtracting the purchase invoice (20260919100900, src/lib/financeOperations.ts, src/lib/api.ts). Other posting rules still run in src/lib/api.ts (F2).

P0 (20260922110000–20260922110200; src/lib/api.ts, supabase/tests/posting_failures.sql): "no silent failures" is now real. An end-to-end replay of a real 27-pilgrim Umrah departure found six approved bookings worth ₹28.9 lakh and ₹25.7 lakh of airline blocks with no journals at all, because a posting failure was caught, written to a console warning, and the business row committed anyway. Every catch around a posting in src/lib/api.ts was audited. A posting that fails now does one of exactly two things:

  • Rolled back. Booking creation (revenue, GST and agent commission), airline-block purchase (stock, supplier transaction and journal) and hotel-lease purchase are undone by rollback_unposted_record() — the business row and every half-written voucher, ledger and supplier row, in one transaction — and the request fails with a message naming the cause. (Superseded for five of the six paths by the P0 below: the row and its voucher are now written in one transaction, so there is nothing to compensate for. Booking creation keeps this backstop, because create_booking and post_booking_revenue are two calls.) The function refuses once a voucher is approved (FIN-031), once the record has been used, and on anything over an hour old. A group expense simply fails, because its journal posts before the row.
  • Recorded as an open PostingFailure, with the payload needed to retry it, where a SECURITY DEFINER function has already committed irreversible operational work and only the money is missing: a passenger cancellation reversal and its credit note (the seats are released and the visa case closed — CXL-001), a passenger transfer adjustment, a B2B third-party sale, a booking invoice on finance approval, and an agent-receipt allocation. These are visible on the Finance page and resolvable by anyone with finance.create; the record itself can never be edited or deleted.

No posting failure anywhere in src/lib/api.ts ends in a console warning.

P0 (20260922120000–20260922120200; src/lib/api.ts, supabase/tests/posting_in_db_functions.sql): the posting is done by the database, on the operational permission. Making failures loud exposed the next problem: ordinary staff could not complete a money-touching step at all. The handler wrote its business row and then posted its journal from the browser, under the acting user's RLS, and JournalEntry INSERT needs finance.create — which intersects bookings.create and inventory.create only at CEO, GM and SUPER_ADMIN. Re-entering the same departure produced 22 escalations to a super-admin, 21 of them this one cause. Granting finance.create to sales and operations was rejected: it would let them write arbitrary journals.

Each posting path is now one SECURITY DEFINER function that takes the actor from auth.uid(), checks the caller's operational permission, writes the business row and its voucher in one transaction, and posts as the system:

Function Writes Permission it demands
post_booking_revenue revenue + GST voucher, agent commission voucher and its ledger mirror bookings.create
create_group_misc_expense GroupExpense + its voucher groups.edit
create_hotel_inventory HotelInventory + purchase voucher + supplier payable hotels.create
create_hotel_assignment GroupHotelAssignment + the stock-consumption voucher hotels.edit
create_quota_block AirlineQuotaBlock + purchase voucher + payable + initial payment inventory.create
create_fit_inventory FITInventory + purchase voucher + payable + initial payment inventory.create
transfer_passenger_to_booking (20260924130000, 20261002090100) the passenger's move, both booking totals, the sold-at receivable move, any price difference (PRC-005) and — when the two bookings bill different parties — the money carried with the traveller (LC-032) booking.transfer
post_booking_revenue_from_booking (20260928235900) derives the booking's posting from the database — receivable (CUS-/AGR-), package revenue, GST on the ground margin, the service charge, the partner's commission and its ledger mirror — line for line as POST /sales/bookings builds it in the browser, and hands it to post_booking_revenue. The native app's New booking calls it right after create_booking; a refusal rolls the booking back (rollback_unposted_record). Idempotent; refuses in words when Finance Settings or the revenue head are missing. Parity is proved by supabase/tests/a_booking_posts_its_own_revenue.sql, whose expected lines are derived by hand from the browser code bookings.create
create_food_inventory → inv_post_food_purchase_as, assign_ground_transfer, assign_hotel_rooms (20260929030000) a meal contract's purchase voucher (Dr 1310 [+ GST input] / Cr the caterer's SUP- ledger, with the caterer's bill), a coach's expense voucher (Dr Ground Transport Expenses [+ GST input] / Cr the operator's SUP-), and the room-consumption voucher handed to create_hotel_assignment — each derived in the database line for line as POST /food, createGroundTransferAssignment and POST /hotels/assignments build them in the browser, at the contract rate (FIN-034). The native app's Inventory and Group services screens call them. A refused food or coach voucher leaves the row standing and is queued as an open PostingFailure with the browser's description; a refused room voucher means no assignment. unassign_hotel_rooms, unassign_food_plan and unassign_ground_transfer reverse through fin_post_reversal_as. Parity is proved by supabase/tests/food_and_transfers_post_their_own_purchase.sql food.create / inventory.edit / hotels.edit
post_quota_block_purchase_from_block / create_quota_block_and_post (20260929010000) derives an airline block's purchase voucher from the block row and Finance Settings — Dr 1310 Stock-in-Hand for the paid seats at the contract rate, Dr GST Input Credit (1400 as linked) when the block carries input GST, Cr the supplier's SUP- creditor ledger, the purchase_invoice ledger mirror and the supplier transaction in the contract currency — line for line as POST /inventory/quota-blocks builds it in the browser (buildQuotaBlockPosting), under the same AIR §15 guard create_quota_block applies, and hands it to fin_post_system_vouchers. The wrapper is the native app's New block: the browser's request checks, create_quota_block with no posting and the derivation, in one transaction, so a refused voucher leaves no row. Idempotent (a block already carrying its purchase_invoice is left alone); refuses in words with no Finance Settings, no supplier, no contract rate on a foreign currency, a payable of nought, an archived block, or an initial deposit (that stays on the desktop with its bank account and reason). Parity is proved by supabase/tests/an_airline_block_posts_its_own_purchase.sql — 18 of 20 seats at ₹51,010 → Dr 1310 9,18,180 / Cr SUP 9,18,180; 9 of 10 seats at 1,000 SAR @ 22.5 with 250 SAR GST → Dr 1310 2,02,500 / Dr 1400 5,625 / Cr SUP 2,08,125 — derived by hand from the browser code inventory.create
post_hotel_purchase_from_hotel / create_hotel_lease (20260929020000) derives a hotel lease's purchase posting from the HotelInventory row — nights, rooms × rate, INR at the contract rate to the paise, Dr 1310 Stock-in-Hand, Dr the GST input head, Cr the supplier's SUP- ledger (fin_supplier_creditor_account), the supplier transaction in the contract currency — line for line as POST /hotels builds it in the browser, and posts it through fin_post_system_vouchers. create_hotel_lease is create_hotel_inventory followed by the posting in one transaction: the native app's New hotel block calls it and a refusal leaves no row. Idempotent; a lease priced at nought posts nothing; refuses in words with no Finance Settings, no supplier or an inactive one. Parity is proved by supabase/tests/a_hotel_lease_posts_its_own_purchase.sql, whose expected figures are derived by hand from the browser code hotels.create

The voucher still lands pending and still waits for a second person (FIN-032) — this changes who may write the entry, not who approves it. Because the row and the voucher are one transaction, a refused posting leaves no business row behind: the rollback path above is now a backstop for a genuinely unbalanced voucher, an unresolvable account or a locked period, not a daily occurrence. fin_post_system_voucher — the function that actually bypasses finance.create — is not callable from the browser; only the wrappers in this table reach it, and each checks a permission first.

Retry. retry_posting_failure (finance.create) re-posts the vouchers a failure recorded at payload.retry.vouchers and resolves the row when they land, recording the attempt either way (retryCount, lastRetryAt, lastRetryBy, lastRetryError). A row that carries no lines — recorded before this existed, or a posting that failed before its accounts resolved — is refused with a message saying it has to be posted by hand.

P0 (20260923100000, 20260923100100; src/lib/api.ts, src/test/posting.one-path.test.ts, supabase/tests/one_posting_path.sql): one door, and it is the only one. Moving six paths into the database fixed six instances of a defect with forty-eight. A third replay of the same departure found ₹13,42,260 — the entire flown cost of the air — missing from the ledger and missing silently: all 27 seat assignments posted Dr 5100 / Cr 1310 from the browser, all 27 were refused by row-level security, and all 27 refusals went into a queue whose Retry button could not clear a single one. The unsold-seat write-off was refused for the role that holds its own permission. Of 49 voucher-building sites in src/lib/api.ts, 42 still posted from the browser.

fin_post_operational_vouchers(operation, posting, referenceId) is now the one door an operational posting goes through. The caller names the operation, never the permission: fin_operation_permissions() maps the operation to the operational permission its route already demands, so the door cannot be used to post under a right the caller happens to hold, and an operation nobody registered is refused rather than posted. Reversals go through it too — post_journal_reversal's body moved into fin_post_reversal_as(uid, …) and the public function became a permission wrapper, so there is one implementation of a reversal and the cap, "status follows the original" and the audit row cannot drift apart.

The registry, each entry mirroring the guard its route already applies:

Operation Permission
flight_seat_consumption (+ reversal) bookings.edit
hotel_purchase / hotel_purchase_reversal hotels.edit / hotels.delete
hotel_assignment_consumption_reversal hotels.edit
food_assignment_consumption (+ reversal), food_purchase (+ reversal) food.edit / food.create / food.delete
ground_transfer_assignment inventory.edit
group_expense_reversal groups.edit
unsold_seat_write_off inventory.writeoff.approve
quota_block_adjustment, fit_adjustment, b2b_third_party_sale inventory.edit
booking_adjustment (+ commission) bookings.edit or booking.transfer
group_invoice_credit_note bookings.cancel.approve

One posting that looks operational deliberately did not move: the airline cancellation filing. Both routes that reach it — POST /inventory/quota-blocks/:id/finance-events and the FIT equivalent — are gated on finance.create, which is exactly what its insert needs, so moving it to the door would have narrowed it: the finance manager who files the cancellation does not hold inventory.edit. That one route serves both a finance event (paying the airline) and an operational one (filing a cancelled seat) under a single finance gate is a real finding; splitting it is a permission change and not this one's to make.

createJournalWithLines — the browser path — now has 21 callers, and every one of them is finance's own work, where finance.create or a finance approval right is the correct gate: a hand-written voucher, the approval queue, an on-account receipt, a credit or debit note, a contra transfer, a settlement, a block or FIT finance event, a period-end stock adjustment, an FX revaluation, the ledger rebuild, the reversal route, and the airline-cancellation approval. src/test/posting.one-path.test.ts holds that inventory and fails the build when a new voucher site appears outside the door, or when the registry and its test double disagree.

One posting per expense (20260923100100). Fixing "an expense with no payer posts nothing" turned a silent no-post into a silent double-post: the business has to record a bought-in cost twice — once as the supplier's bill, once as the departure's cost, because the group cost report reads GroupExpense and never SupplierTransaction — and after the fix both posted. ₹2,70,894 of catering sat in 5300 twice. A group expense may now name the bill it is the departure's copy of (GroupExpense.supplierTransactionId): it records the cost for the report, posts nothing, and the bill's own voucher is tagged to the departure instead. A unique index refuses a second expense claiming the same bill. An expense that names no bill still posts.

The GST on a booking is the GST its voucher posts (money audit 30/09/2026, issue #497). post_booking_revenue writes the booking's GST (Booking.gstEnabled, gstRate, gstAmount) from the caller's posting, and that figure counts in what the booking owes the moment it is written, although the voucher itself waits for approval. It used to write whatever it was sent. It now refuses: a negative amount; a rate below 0 or above 100; an amount with no rate; an amount in fractions of a paisa; an amount above the rate on the whole price (GST is charged on the ground margin, which is never more than the price — a typed amount may differ from rate × margin, but not exceed that); and an amount that differs from what the booking's voucher credits to the GST Payable head in Finance Settings. Enforced by 20261004200000_money_audit_small_items.sql; test supabase/tests/money_audit_small_items.sql. The wizard and the phone no longer show a GST figure they cannot compute: with GST on and no amount typed they say GST on the margin is set when the booking is saved (src/components/bookings/wizard/wizardTypes.ts gstSetWhenSaved, apps/mobile/src/lib/newBooking.ts bookingTotals).

Built (#509): the GST — and the price with it, since both post on the same voucher — counts in the booking's balance only once the booking voucher is approved. See FIN-033.

Not covered yet: re-costing a hotel lease, and changing a hotel stay's cost (issue #559 step 5). Both still post from the browser through the operational door with lines the browser builds: PATCH /hotels/:id reverses the lease's purchase voucher and posts a new one when the rooms, rate, dates, currency, GST or supplier change; PATCH /hotels/assignments/:id reverses and re-posts a stay's consumption voucher when its rooms, rate or dates change. The re-posted lease voucher debits Hotel Expenses (5200), while the voucher the lease first posted (post_hotel_purchase_from_hotel, create_hotel_lease) debits Stock-in-Hand (1310) and each stay later moves its cost from 1310 to 5200 — so after a re-cost the stock is reversed and the expense is counted when the lease is re-costed and again when a stay is assigned. Finance has to decide which account a re-cost debits before it moves into one database function. Until then the phone does not re-cost a lease or change a stay's cost: it assigns rooms (assign_hotel_rooms) and takes them back (unassign_hotel_rooms), which derive their vouchers in the database, and leaves the rest to the website (native app → Not built).

Not covered yet: a payment to a supplier outside an airline block or FIT. POST /suppliers/:id/transactions posts a supplier payment, a bill, and TDS (FIN-021) from the browser (createJournalWithLines, on finance.create). A payment against a block or a FIT already goes through record_airline_block_payment (FIN-044), and the phone records those from the block's screen. Any other supplier payment stays on the website until it has one database function that applies TDS, the approval limits of ACC-030 and the foreign-currency settlement the browser applies today.

FIN-031 · No edits to posted entries

Status: LAW (via audit trail) · Source: Companies (Accounts) Rules r.3(1) Posted vouchers are never edited or deleted. Corrections are reversals plus new entries (existing Reverse + Clone). Enforced by: JournalEntry_guard, JournalLine_guard, LedgerEntry_guard, SupplierTransaction_guard triggers — approved vouchers, their lines and posted ledger mirrors cannot be edited or deleted by any role (service-role break-glass with app.finance_break_glass only); period lock on INSERT and UPDATE by entryDate (20260918110000; supabase/tests/money_integrity.sql). Ledger reset and GL force-delete removed. F1 (20260919100000, 20260919100400, 20260919100600; supabase/tests/finance_controls.sql): every reversal references the voucher it reverses and follows its status — a reversal of a pending voucher is pending and cannot be approved before it, rejecting a voucher rejects its pending reversals, and the reversals of a voucher can never exceed the voucher itself (checked under a lock, so partial reversals are allowed and cancellation reversals no longer stack). post_journal_reversal is the only way to reverse; it posts approved only for a caller who may approve and did not make the original. A supplier transaction whose voucher is posted is immutable; once the voucher is reversed it is corrected through correct_supplier_transaction (finance.supplier_transactions.correct, reason, audited). An opening balance is a voucher: set_account_opening_balance posts it pending and reverses the previous one, and Account.openingBalance cannot be typed in from the browser.

The one thing that may be filled in on an approved voucher is its departure dimension (groupId), once, from nothing to a departure — never changed, never removed. See FIN-041.

A voucher balances to the paisa (money audit 30/09/2026, issue #497). Every journal line is stored rounded to the paisa. A voucher's debits equal its credits exactly after that rounding — no ₹0.01 of drift. Before this, four posting functions added up the raw figures, let the sides differ by ₹0.01 and then rounded each line as they stored it: Dr 100.01 / Cr 100.00 was accepted, and so was Dr 100 against Cr 33.333 three times, stored as Cr 99.99. A voucher written straight in as approved was never balance-checked at all. Enforced by (20261004200000_money_audit_small_items.sql; test supabase/tests/money_audit_small_items.sql): the JournalLine_paisa trigger rounds every line; fin_post_system_voucher, post_cancellation_reversal, post_decision_journal and fin_post_reversal_as (custom lines) round each line first and then require equality; fin_journal_entry_guard requires equality to approve; the deferred constraint trigger JournalEntry_balanced refuses, at the end of the transaction, a voucher inserted as approved with fewer than two lines, no debit, or sides that differ. In the browser, createJournalWithLines and the Journal tab's form compare the sides in whole paise (src/test/harness/postingRpcs.ts mirrors the database). A pending voucher already in the queue that is a paisa out can no longer be approved; it is rejected and posted again.

FIN-032 · Maker-checker on vouchers and payments

Status: DECIDED (enforced in the database since 2026-09-18, 20260918110000) · Owner: Finance The person who posts a voucher, records a payment or files a refund cannot approve or verify it. Enforced in the database (ACC-020). Enforced by: record_airline_block_payment (20260929150000: the ticketing manager's or executive's payment to the airline lands pending with the recorder as its maker; approve_journal_entries skips the maker with maker_checker_self_approval and a finance manager approves — supabase/tests/ticketing_pays_for_its_blocks.sql T-040), record_payment (always pending, recorder from session, and since F1 only with finance.payments.record), verify_payment (recorder cannot verify), refund_payment / request_booking_refund + approve_refund (requester cannot approve; the payout request needs finance.payments.refund), approve_journal_entries (creator cannot approve; finance.journals.approve_own audited override), decide_b2b_cancellation (filer cannot decide), Payment_guard (browser writes cannot set status) — 20260918110000; supabase/tests/money_integrity.sql. Online payment (20260929130000; supabase/tests/an_online_payment_starts_with_an_order.sql): the app's razorpay-order function records no payment — it creates the Razorpay order (with notes.bookingId) for the booking's customer or payer or its partner — never for staff (see below) — capped at the agreed due (FIN-033, 20261006110000; approved voucher or not), and writes only an OnlinePaymentOrder row a session cannot write; the verified Payment is recorded by razorpay-webhook on Razorpay's HMAC-signed payment.captured event, by the system, never by the person paying. A claim a person makes (customer_submit_payment, partner_submit_payment) stays pending. F1 closes the last gap (20260919100000; supabase/tests/finance_controls.sql): a browser write can no longer create an approved voucher — every direct insert is stored pending and a direct status change to approved is refused, so approval happens only in approve_journal_entries. A voucher can only be approved when its lines balance. System postings that legitimately post approved go through SECURITY DEFINER functions: verify_payment, approve_refund, post_journal_reversal, post_decision_journal (the B2B cancellation vouchers, only for the person who just decided that cancellation, once per reference) and post_cancellation_reversal. Approval limits by amount: ACC-030.

P0 (20260922120100; supabase/tests/posting_in_db_functions.sql): a cancellation's credit is posted by the approval, not by a person. finance.journals.approve is held by FINANCE_MANAGER, CEO, GM and SUPER_ADMIN and deliberately not by the ACCOUNTANT (ACC-011/ACC-012). So when a finance manager approved a passenger cancellation, the credit that takes the pilgrim off the receivable had that manager as its maker and nobody below CEO could approve it: in the replay an ₹88,000 reversal sat pending for ever and the cancellation was right everywhere except the ledger. The answer was not to give the accountant journal approval — that bundle is an owner decision about segregation of duties. post_cancellation_reversal posts the credit against the cancellation decision itself: one person requests, a different person approves (enforced in the handler and again in the function), so the decision is the second pair of eyes and the credit is posted approved when the voucher it reverses is approved. It is still a reversal, so the reversal cap and "status follows the original" (FIN-031) still bound it. It also removed an escalation: an OPS_MANAGER with bookings.cancel.approve and no finance.create could not approve a cancellation at all.

A reversal posts on its own only when it mirrors the voucher it reverses (DECIDED 2026-09-30 · owner: "Check the lines"; #493; 20261004160000; supabase/tests/chosen_lines_wait_for_a_second_person.sql). A partial reversal (post_journal_reversal with lines) and the cancellation credit both take lines from the caller. Until this change the only limit was the total, so a journals approver could post "Dr Suspense / Cr Bank ₹5,00,000" against anyone's approved voucher, approved, alone; and the cancellation credit could name a voucher of a different booking. Now fin_lines_mirror_original checks the lines: every line is on one of the original voucher's own accounts, on the opposite side, and no account carries more than the original did. Then: - a mirror follows the original as before — approved when the original is approved and the caller may approve; - anything else from post_journal_reversal posts pending and a second person approves it in approve_journal_entries; - the cancellation credit is refused unless the voucher it reverses belongs to the passenger's booking (booking… reference, same booking id), and posts approved only on a mirror — otherwise pending.

A normal cancellation credit (Dr revenue and GST payable, Cr the receivable, all within the booking voucher) is a mirror, so the ₹88,000 case above still posts approved and nothing piles up. A credit whose receivable account has changed since the booking was posted, or one against a legacy booking whose GST sat on a separate booking_gst voucher, now waits for a finance approver; the passenger's balance does not wait, because it reads the cancellation decision, not the voucher (PRC-030). The B2B cancellation vouchers are not reversals, so they are checked by size instead (20261004210000; supabase/tests/decision_vouchers_match_the_decision.sql): post_decision_journal posts a voucher approved only when it is the size the decision recorded — the sale amount for the buyer voucher, the supplier's refund plus its charge for the supplier voucher, the cost moved back to stock for the cost reversal. Any other size posts pending for a finance approver; it is never refused, so a cancellation is never left without its voucher.

A hand-made voucher from any door (20261001160000; supabase/tests/a_journal_from_any_door.sql; owner, 27 Sep 2026: "finance staff make ledger entries" on the phone). create_manual_journal(p_entry) writes a journal, payment, receipt or contra voucher in one transaction, on finance.create — the same right POST /finance/journals asks for. It is always pending, with the caller as maker, so a second person approves it in approve_journal_entries; the maker is refused there unless they hold finance.journals.approve_own. It refuses, in plain words: a date in the future (IST) or in a locked or closed period; no narration; fewer than two lines; a line on a missing, inactive or group account; a line with both a debit and a credit, or neither; more than two decimals; totals that differ by even a paisa; any foreign currency (the website's per-line rate, FIN-034, stays on the website); a payment that credits no cash or bank account, a receipt that debits none, a contra that touches anything else. The voucher number takes the website's prefix for its type from fin_voucher_prefix (JV, PV, RV, CT); every type is stored as manual_journal, so the manual-journal approval limit (ACC-030) applies to all four. The same voucher sent twice within ten minutes returns the first. Each write is audited (finance.voucher.create, with the door). The phone app uses it; the website still posts through createJournalWithLines.

Staff do not pay online (DECIDED 2026-09-30 · owner: "there shouldn't be option to pay online (via Razorpay for example) for bookings for employees in the mobile app or on the web app, right?"). Only the booking's own side starts an online payment: its customer or payer (the traveller app) or its business partner (the partner app and web portal, PTR-081, PTR-093). A member of staff never starts a Razorpay payment for a customer — not with finance.payments.record, not with any other right. Staff record money the customer paid them (Take payment on the phone, Record payment on the website); that receipt is pending and a second person verifies it, as above. A staff member who is themselves the booking's customer or payer (their own trip, their customer record linked to their login) is that booking's customer and may pay it online like any traveller. The staff booking screen in the app has no Pay online; the website's staff screens never had one. Enforced by: online_payment_actor() answers customer, partner or nothing — never staff (20261003180000_staff_do_not_pay_online.sql; test supabase/tests/staff_do_not_pay_online.sql); razorpay-order refuses a staff login that is nobody to the booking with 403 "Staff don't pay online. Record the customer's payment instead." (actorRefusal, supabase/functions/razorpay-order/index.test.ts); the app screen apps/mobile/src/app/booking/[id].tsx (guard apps/mobile/src/lib/staffPayOnline.test.ts). razorpay-webhook is unchanged. Orders a staff login started before this change keep actorKind staff; no new one can.

FIN-033 · Customer balance comes from the ledger

Status: DECIDED 2026-10-01 for when a booking is owed (below; enforced) · PROPOSED for the customer ledger worked out from ledger entries (not built yet, F3) · Owner: Finance A customer's paid and outstanding amounts, the customer ledger and group P&L are calculated from ledger entries, not from stored totals on bookings.

A booking is owed once its voucher is approved (DECIDED 2026-10-01 · owner: "Price + GST on approval"; #509; 20261004220000; supabase/tests/a_booking_is_owed_once_its_voucher_is_approved.sql). The price and the GST post on one booking voucher, which waits for a finance approver (FIN-032). A booking's balance counts them only once that voucher is approved: while it is pending, or if it is rejected, the booking owes nothing. fin_booking_billed() gives the figure; the balance writers (Booking_paid_guard, the passenger refresh, fin_refresh_payment_totals, bkg_refresh_paid) and the staff figures that work out what is due (settlement summary, dues near departure, departure readiness, payment reminders) use it, and the trigger "JournalEntry_booking_balance" refreshes the balance when the voucher is written, approved or rejected. What the customer agreed to pay is unchanged and still governs the limits — what a customer or partner may pay (customer_submit_payment, partner_submit_payment), what may be refunded (PRC-030) and the cancellation cap — and the partner and agent statements, booking emails and invoices, which show the booked amount. Otherwise a customer could not pay before approval, and every payment made before approval would read as overpaid and refundable.

Awaiting approval is shown, and payable (DECIDED 2026-10-01 · owner: "allow online payment before approval, up to the booked amount"; 20261006110000; supabase/tests/awaiting_approval_is_shown_and_payable.sql). A booking has two figures: the balance above, and the agreed due — price + GST − receipts − cancellation credit, never below 0, in the booking's currency, whether or not the voucher is approved (fin_booking_agreed_due()). Online orders started but not paid are not subtracted; only money received is. The online-payment caps and the payment-claim caps use the agreed due: razorpay-order (the app, the partner portal and app, the WhatsApp payment link — whatsapp_pay_link_issue()), and customer_submit_payment / partner_submit_payment (the agreed due less the claims still waiting). Nothing is due only when the agreed due is 0. Each booking read — booking_screen, booking_list_page, trv_my_trips, whatsapp_menu_bookings and the PostgREST computed fields booking_agreed_due / booking_awaiting_approval — carries agreedDue and awaitingApproval (the voucher is not approved). While a booking is awaiting approval and its agreed due is above 0, every screen shows "Awaiting finance approval — ₹X will be due" in amber or neutral, never a green Fully paid (web booking page and list, the staff phone pages /m/bookings, phone booking screen, list and Take payment, the customer app and web customer portal, the partner portal — bookings list, dashboard, reports, payments — and app, and the printed group report with money). Where a screen totals many bookings — the partner dashboard and reports, the customer portal's money summary, the group report's totals row — the total stays the sum of balances and the agreed due of the bookings awaiting approval is named apart, ₹B more once finance approves, never added into it. Not built: the group screen (roster Balance column, travellers panel, Overview money summary) still shows the stored balance — its one-call read does not carry the agreed due; finance's own screens (receipts, allocation, approvals, settlement, ledgers) show the ledger balance by design. The settlement report's rows bill through the voucher state as its headline does. A transfer writes both bookings' balances through fin_booking_billed(); the transfer carry stays on the agreed amount.

Every booking becomes owed (bookings audit, 01/10/2026; 20261008130000; supabase/tests/booking_flows.sql). Four paths never posted a booking voucher, so their bookings were never owed: a partner's booking, a booking made for a transferred traveller, a booking priced later, and a booking whose browser closed between create_booking and the posting. Now a booking's price is "approved in the books" (fin_booking_price_approved(), used by fin_booking_billed() and awaitingApproval) when its booking voucher is approved or when the receivable a transfer moved into it is approved — the two ways fin_booking_is_ledgered() already counted a booking as in the books; the trigger "JournalEntry_transfer_move_balance" refreshes the receiving booking's balance when that move is approved or rejected. A booking that is in the books neither way gets its booking voucher, built from the booking by the one builder (post_booking_revenue_from_booking): finance approval posts it when it is missing (partner bookings, a closed browser), as the approver, pending a second person — or refuses with Finance Settings' own message, and the booking stays waiting; an edit that prices it posts the booking voucher for the whole price instead of an adjustment; a transfer that lands confirmed (LC-034) posts it when the old booking's price was never in the books. The voucher posts the GST the booking itself carries, and none when none was decided — Finance Settings are not applied at approval, so a partner's booking does not owe more than it was submitted at (and checked against the credit limit for); GST switched on without a rate is refused. An edit to a booking that has left sales does not recalculate its GST (PRC-003). A booking made for a transfer posts neither GST nor commission (PRC-005). An adjustment is posted only on top of a booking that is in the books. Enforced by (partial): Booking.paidAmount is recomputed from verified payments by the database and cannot be set from the browser (Booking_paid_guard); group P&L revenue from finance_group_pnl (group invoices net of credit notes, active passengers). Customer ledger from ledger entries: not yet (F3). F1 reports (20260919100600; supabase/tests/finance_controls.sql): the account statement, account ledger, group ledger, GST summary, AP aging, the ledgerwise settlement report and the bank auto-match candidates are aggregated in SQL over approved lines by entryDate in IST and returned as one value, so PostgREST's 1,000-row cap can never cut a report short; opening balances are counted once (from their voucher, never from Account.openingBalance on top); finance_opening_balance_drift() reports accounts where the cache and the voucher disagree (AUD-010). Each report function checks the permission of the report it serves.

W-B2 (20260921120000, 20260921120100; supabase/tests/finance_reports_truncation.sql, src/lib/api.paging.reports.test.ts) closes the reports the F1 pass stopped short of. The settlement report's money comes from finance_settlement_summary() and its listing is paged; the handler cross-checks the two and refuses rather than serving a short list under a right-looking total. The cancellation report's date window is a SQL predicate, not a JavaScript filter over whichever thousand rows came back — a "last month" report used to be able to return empty while cancellations existed. A GL account's balance comes from finance_account_balance(), so the insufficient-balance guard (and GET /accounts/:id/balance, which replaces the browser-side sum in AirlineBlocks) decides on the whole account, not its first 1,000 lines. The ledgerwise report pages its list of party sub-accounts; /finance/journals honours ?accountId=; /finance/receivables-payables pages the partner half as well as the customer half; /finance/pending-settlements scopes its invoice read to the party in SQL; the on-account allocation guard and the ledger rebuild page their reads.

FIN-048 · A booking number is read from its trailing digits

Status: DECIDED 2026-10-01 · Owner: Finance · Source: bookings audit (engineering) A booking number is the Finance Settings prefix, a hyphen and five or more digits: BK-00012, AH-BK-00012. The number is taken from the trailing digits, so a prefix may carry a hyphen or end in digits (AH-26) without breaking the series — before, "AH-BK" failed every new booking and "AH-26" handed out the same number again and again. A prefix is 1 to 12 letters or digits with a hyphen only between them, stored in capitals; blank means the fallback BK, the same on every path (staff, partner, transfer). The invoice and receipt series read their numbers the same way.

Enforced by: next_booking_number(), next_invoice_number(), next_receipt_number(), bl_next_booking_no() and the trigger "FinanceConfig_booking_prefix" in 20261008130000_booking_flows.sql; PUT /admin/finance-config and Finance → Settings refuse a bad prefix first. Test: supabase/tests/booking_flows.sql, src/lib/api.bookingFlows.test.ts.

FIN-034 · Foreign currency

Status: DECIDED (existing feature) · Owner: Finance · Source: Ind AS 21 / AS 11 Foreign-currency transactions are recorded in INR at the rate on the transaction date, the rate is stored on the entry, and gains/losses are recognised on settlement or revaluation. Enforced by (partial): airline block and FIT purchase / initial-payment / adjustment vouchers convert at the contract exchangeRate with the original amount and rate on each line and refuse to post without a rate; group P&L refuses to count a foreign contract without a rate; realised FX gain/loss lines carry the real difference and balance (applyRealisedFxSettlement; src/lib/api.moneyIntegrity.test.ts). Browser-side for now, not in the database. Receipts (issue #494; owner 30/09/2026: bookings are sometimes priced in SAR or USD; 20261004180000; supabase/tests/foreign_currency_receipts.sql, src/lib/api.receiptIdentity.test.ts): a receipt is recorded in the booking's currency — POST /finance/payments converts money that arrived in another currency at the rate on the received date and keeps the original currency, amount and rate on the Payment. The receipt voucher is in INR, in the database (fin_post_payment_receipt): money that arrived in rupees posts what arrived; money that arrived in a foreign currency posts at the receipt's stored INR rate, else the exchange_rates rate on or before the received date, with the original currency, amount and rate on each line and on the entry. With no rate at all the voucher is refused — and so is the verification — with "No … to INR exchange rate on or before …. Add one under Finance → Settings → Exchange Rate Management" rather than posted at a guess. The rate on a day is the one in force that day — started on or before it and not past its expiry date (FIN-046). A booking's paid and balance stay in the booking's currency: fin_booking_receipts_total counts every payment, allocation and carried amount in it (through the receipt's own rate when it was converted at recording, else the rates on the received date; with no rate on record for either currency, the amount counts as recorded). Not done: the exchange gain or loss between the rate a foreign sale was booked at and the rate its receipt posts at. Period-end revaluation (W-B2, 20260921120100; supabase/tests/finance_reports_truncation.sql, src/lib/api.paging.reports.test.ts): the input is every open foreign-currency balance as at the period end, from finance_fx_open_balances() — one aggregate per (party sub-ledger, currency) over approved lines dated by entryDate in IST. Before this it read vouchers created inside the period — so a supplier invoiced in March and unpaid through April was never revalued — and read them unpaged, so a run revalued whichever 1,000 vouchers came back and posted the gain/loss as if that were the whole exposure. A run now covers the whole book or refuses.

What a receipt in another currency does, as the screens now say it (Record payment, on the booking and the bookings list): the amount is converted into the booking's currency at the rate in force on the date received, the receipt keeps what arrived, and the voucher posts in rupees at the rate on or before that date. No exchange gain or loss is recorded on a receipt — see "Not done" above. The dialog used to say the amount was converted "using the current exchange rate at the time of posting" and that a gain or loss would be recorded; neither was true.

FIN-046 · Exchange rates are managed by finance and dated

Status: DECIDED (2026-10-01) · Owner: the owner · Source: money audit 01/10/2026 — any staff login could change a rate; the owner: a new permission for finance and leadership, past-dated rates allowed with a reason and audited

Every foreign-currency receipt, meal contract and visa bill converts at the rates in exchange_rates (FIN-034), so a rate is money. Only a holder of finance.rates.manage — FINANCE_MANAGER, ACCOUNTANT, CEO, GM and SUPER_ADMIN — adds, changes or deletes one, in Finance → Settings → Exchange Rate Management or Admin → Currencies → Exchange Rates, or refreshes rates from the live feed.

  • A rate has a date. It defaults to today in India (IST). A date after today is refused. A date before today is allowed with a reason of at least 3 characters, because receipts, contracts and bills dated from then convert at it. Changing or deleting a rate dated before today needs a reason too.
  • A rate is in force from its date to its expiry date, both included. No expiry means it runs on. A new rate closes the pair's older open-ended rates on its own date. A rate dated before a newer one runs until the newer one starts. On the same day a manual rate wins over one fetched from the feed, and the feed never replaces a manual rate of the same day. Deleting a rate re-opens the rate it had closed.
  • With no rate in force, the conversion falls back to the newest snapshot from the live feed (exchange_rate_snapshots) of any date — the feed's cache, which may be old. With neither, the receipt, contract or bill is refused (FIN-034).
  • Every change is audited: who, the rate before and after, the date, whether it was backdated, and the reason.

Enforced by (20261006100000; supabase/tests/exchange_rates_are_managed_and_dated.sql, src/services/exchangeRateAdmin.test.ts): exchange_rates has no write policy and no INSERT / UPDATE / DELETE grant for anon or authenticated — a browser session reads rates and writes none. set_exchange_rate() and delete_exchange_rate() (SECURITY DEFINER, fin_require('finance.rates.manage')) are the only writers; they date the rate with fin_ist_today(), refuse a future date, require the reason, and write fin_audit rows (exchange_rate_added, exchange_rate_changed, exchange_rate_deleted); audit_table_change records each row change as well. fin_resolve_rate_to_inr() and the browser's resolveRate() honour expiry_date. POST /finance/fx-rates/refresh needs finance.rates.manage and stores each rate through set_exchange_rate().

Not built: a rate between two foreign currencies is stored as typed and not checked against their rupee rates; the snapshot fallback has no age limit.

Group costing

Added after the 19 Sep replay of the real 29 Aug Umrah departure (audit, defects R1, R5, R14 and §8.3). The departure's reported profit was meaningless: ₹15,75,255 on a departure that made ₹2.3 lakh, and a cost report that disagreed with the ledger by ₹13,45,100 with nothing in the product saying so.

FIN-035 · Inventory becomes a cost when it is consumed, and every voucher names its departure

Status: PROPOSED · Owner: Finance · Source: Ind AS 2 / AS 2 (inventory), matching concept A seat or a room bought into stock is an asset only while it can still be sold. It becomes a cost of sale when it is consumed, and consumption is the moment the unit is committed to a named passenger on a named departure — a seat assigned to a pilgrim, a room allocated to a group. Not ticket issue, which is a document event that does not always happen in the system; not the departure date passing, which would leave every open departure's profit and loss wrong until a closing run caught up. Releasing the unit reverses the entry and the cost returns to stock.

Every voucher carries the departure it belongs to, or none where it genuinely belongs to no single departure (a block bought into stock before any group is linked to it; a receipt, which is a balance-sheet movement rather than something a departure earns). A departure's cost is a sum of ledger lines, not a second book kept alongside one.

Enforced by (20260922130000, 20260922130100, 20260923100000; src/lib/api.ts, src/lib/api.groupCosting.test.ts, supabase/tests/one_posting_path.sql): JournalEntry.groupId, indexed, derived by the database rather than remembered by the caller — fin_voucher_group_id() reads the departure off the reference the voucher already carries (a booking, a group expense, a group invoice, a hotel or food or ground assignment, a seat assignment, a single-departure airline filing) and a BEFORE INSERT trigger applies it to every insert, browser or system function. An explicit groupId still wins, so a voucher that is deliberately company-level — an unsold-seat write-off on a block shared by several departures — stays untagged. Asking 49 call sites to remember a field produced 27 untagged vouchers out of 39 on the test departure and ₹18,78,920 reported as unexplained; deriving it is what makes the reconciliation block mean anything. Backfilled from bookings, group invoices, group expenses, hotel and food consumption and single-departure airline penalties, and inherited by every reversal through a trigger so a credit note can never land on a different departure from the sale it undoes. POST /sales/bookings/:id/passengers/:id/flights posts Dr 5100 Airline Block Purchases / Cr 1310 Stock-in-Hand at the contract rate per seat, FX-converted, idempotent per assignment, skipped for infants, reversed on unassignment and on passenger cancellation. Hotels have done the same since PR #85. A refused consumption entry is queued as a PostingFailure rather than rolling back a correct seat assignment (FIN-030).

Before this, a flown block never became a cost of sale at all: ₹15,08,730 of seats on a landed aircraft were still carried as a current asset.

Measured on the fourth replay of the real 29 Aug departure (docs/audits/2026-09-18-real-group-accounting-test.md §19): ₹13,42,260 of flown air in 5100 on 27 vouchers, Stock-in-Hand nil once the unsold-seat write-offs are approved, every voucher that can name its departure naming it, and the group cost report and the general ledger agreeing on ₹25,81,946 with nothing unexplained.

FIN-045 · A verified payment has a receipt

Status: PROPOSED (2026-09-27) · Owner: Finance · Source: the owner, 27 Sep 2026 — "give staff more options to work from phone"; a customer asks for a receipt for every payment

Every payment the office has verified has the company's own receipt, a one-page A4 PDF: the logo and the office's address, the receipt number (Payment.receiptNo, given when the payment is verified — FIN-032), the date received (receivedDate, FIN-042) as DD/MM/YYYY, received from (the partner with its BP- code on a partner's booking, otherwise the payer — or the customer — with the CU- code, PTY-006), the booking number and departure, the amount in figures with Indian grouping (₹ 1,23,45,678.50) and in words ("Rupees One Crore Twenty-Three Lakh … and Fifty Paise Only"), the method, the reference (UTR, cheque number; "Cash — no instrument" for cash), who verified it and when, the booking's total and the balance after this payment — the booking's gross less every counted receipt up to and including this one, in the order they were verified. It says it is not a tax invoice.

A payment waiting to be verified, a rejected one, a refund and a payment against a group invoice with no booking have no receipt, and asking for one is refused with the reason. A payment has one live receipt: asking again hands back the same file; staff may re-issue it (the previous file is kept, superseded).

Who: staff holding finance.view or bookings.view; the booking's own customer or payer; the booking's partner. Nobody else — a passenger on someone else's booking does not see the money (TRV-001). The file lives on the company Shared Drive under Issued/<booking> like an e-ticket sheet (TRV-013, ACC-074).

Enforced by: the edge function issue-document (POST {kind: 'receipt', paymentId, reissue?}) — checks finance.view / bookings.view with user_has_permission, or that the caller's own customer record is the booking's customer or payer, or that their agency is the booking's partner (a stranger is refused with 403 whether or not the payment exists); refuses a payment that is not verified, has no positive amount or has no booking (409); hands back the live receipt unless staff ask for reissue; a paused account (ACC-070) opens what is there and makes nothing new. The rules and the sheet's text are in supabase/functions/issue-document/receipt.ts. issue_document_record() accepts kind receipt only for a verified payment of that booking (refId = the payment); IssuedDocument.kind accepts receipt; the row policy lets finance.view read receipts (not e-tickets or vouchers). Migration: 20261001130000_a_receipt_is_a_document.sql. Tests: supabase/tests/a_receipt_is_a_document.sql, supabase/functions/issue-document/receipt.test.ts, apps/mobile/src/lib/travellerDocs.test.ts, src/components/bookings/PaymentReceiptButton.test.tsx. Screens: the staff and partner booking and the traveller's Money card in the app (Native app), the desktop booking page's Payments tab (Bookings).

Not built: a receipt for a payment against a group invoice with no booking (IssuedDocument belongs to a booking); a receipt sent by email or WhatsApp by the system itself (the app opens WhatsApp with a note and shares the PDF from the phone — TRV-015); a receipt in the traveller's My documents list (it is on the Money card); GST on the receipt.

FIN-044 · Ticketing pays for its blocks, finance approves

Status: DECIDED (2026-09-25) · Owner: the owner · Source: "ticket manager and ticket executive should be able to make airline block payments"

The ticketing manager owns the airline block and holds the airline's payment deadline (AIR §21, §30); the ticketing executive works it. Either may record a payment to the airline for a block or a FIT — the amount, the account it left, the method, the reference, the date. Recording is not approving: the voucher lands pending and a second person in finance approves it (FIN-032), exactly as when finance records it. Nothing about how the money is booked changes with who records it.

Enforced by (20260929145900, 20260929150000; supabase/tests/ticketing_pays_for_its_blocks.sql; src/lib/api.blockPayments.test.ts): a permission, inventory.block_payments.record (TICKET_MANAGER, TICKET_EXEC, FINANCE_MANAGER, ACCOUNTANT, SUPER_ADMIN), and one SECURITY DEFINER function, record_airline_block_payment(p_block_id, p_kind, p_amount, p_currency, p_exchange_rate, p_paid_from_account_id, p_method, p_reference, p_paid_on, p_notes). It derives the voucher the desktop's finance-events route builds in the browser — Dr the supplier's SUP- ledger / Cr the bank or cash account, the supplier_payment ledger mirror from that account, the SupplierTransaction credit in the contract currency, the desktop's memo (<supplier> · Supplier payment for airline block <code> <origin>-<destination>, or the note typed, then · Ref <reference>) — and hands it to fin_post_system_vouchers. Parity: 2,00,000 on an INR block → Dr SUP- 2,00,000 / Cr 1010 2,00,000; 4,000 SAR on a riyal block at 22.5 → Dr SUP- 90,000 / Cr 1010 90,000 with 4,000 SAR @ 22.5 on each line; 1,00,000 to a 194C company → Dr SUP- 1,00,000 / Cr 1010 98,000 / Cr 2401 2,000 — each derived by hand from the browser code.

Where the desktop was silent, the database is not: the overpayment guard of POST /suppliers/:id/transactions (INV-025 — paid seats × fare less the deposit and the payments net of refunds, AIR §15) is applied to every payment, not only the screen's; a foreign contract is converted at the contract rate with the original amount and rate on each line (FIN-034 — the desktop's block page posted the riyal figure into INR heads unconverted, so paying the airline SAR 4,000 cleared ₹4,000 of payable); a supplier set up for TDS is refused unless the caller may deduct, and then deducted (FIN-021); a payment dated in the future, on an archived or fully cancelled purchase, from a ledger that is not an active leaf under Current Assets, or from an account that may not go negative and cannot cover it, is refused in the desktop's words; the same payment sent twice within ten minutes returns the first (idempotencyKey). The method is kept in the audit row, not the voucher — the desktop keeps none.

The web routes POST /inventory/quota-blocks/:id/finance-events, POST /inventory/fit/:id/finance-events (eventType: supplier_payment) and POST /suppliers/:id/transactions (a credit against quota_block / fit) send a caller who lacks their own gate (finance.create / suppliers.edit) and holds the permission through the function; a caller who holds the gate keeps the browser-built path. Deliberately not switched for everyone: the function is not byte-for-byte with finance's path on a foreign contract (it converts) or a TDS supplier (it deducts), and changing finance's own vouchers is finance's decision, not this one's. The native app has no other path.

Amended 2026-09-25 (20260929200000; supabase/tests/ticketing_files_and_refunds.sql): the same right covers money back from the airline and the deposit at purchase. record_airline_block_payment takes a p_event: supplier_payment (as above), supplier_refund (record_airline_block_refund is the app's wrapper: Dr the account the money came into / Cr the supplier's SUP- ledger, the supplier_refund ledger mirror, a SupplierTransaction refund in the contract currency, pending finance; refused above what was paid so far — "Refund exceeds the … paid so far", the browser's own cap; no TDS on money coming back; a fully cancelled block still takes its refund) and initial_payment (the desktop's deposit voucher: initial_payment mirror and category, the memo <supplier> · Initial supplier payment for airline block <code> <o>-<d>). create_quota_block_and_post accepts initialPaymentAmount with initialPaymentSourceAccountId: the row, its purchase voucher and the deposit's voucher in one transaction, the deposit through the payment function (the account, the rate, the guard and the TDS rule apply), then the amount on the row as the desktop keeps it; a deposit with no account, or by a creator without inventory.block_payments.record, is refused and leaves no block. The deposit's own supplier transaction is read as the deposit whatever prefix the memo carries (fin_is_initial_quota_payment_description and the desktop's isInitialQuotaPaymentDescription both match anywhere in the text; anchored at the start they missed the desktop's <supplier> · prefix and counted a named supplier's deposit twice as paid). Parity: 50,000 back on the INR block → Dr 1010 50,000 / Cr SUP- 50,000, paid so far 3,00,000 → 2,50,000, owed 6,68,180; a 1,50,000 deposit on a 4,00,000 block → Dr SUP- 1,50,000 / Cr 1010 1,50,000, paid once, 2,50,000 owed; 2,000 SAR @ 22.5 → Dr SUP- 45,000 with the riyals and the rate on the line. The website records the refund too (October 2026, finance staff guide review): Record refund from the airline on the airline block's payment section → POST /inventory/quota-blocks/:id/airline-refund (inventory.block_payments.record) → record_airline_block_refund, the app's own call; on a FIT, the same button in the FIT payment dialog → POST /inventory/fit/:id/airline-refund → the same function with p_kind: 'fit' (src/pages/inventory/FITInventory.airlineRefund.test.tsx); no database change (src/lib/api.airlineRefund.test.ts, src/components/inventory/RecordAirlineRefundDialog.test.tsx). The filing of a third-party sale's cancellation by the same roles is INV-014.

Amended 2026-09-26 (owner): nothing is cleared until finance approves it. "When posting payment to airline block, it generates approval which is good, but make sure it shows PNR also in the approval. Also, it shows paid status in the airline block, but it should show something like pending for approval or finance not approved yet … which means nothing should be cleared unless approved first."

  1. A payment to the airline, a refund from it, and the deposit count as paid only once their voucher is approved. Until then the block shows them as Awaiting finance approval, and what is still owed is the cost less the approved payments. A block is Paid only when approved payments cover it.
  2. A payment waiting for approval still counts against the overpayment guard, so it cannot be recorded twice. A rejected voucher counts nowhere, so the correct payment can be recorded.
  3. The voucher memo starts with PNR <pnr> ·. The approvals inbox names the airline block or FIT and its PNR on every voucher that belongs to one, including vouchers recorded before this change.

Enforced by: 20260930230000_block_payments_count_when_approved.sql (fin_seat_purchase_money adds paidApproved, awaitingApproval, outstandingApproved and paymentStatus; paidSoFar and outstanding stay the guard's figures, now without rejected vouchers; fin_block_voucher_status; airline_blocks_screen gives each payment its voucherStatus; record_airline_block_payment puts the PNR first in the memo) and 20260930220000_a_release_without_penalty_is_foc.sql (dash_approvals_inbox, the voucher item). Test: supabase/tests/block_payments_count_when_approved.sql. Screens: Airline blocks, desktop and app.

Not built: an initial-payment adjustment or reversal stays with finance on the desktop; the ticketing roles cannot approve a voucher or decide a cancellation, and the executive cannot approve anything at all. A FIT's amountPaid column is not maintained by the functions (the summary derives what is paid from the supplier transactions).

FIN-043 · A visa is billed by the agent who processed it

Status: DECIDED (2026-09-23) · Owner: Management · Source: the owner, on how visas are actually bought

Visas are sent to an agent — Rawasd Holidays and the like — who charges for the application. What they charge is a cost of the trip and a debt to them, so it is recorded the same way any other bought-in cost is.

  • The agent is named per case. One pilgrim's visa may go to a different agent from the next, so the supplier sits on VisaCase, not on the departure.
  • A refusal costs the same as an approval. The agent charged for the application, not for its outcome. This is the same reason the cancellation policy keeps the visa fee once a visa has been applied for (PRC-021).
  • Only a finished visa is billed. Issued, collected or refused. A case still with the embassy has not been charged for yet, and the system says which case it is refusing over.
  • Nothing is billed twice. A case carries the bill it went out on; billing it again is refused, naming the traveller.
  • The agent's own currency, the agent's own invoice number. The bill is held in riyals for a Saudi agent and rupees for an Indian one, converted for the books at the rate for the day they invoiced (FIN-034). With no rate for that day the bill is refused rather than posted at a guess.
  • Billing is a finance right. finance.create. Moving a visa along (visa.edit) does not let someone put a debt on the books.

Posting: Dr 5500 Visa & Documentation Expenses / Cr the agent's own creditor account (SUP-<id>), pending a second person's approval like every other operational voucher (FIN-032).

Not built: the bill is not recorded as a departure's cost row (FIN-041). One bill can span several departures and a cost row holds one. The per-case VisaCase."supplierCost" is what a departure's visa cost is read from.

Enforced by (20260926200000_a_visa_is_billed_by_its_supplier.sql; supabase/tests/visa_supplier_bill.sql, src/lib/api.visaSupplierBill.test.ts): bill_visa_cases() raises the bill, stamps each case with its own share and posts the voucher in one transaction, so a refused posting leaves no bill and no stamped case. POST /visa/supplier-bills converts the currency and builds the posting. Visa → Pipeline: select the cases, Generate invoice.

Before this a visa case carried no supplier and no cost at all. What the agent was owed existed nowhere in the system, and a departure's visa cost could not be worked out from it.

FIN-042 · A receipt says when, how, and against what

Status: PROPOSED · Owner: Finance · Source: bank reconciliation — a receipt is matched to a statement line, or it is not matched at all

Three facts identify money coming in, and the business knows all three at the counter. None of them has a default:

  • The value date — the day the money arrived. It is what dates the receipt voucher, not the day someone got round to keying it. Payment.receivedDate is NOT NULL, cannot be in the future, and must fall in an open period.
  • The mode — cash, bank transfer, cheque, card or UPI. A receipt is not assumed to be cash; the commonest thing on a bank statement is the one thing that is not on it.
  • The instrument — the UTR, cheque number, card authorisation or UPI reference. Required for every mode but cash, which leaves no instrument.

record_payment() and record_payment_allocated() — the two ways a cashier takes money in — refuse a receipt missing any of the three. The internal paths that create a payment row for something that IS happening now (a refund payout, a customer's own submission, the allocation of an existing receipt) take the IST day from the column default.

Enforced by (20260924115000; supabase/tests/receipt_value_date.sql, src/lib/api.receiptIdentity.test.ts): fin_assert_receipt_identified(), fin_payment_method_needs_reference(), the NOT NULL on Payment."receivedDate", and receiptIdentity() in src/lib/api.ts so the screen shows the refusal against the field. RecordPaymentDialog has a Received on date and requires the instrument reference for a non-cash mode.

Before this the columns existed and nothing asked for any of them. Each had a silent default that looked like an answer — no date meant today, no mode meant CASH, no reference meant none whatever the mode — and the dialog had no date field at all. Every receipt in the 29 Aug 2026 replay landed on the departure date, and bank reconciliation was impossible from the register alone (audit gap G10).

FIN-041 · A bought-in cost is entered once

Status: PROPOSED · Owner: Finance · Source: one source document, one entry

A supplier's bill against a departure is that departure's cost. There is one document, so there is one entry.

  • The bill names the departure (SupplierTransaction.groupId), on the form where the bill is recorded. record_supplier_bill_for_group() writes the departure's cost row with it, in the same call, linked to the bill.
  • The cost row posts nothing. The bill already posted it; the row exists so the departure's cost report sees a cost the ledger already holds, and the bill's voucher is tagged to the departure so the two books agree (FIN-035).
  • Typing it a second time is refused. An expense on a departure that repeats an unlinked bill of the same amount and currency within a fortnight is refused, naming the bill to record it against instead.
  • A bill is one departure's cost, once. A unique index, and a refusal that says so.
  • Permissions: recording the bill is suppliers.edit; writing a departure's cost is groups.edit. Doing both in one call needs both, and widens neither.

One amendment to FIN-031. An approved voucher is never edited — but the departure dimension is not an edit. JournalEntry.groupId may be filled in on an approved voucher, once, from nothing to a departure; it may never be changed and never removed, and nothing else about an approved voucher moves. A supplier bill is posted before anyone says which departure it is for, and is often approved the same day; without this the ledger and the cost report could not agree on a cost they both hold.

Enforced by (20260924114000; supabase/tests/supplier_bill_group_cost.sql, src/lib/api.supplierBillGroupCost.test.ts): SupplierTransaction.groupId, record_supplier_bill_for_group(), the GroupExpense_duplicate_bill_guard trigger, and the narrowed clause in fin_journal_entry_guard().

Before this, computeGroupCostBreakdown() read GroupExpense and never SupplierTransaction, so the business had to record a bought-in cost twice — once as the bill, once as the departure's cost. ₹2,70,894 of catering on the 29 Aug 2026 departure was in the company P&L twice. 20260923100100 stopped the double posting when the two are linked; nothing stopped an operator typing it twice without the link (audit T3, gap 12).

FIN-040 · A receipt is the money that arrived

Status: PROPOSED · Owner: Finance · Source: bank reconciliation — a receipt must match a line on the statement

One transfer is one receipt, whatever it settles. A sub-agent paying one lump for pilgrims spread over several bookings has made one payment, and the books must show that payment, for that amount, on that date, with that instrument reference.

  • One Payment row for the money, PaymentAllocation rows for what it settles. A receipt over several bookings carries no single bookingId; a receipt against one booking still names it, so everything that reads that field is unchanged.
  • A receipt is one party's money. Every allocation must resolve to the same receivable ledger (fin_payer_ledger_code). A receipt spanning two payers is refused rather than merged — that is two receipts.
  • One voucher, against the payer: Dr 1000/1010 · Cr <the payer's receivable> for the whole amount. Not one voucher per booking.
  • Each booking still shows its own share. fin_booking_receipts_total() counts a booking's own receipts plus its share of an allocated one, never twice, and verifying a receipt refreshes every booking it settles.
  • Maker-checker is unchanged, and lighter (FIN-032): one receipt is recorded by the cashier, lands pending, and takes one verification instead of one per invented half.
  • A split receipt is refunded from one of its bookings, never as a whole. The refund request names the allocation the money comes back from, and carries that booking (or group invoice). Approval posts Dr <the payer's receivable> · Cr <bank> and the refund row comes off that booking's paid amount only. The cap is the least of: what is left of the receipt; what the receipt put into that booking, less what it has already refunded or has waiting to it; and what the booking was overpaid (PRC-030). A request on a split receipt that names no booking is refused. A booking paid through a split receipt can also be refunded at booking level (request_booking_refund), as PRC-030 counts its share.

Enforced by (20260924113000; supabase/tests/one_receipt_many_bookings.sql, src/lib/api.oneReceipt.test.ts): PaymentAllocation, record_payment_allocated() (which record_payments_batch() and POST /finance/payments/batch now are), fin_post_payment_receipt(), fin_booking_receipts_total() and fin_refresh_receipt_targets(). Refunds of a split receipt (20261004190000_a_split_receipt_is_refunded_from_a_booking.sql, supabase/tests/a_split_receipt_is_refunded_from_a_booking.sql): refund_payment(…, p_allocation_id), fin_refundable() and refund_preview(), which lists a split receipt's bookings with what each can give back. Before this, a refund of a split receipt named no booking and approve_refund() always failed, and a booking paid through one had nothing refundable (#496). Who reads an allocation (ACC-010; 20260929180000_an_allocation_is_read_by_its_owner.sql, supabase/tests/an_allocation_is_read_by_its_owner.sql): staff holding finance.view, bookings.view or agents.ledger.view; the customer of the allocated booking (the allocation's booking, the receipt's booking, an allocated invoice's booking, or a group invoice they pay); the partner whose agency owns the booking or invoice. Nobody else — a tour leader reads none, anon has no privilege on the table. Before this the table answered every signed-in login (USING (true)), so a customer or partner could read every receipt's allocations and amounts although the receipts themselves were scoped.

Before this, Payment.bookingId took exactly one booking. ABE ZUM ZUM's ₹9,15,000 on the 29 Aug 2026 departure had to be split by hand into two halves that never happened, or posted against a group invoice the business never raises; the party ledger showed neither the money that arrived nor anything matching the bank statement (audit gap G5). record_payments_batch() (20260919100900) made the split atomic, which was right for the failure it addressed — a browser firing one POST per booking and stopping half way — but the invented halves were the design.

FIN-039 · A per-traveller service charge is the company's own income

Status: PROPOSED · Owner: Finance · Source: Ind AS 115 / AS 9 (a distinct performance obligation, billed and earned separately)

The business charges a flat amount a head for taking the booking — ₹700 on the 29 Aug 2026 departure, ₹17,500 over its 25 travellers. It is not the sale of a trip. It is what the company charges for its own work, and it is earned when the booking is taken.

  • It is set on the departure, not typed on every booking: TravelGroup.serviceChargePerPax (and serviceChargeLabel, for what the document calls it). A booking taken on that departure inherits perPax × travellers into Booking.serviceChargeAmount, and a caller that states an amount keeps it.
  • It is inside the price the customer pays. One price is billed, so the receivable is the whole of it; what changes is which income head recognises which part. The charge is never more than the price it sits inside — the group leader billed ₹1 carries ₹1 of it, not ₹700.
  • It is recognised on 4150 Service Charges, on a voucher of its own (booking_service_charge), and 4000 Sales / Booking Revenue carries only the trip: totalAmount − serviceChargeAmount. post_booking_revenue() refuses a posting that recognises the charge as package revenue.
  • A cancellation does not give it back (and see FIN-038). It has its own reference type, which is not in the set the cancellation reverses, so it survives untouched. The refundable part of a sale is the price less the charge, which is what the reversal is taken against; and the retention voucher reclassifies only what is still in Package Revenue, so the company never books the same rupee of income twice.

Enforced by (20260924112000; supabase/tests/service_charge.sql, src/lib/api.serviceCharge.test.ts): the two group columns, the Booking_service_charge_default trigger, Account 4150, FinanceConfig.serviceChargeIncomeAccountCode, the guard inside post_booking_revenue(), and booking_service_charge in fin_voucher_group_id() so the voucher names its departure. On screen (2026-09-27): the group Edit dialog sets the amount and its invoice label (groups.edit, confirmed with a reason, sent only when it changed), and the group's Overview tab shows it — see Groups.

Before this there was no field for it at all: all ₹17,500 rode inside the package price as the sale of a trip, and a cancellation refunded a share of a charge the business never refunds (audit gap G13, §8.1).

FIN-038 · What a cancellation keeps is not a trip sold

Status: PROPOSED · Owner: Finance · Source: Ind AS 115 / AS 9 (a cancellation fee is not revenue from a service), matching concept

A cancelled trip is not a sale. What the company keeps when a passenger cancels is two different things, and they are taxed differently:

  • the company's own charge for cancelling — the flat amount and the percentage on the policy's band. This is income the company earned for doing nothing but administering a cancellation, and it is taxable in its own right;
  • pass-through cost it had already spent and could not get back — the visa lodged with the consulate, the ticket already issued. Recovering this is not income at all. It reduces the cost the company bore.

The posting rule. On an approved cancellation:

  1. the refunded share of the sale is reversed against the original revenue voucher, as it already is (FIN-031, no stacking);
  2. what is retained is then taken out of Package Revenue and recognised for what it is, in one voucher against the booking:
Dr  4000 Sales / Booking Revenue            the whole retained amount
    Cr  4200 Cancellation Fee Income        the company's own charge
    Cr  <the head each kept item recovers>  the cost recovered

A kept item names the head it recovers (CancellationPolicyComponent.recoveryAccountCode, defaulting by rate-sheet line: visa → 5500, flight/ticket → 5100, hotel → 5200, food → 5300, ground → 5400). A kept item that names no head stays with the company's own charge rather than being guessed at.

  1. recovered cost comes first. Where the charge is capped at the passenger's price, or a manager overrides the refund downwards, the company recovers what it actually spent before it books any fee — and where even that will not fit, each kept item recovers its share and no more, so the voucher still balances.

Nothing of a cancelled trip is left in Package Revenue. The voucher carries the departure tag like every other (FIN-035) and posts through the one operational door on bookings.cancel.approve — the right the approval route already demands (FIN-030). A per-traveller service charge (FIN-039) is not touched by a cancellation at all: it is charged for taking the booking, it was earned when the booking was taken, and its voucher is not in the set the cancellation reverses.

On the 29 Aug 2026 departure: ₹1,15,000 sold, ₹88,000 refunded, ₹27,000 kept — ₹10,000 of fee to 4200 and ₹17,000 of visa credited back to 5500.

Enforced by (20260924111000; supabase/tests/cancellation_retention.sql, src/lib/api.cancellationRetention.test.ts): cancellation_quote() returns feeAmount / recoveredAmount per passenger and totalFee / totalRecovered overall, with the head each kept item recovers; fin_operation_permissions('booking_cancellation_retention') is the gate; fin_voucher_group_id() tags it.

Before this, the whole retention simply stayed behind in 4000 because the reversal only took out the refunded share. The ₹27,000 on the real departure sat in the group's revenue as a trip nobody took, there was no cancellation income anywhere in the books, and the visa the company had paid for was still carried as a cost it bore in full (audit gap G11, §16).

FIN-036 · Revenue follows the price on the sale

Status: PROPOSED · Owner: Finance · Source: Ind AS 115 / AS 9, and FIN-030 A group rate sheet is where a price is suggested. A booking is where one is agreed. Revenue is recognised for what was agreed, and a rate sheet never decides whether revenue posts:

  • a booking that carries a price posts revenue for that amount, rate sheet or not;
  • a booking with no price of its own (the enrol-now-price-later flow) posts nothing, and the group invoice carries that sale;
  • never both, and never neither. The invoice-issue handler declines to post when booking-level journals already exist for the group, so a sale is recognised once. It says so: POST /group-invoices/:id/post answers skipped: 'bookings_already_posted' (or 'already_posted' for an invoice posted before) with a reason, and the invoice dialog shows Nothing to post: the bookings on this group already posted their revenue instead of a success.

An invoice bills the price actually sold. Where the bookings behind it carry their own prices, those prices are the invoice; the rate-sheet computation is recorded alongside as pricingBasis so the document can say which basis it used. A customer is owed the number they agreed to, not an average across the group.

Enforced by (src/lib/api.ts, src/lib/api.groupCosting.test.ts): POST /sales/bookings skips its revenue journal only when totalAmount <= 0; POST /group-invoices prices from Booking.totalAmount when the bookings carry prices and falls back to computeInvoiceTotals() when they do not.

Before this, skipLegacyBookingJournals = Boolean(group.pricing): setting a rate sheet silently switched revenue off for every booking on the group, nothing required the invoice that was meant to carry it to be raised, and the usual outcome was neither. The invoice that would have posted something billed ₹98,672.65 a head against pilgrims sold at ₹1,15,000.

FIN-037 · Stock cannot hold value that no longer exists

Status: PROPOSED · Owner: Finance · Source: Ind AS 2 / AS 2 (net realisable value) Capacity that was bought and can no longer be sold is a cost, not an asset. Once a departure has flown, seats on it that nobody took are written off: they leave Stock-in-Hand and become an expense, with a written reason and a named approver, recorded immutably. A write-off is never anonymous and is never corrected by editing — a wrong one is reversed like any other posted voucher (FIN-031).

Who carries the loss is the owner's decision, in FinanceConfig.unsoldSeatTreatment:

  • departure (default) — charge the departure that bought the seats. On the 29 Aug rehearsal this turns a reported ₹1.78 lakh profit into a ₹21,545 loss, which is the truth: the departure committed to the capacity.
  • central — hold the loss off the departure, for a business that treats unsold block capacity as a trading position rather than a cost of one group.

A block shared by several departures cannot be attributed to one of them without inventing an allocation, so its loss is held centrally and the voucher's memo says why.

A written-off seat leaves availability. It has been paid for, booked as a loss and cannot be flown; selling it again charges the departure twice and puts a pilgrim on a flight that has already gone. The seat stays visible — reported as writtenOffSeats on the block and on inventory_resource_position(), so an auditor still sees the capacity nobody took — but it is not sellable, and an attempt to assign it is refused with a message that says it was written off.

Enforced by (20260922130100, 20260923131000; src/lib/api.ts, supabase/tests/unsold_seat_write_off.sql, src/lib/api.groupCosting.test.ts): POST /inventory/quota-blocks/:id/write-off-unsold posts Dr 5150 Unsold Seat Write-off / Cr 1310 Stock-in-Hand for the block's unsold seats at the contract rate, needs inventory.writeoff.approve and a reason of at least three characters, and records the approver in UnsoldSeatWriteOff — one row per block per departure, so a double-click cannot write the same seats off twice. Recording the row fires inv_sync_unsold_write_off(), which recomputes the block's derived counters (INV-013): written-off seats join withdrawnSeats, the set that already held seats cancelled or lost with the airline. The counter is derived, so reversing a wrong write-off returns the seat to sale.

Before this, UnsoldSeatWriteOff was written and read nowhere: the block went on advertising the same seats after the company had booked them as a loss — ₹1,55,030 on the 29 Aug 2026 departure, still sellable weeks after the aircraft left.

Before this, the only route out of stock was an airline-cancellation filing, which expects a refund and a penalty. The 29 Aug departure bought ₹15,50,300 of seats, charged ₹13,50,860 to the group, and the ₹1,99,440 difference appeared in no profit and loss at all.

FIN-047 · A group's cost report reconciles to its ledger, or says why not

Status: PROPOSED · Owner: Finance · Source: FIN-033 · Numbered FIN-038 until 2026-10-02, when it was found to share that number with "What a cancellation keeps is not a trip sold" The group cost report reads operational records; the ledger reads vouchers. Where the two differ, the report names every rupee of the difference and reports what it cannot account for. Silence is not agreement.

Accounted-for causes are: vouchers posted but not yet approved by a second person (FIN-032), which are in the report and join the ledger on approval; unsold-seat write-offs (FIN-037), which are a real cost that no operational record represents, so they are in the ledger and not the report; and operational records whose accounting entry was refused and is queued in Finance → Unposted accounting entries (FIN-030), whose cost is in the report and missing from the ledger until finance retries them. Anything left over is reported as unexplained.

Enforced by (20260922130000; src/lib/api.ts): finance_group_ledger_cost() sums approved expense postings per departure in SQL, so the ledger half can never be cut short by PostgREST's row cap; GET /groups/:id/expenses and GET /groups/:id/financial-summary both return a reconciliation block carrying both totals, the difference, the named causes and unexplained.

Before this the two books simply never met, and on the 29 Aug departure they disagreed by ₹13,45,100 — ₹25,73,346 against ₹12,28,246 — with nothing in the product warning anyone. To get catering into both, it had to be entered twice, and the second copy entered without a payer so it would not post a second voucher.