Finance API
The finance surface is the largest section of src/lib/api.ts — vouchers, journals,
payments, invoices, period management, tax returns and reports.
Every write handler begins with await requirePermission(...), but that runs in the
browser. Since Wave 1B the money paths are thin wrappers over SECURITY DEFINER database
functions that check the permission again, take the actor from the session, enforce
maker-checker and apply the period lock. Where the two disagree, the function wins
(ACC-001).
Read alongside: Receipts and refunds, Journals, Maker-checker, Finance reports.
One call per screen (/finance/screen)
Handler: handleFinanceScreen in src/lib/api.ts; database: finance_screen(p_sections)
(PRF-010, migration 20260930170000_finance_is_one_call.sql).
GET /finance/screen?parts=<comma list> answers every part a finance tab shows on first paint
in one request. Each part has exactly the shape of the route in brackets; a part the caller
may not read is left out and named in denied (where that route answered 403).
GET /finance/screen?parts=payments,periods,currencies
→ { "payments": [...], "periods": [...], "denied": ["currencies"] }
| Part | Same answer as | Checked in the database |
|---|---|---|
payments |
GET /finance/payments |
finance.view |
periods |
GET /finance/periods |
staff beyond field work (the AccountingPeriod policy) |
users |
GET /users — only id, fullName, email |
yourself, or staff beyond field work |
config |
GET /admin/finance-config — read only |
staff beyond field work |
accounts |
GET /finance/accounts |
finance.view and the Account policy |
customers |
the newest 999 live customers — id, firstName, lastName only |
the Customer staff policy |
suppliers |
active suppliers — id, name, category only |
the Supplier policy |
agents |
live partners — id, name, company only |
the Agent staff policy |
trialBalance, balanceSheet |
GET /finance/trial-balance, /balance-sheet (no filters) |
finance.view |
aging |
GET /finance/aging |
finance.view |
paymentAccounts |
{ accounts: GET /accounts?includeInactive=true, movement: { accountId: sum } } — the balance is openingBalance + movement |
the Account policy; the sums need finance.view |
currencies |
GET /admin/currencies |
admin.currency.view |
journals |
GET /finance/journals (no filters: the newest 500; the register pages every voucher with ?paged=1, see Journals) |
finance.view |
journalsPending |
GET /finance/journals/pending |
finance.view |
refundsRequested |
GET /finance/refunds?status=requested |
finance.view |
airlineCancellationsPending |
GET /finance/airline-cancellations?status=pending_refund |
finance.view |
b2bCancellationsPending |
GET /finance/b2b-cancellations?status=pending_approval |
finance.view |
ledger |
GET /finance/ledger (no filter) |
finance.view |
invoices |
GET /finance/invoices |
the Invoice staff policy |
finance_screen is SECURITY DEFINER and not executable by anon. It writes nothing: the
config part reads FinanceConfig as it is, where GET /admin/finance-config also re-asserts
the chart's head accounts (postings still do that, through getFinanceConfig()).
These routes answer from one database call and make no browser-side permission check first — the function checks the same permission, and a refusal is the same 403:
| Route | Function |
|---|---|
GET /finance/payments |
finance_screen(['payments']) |
GET /finance/journals (no query string), /finance/journals/pending |
finance_screen |
GET /finance/journals/:id |
finance_journal_screen — a missing voucher is the 400 PGRST116 it always was |
GET /finance/accounts/:id/ledger |
finance_ledger_screen |
GET /finance/receivables-payables |
finance_receivables_payables_screen (summed in the database) |
GET /finance/trial-balance, /balance-sheet, /profit-loss, /cash-flow |
finance_account_totals (checks finance.view) |
GET /finance/day-book |
finance_day_book_page (checks finance.view) |
GET /finance/pin/status |
finance_pin_status (reads the caller from the session) |
On a database without these functions each route falls back to its table reads, with the browser check first, as before.
Journals
Handler: handleFinanceJournals at src/lib/api.ts:21591.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/journals |
GET | finance.view |
List journal entries. Every row carries groupId — the departure the voucher belongs to (FIN-035) — and ?groupId= filters to one departure. GET /groups/:id/ledger is the same list scoped to a departure |
/finance/journals |
POST | finance.create |
Create a manual voucher — always lands pending |
/finance/journals/pending |
GET | finance.view |
The approval queue |
/finance/journals/:id |
GET | finance.view |
One voucher, its lines and its reference label |
/finance/journals/:id/approve |
POST | finance.journals.approve |
approve_journal_entries |
/finance/journals/:id/reject |
POST | finance.journals.reject or .approve |
reason required |
/finance/journals/:id/reverse |
POST | finance.journals.reverse or finance.create |
post_journal_reversal |
/finance/journals/approve-bulk |
POST | finance.journals.bulk_approve |
one call, per-row skip reasons |
Journal approval is no longer approvals.approve
It is finance.journals.approve. Sales and operations managers hold
approvals.approve for bookings and were revoked from voucher approval
(ACC-012). The break-glass overrides
finance.journals.approve_own and .reverse_own are held only by SUPER_ADMIN.
A hand-written voucher above ₹25,000 needs an approver holding
finance.approvals.high_value (ACC-030).
POST /finance/journals — create
Input — { referenceType?, referenceId?, memo?, entryDate?, createdBy?, lines: [{ accountId, debit, credit }] }.
Lines must sum to zero (total debit === total credit).
Output — the inserted JournalEntry with its voucherNo minted from
FinanceConfig.journalVoucherPrefix.
Notes:
- New entries default to
status='pending'(the approval-gated default). - Posting into a locked/closed
AccountingPeriodthrows400. - The voucher number is minted server-side — do not pass one.
POST /finance/journals/:id/approve
Cite: src/lib/api.ts:22022.
- Idempotent: already-approved entry returns
{ ok: true, alreadyApplied: true }. - Conflict: a rejected entry returns
409 Conflict. - Maker-checker: the creator cannot approve their own entry unless they have
finance.journals.approve_own. Nor can the maker of the voucher a reversal reverses approve that reversal:403with the database's message "Maker-checker: you created the voucher this reverses (ACC-020). Ask a different person." - Approval limit (ACC-030): above ₹25,000 a voucher a
person wrote (journal, contra, on-account receipt, reversal on custom lines) needs
finance.approvals.high_value; otherwise403with "₹30,000 is above your approval limit for journals (₹25,000). It needs CEO or GM." - Concurrency: the UPDATE is conditional on
status='pending'. A lost race re-fetches and either returns the idempotent success or throws409— no duplicate audit row.
POST /finance/journals/:id/reject
Cite: src/lib/api.ts:22071. Same maker-checker + concurrency
pattern as /approve. Cannot reject an already-approved entry (reverse it instead).
POST /finance/journals/:id/reverse
Cite: src/lib/api.ts:21878. Creates a new JournalEntry with
isReversal=true, reversalOfEntryId=<original>, and swapped debit/credit lines.
Only approved journals can be reversed
Pending entries haven't posted to live balances — reversing them would drive balances
negative. The handler throws 400 if the original is pending or rejected.
Rules enforced:
- Cannot reverse a reversal (would double-swap).
- Cannot reverse a journal that already has a reversal (
409if a duplicate reversal now exists after a concurrent race; the losing row is rolled back). - Maker-checker: creator cannot reverse own entry unless they have
finance.journals.reverse_own—403"Maker-checker: you cannot reverse a voucher you created (ACC-020). Ask a different person." This route's check is screen-level guidance;post_journal_reversalitself does not refuse the maker. The database's control is at approval:approve_journal_entriesnever lets the original's maker approve the reversal (ACC-020). - Recomputes booking balance if the original was booking-scoped (
booking,booking_gst,booking_commission, etc.).
POST /finance/journals/approve-bulk
Cite: src/lib/api.ts:21670. Body: { ids: string[] }.
- Skips own-authored entries (maker-checker) — returns them in
skippedwithreason: 'maker_checker_self_approval'. - Skips rows that another approver already flipped — returns in
skippedwithreason: 'concurrent_transition'. - Returns
{ ok, approved, approvedIds, skippedIds, skipped }so the UI can route skipped entries to a different approver.
Payments
Handler: handleFinancePayments at src/lib/api.ts:11955.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/payments |
GET | finance.view |
List payments (paged) |
/finance/payments |
POST | finance.payments.record |
record_payment — the row is always pending; receivedDate, method and (non-cash) reference are required (FIN-042) |
/finance/payments/batch |
POST | finance.payments.record |
record_payments_batch — one receipt allocated over several bookings of the same payer (FIN-040) |
/finance/payments/:id |
GET, PATCH | finance.view / finance.edit |
read; annotate notes and receipt URL only |
/finance/payments/:id/verify, /reject |
POST | finance.payments.verify (.reject) |
verify_payment — the recorder cannot verify |
/finance/payments/:id/refund |
POST | finance.payments.refund |
refund_payment — raises a request, not a payout. Body {amount, reason, accountId?, accountReason?, reference?, allocationId?}; allocationId is required for a receipt split over several bookings and names the one the money comes back from (FIN-040) |
/finance/payments/:id/refund-preview |
GET | finance.view |
what is refundable: {refundable, defaultAccountId, split}; a split receipt answers refundable: 0 and allocations: [{id, bookingId, bookingNo, groupInvoiceId, invoiceNumber, amount, refundable}] |
One receipt, several bookings
POST /finance/payments/batch takes allocations: [{ bookingId | groupInvoiceId | invoiceId, amount, notes? }]
plus the receipt's own method, reference, sourceAccountId, receivedDate
and notes. It records one Payment for the money that arrived and one
PaymentAllocation per booking it settles, in one transaction, and returns
{ payment, allocations, count, total, payerLedger } (payments keeps the old
list shape, with that single receipt in it).
Every allocation must belong to the same payer — a receipt spanning two parties
is refused with 400 … cannot be split across two payers. GET
/finance/payments carries allocations on each row, so the register shows the
real ₹9,15,000 transfer rather than two invented halves. Maker-checker is
unchanged: the receipt lands pending and one verification settles the whole
of it, posting one voucher against the payer's receivable.
Currency
POST /finance/payments takes currency (default INR). When it differs from the
booking's currency the handler converts amount into the booking's currency at the rate
in force on receivedDate and sends the original currency, amount and rate with it; with
no rate it refuses with 400 No exchange rate found for … Add one in Finance → Settings →
Exchange Rate Management before recording this payment. (FIN-046). The batch route does
not convert: its allocations are in the receipt's currency, and each booking counts its
share in its own currency. No exchange gain or loss is posted on a receipt (FIN-034).
/verify posts the voucher in INR (FIN-034):
the rupees that arrived, or the foreign amount at the receipt's stored INR rate, else the
rate in force on receivedDate, with the original currency, amount and rate on the
voucher. With no rate it refuses (check_violation, "No USD to INR exchange rate on or
before …. Add one under Finance → Settings → Exchange Rate Management, then verify
again.") and the receipt stays pending.
| /finance/refunds | GET | finance.view | refund requests |
| /finance/refunds/:id/approve, /reject | POST | finance.refunds.approve | approve_refund — the requester cannot approve, and the amount limit applies |
| /sales/bookings/:id/refund | POST | finance.payments.refund | request_booking_refund |
There is no way to record a verified payment. Payment_guard overwrites the status, the
verifier, the receipt number and createdBy on every insert that arrives from a browser
session, and on update allows only notes and receiptUrl (plus reference,
sourceAccountId and method while the row is still pending).
Details, including the receipt date rule, the 30-second double-click guard, the refund cap and which account a refund is paid from: Receipts and refunds.
Exchange rates
FIN-046.
A browser session reads exchange_rates and writes nothing to it; every write is one of the
two functions below.
| Route / function | Method | Permission | Notes |
|---|---|---|---|
/finance/fx-rates |
GET | finance.view |
?date= (default today): per active currency, the rate on that date, else the newest one in force on or before it |
/finance/fx-rates/history |
GET | finance.view |
?currency=&from=&to= |
/finance/fx-rates/refresh |
POST | finance.rates.manage |
Fetches the live feed and stores each <currency> → INR rate through set_exchange_rate as an automatic rate for today. Was finance.edit |
set_exchange_rate(p_currency, p_rate, p_effective_date, p_reason, p_to_currency, p_expiry_date, p_is_manual, p_source, p_rate_id) |
RPC | finance.rates.manage |
Date defaults to today in IST; after today refused; before today needs p_reason (3+ characters), as does changing a row dated before today. Without p_rate_id it replaces the pair's row of the same day and kind, else inserts and closes the pair's older open-ended rates on its date. An automatic rate never replaces a manual one of the same day — answers {skipped: true}. Returns the row. Audited (exchange_rate_added / exchange_rate_changed) |
delete_exchange_rate(p_rate_id, p_reason) |
RPC | finance.rates.manage |
A rate dated before today needs a reason. The rate it had closed runs on again. Audited (exchange_rate_deleted) |
Finance vouchers
Handler: handleFinanceVouchers at src/lib/api.ts:11124.
Each voucher posts a balanced journal entry and records a ledger/SupplierTransaction row. All use the voucher idempotency key pattern.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/vouchers/customer-on-account-receipts/:customerId |
GET | (read-only) | List customer on-account receipts + their allocations |
/finance/vouchers/customer-on-account-receipts/:id/allocate |
POST | finance.edit |
Apply an on-account receipt to a specific booking/invoice |
/finance/vouchers/customer-on-account-receipt |
POST | finance.edit |
Record a customer on-account receipt (cash/bank DR, customer receivable CR) |
/finance/vouchers/supplier-refund-receipt |
POST | finance.create |
Record a refund received from a supplier |
/finance/vouchers/agent-on-account-receipt |
POST | finance.create |
Agent on-account receipt |
/finance/vouchers/agent-receipt-allocate |
POST | finance.payments.record |
Allocate an agent receipt to specific bookings |
/finance/vouchers/debit-note |
POST | finance.create |
DR party-ledger / CR offset (customer, agent, or supplier) |
/finance/vouchers/credit-note |
POST | finance.create |
DR offset / CR party-ledger |
POST /finance/vouchers/customer-on-account-receipt
Cite: src/lib/api.ts:11307.
Input — { customerId, amount, method: 'CASH'|'BANK_TRANSFER'|...,
sourceAccountId?, reference?, transactionDate?, currency? }.
Journal — DR Cash/Bank (or sourceAccountId), CR Customer Receivable (1200 sub-ledger).
Idempotency — keyed on (customerId, amount, method, sourceAccountId, reference,
transactionDate). A retry returns the original receipt with deduplicated: true.
POST /finance/vouchers/debit-note + /credit-note
Cite: src/lib/api.ts:11561.
Input — { partyType: 'customer'|'agent'|'supplier', partyId, amount, offsetAccountId,
description?, transactionDate?, reference? }.
- Debit note — DR party-ledger / CR offset (increases what they owe us, or reduces what we owe them).
- Credit note — DR offset / CR party-ledger (reduces what they owe us, or increases what we owe them).
Entries post as status='pending' and wait for finance approval before affecting live
balances.
GL Accounts (chart of accounts)
Handler: handleFinanceAccounts at src/lib/api.ts:20250.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/accounts |
GET | (read-only) | Chart of accounts tree |
/finance/accounts |
POST | finance.create |
Create GL account (respects parent / group rules) |
/finance/accounts/:id |
PATCH | finance.edit |
Update GL account |
/finance/accounts/:id/opening-balance |
PUT | finance.create or finance.edit |
set_account_opening_balance — posts an OB- voucher pending, for a second person to approve; restating reverses the previous one |
/finance/accounts/opening-balances/import |
POST | finance.edit |
Bulk import opening balances |
/finance/accounts/:id/ledger |
GET | (read-only) | Account ledger with running balance |
/finance/accounts/group/:id/ledger |
GET | (read-only) | Consolidated ledger across a group's child accounts |
Legacy /accounts surface
Handler: handleAccounts at src/lib/api.ts:16589.
Historically a parallel "financial account" model (bank / cash / digital wallet abstracted
from the GL chart). Create/edit endpoints now throw deprecation errors — all GL account
operations go through /finance/accounts.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/accounts |
GET | (read-only) | List active GL accounts marked as payment/cash accounts |
/accounts |
POST | deprecated — throws 400 |
Use /finance/accounts |
/accounts/:id |
PATCH | deprecated — throws 400 |
Use /finance/accounts |
/accounts/:id |
DELETE | accounts.delete |
Refuses an account with children or any journal line. ?force=true answers 410 — deactivate instead |
/accounts/:id/ledger |
GET | (read-only) | Accounting-style ledger (Dr/Cr/balance) |
/accounts/:id/transactions |
GET | (read-only) | Raw journal-line transactions |
/accounts/:id/transactions |
POST | finance.create |
Manual contra posting |
/accounts/transfers |
POST | finance.create |
Contra transfer — DR toAccount / CR fromAccount |
/finance/settlements/any-to-any |
POST | finance.create |
Cross-ledger settlement (customer credit ↔ supplier payable, write-offs) |
Cross-ledger settlements inherit approval-pending
POST /finance/settlements/any-to-any posts status='pending' and waits in the
Approvals queue. See src/lib/api.ts:16854.
Periods (period lock)
Handler: handleFinancePeriods at src/lib/api.ts:20957.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/periods |
GET | (read-only) | List accounting periods |
/finance/periods |
POST | finance.edit |
Create a period |
/finance/periods/:id |
PATCH | finance.edit |
Edit a period |
/finance/periods/:id/lock |
POST | finance.edit |
Lock — reject new postings to the period |
/finance/periods/:id/unlock |
POST | finance.edit |
Unlock (if not yet closed) |
/finance/periods/:id/close |
POST | finance.edit |
Close — permanent, rolls retained earnings |
The lock is applied by the database on insert and on update, keyed on the voucher's
entryDate — the day the transaction belongs to, not the day it was keyed in. It previously
resolved the period from createdAt. record_payment applies the same check to
receivedDate. src/lib/api.ts mirrors it so the screen can warn early; the database is
what refuses.
Locking, unlocking and closing each need their own permission and stamp who did it. A closed
period cannot be changed at all, and a financial year closed through close_financial_year
cannot be reopened. finance.periods.close and finance.years.close are super-admin only.
Ledger utilities
Handler: handleFinanceLedger at src/lib/api.ts:21163.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/ledger |
GET | finance.view |
LedgerEntry rows, paged |
/finance/ledger |
POST | finance.ledger.rebuild |
Rebuild derived rows (rebuild_quota_blocks, rebuild_all) — super-admin only |
/finance/ledger |
DELETE | — | Answers 410. The "Clear All Ledger Data" wipe was removed (FIN-031, AUD-002); posted vouchers are permanent and corrections are reversals |
Re-posting booking revenue was taken out of rebuild_all because it duplicated revenue.
Booking finance lifecycle
Handler: handleFinanceBookings at src/lib/api.ts:13767.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/bookings/:id/finance-approve |
POST | approve: approvals.approve or finance.bookings.approve_finance; reject (status: 'REJECTED'): approvals.approve or finance.bookings.reject_finance — the same rights finance_decide_booking accepts |
Approve or reject a booking after ops approval — finance_decide_booking |
/finance/bookings/:id/send-back |
POST | approvals.approve or finance.bookings.reject_finance |
Send it back for correction, reason required |
The route pattern matches only those two actions; anything else is a 404. The approver is the signed-in user and cannot be the person who created the booking (LC-010).
/finance/bookings/:id/mark-paid was removed
It closed a balance by inserting a verified cash payment for the remainder, with no receipt voucher and no second person (FIN-032). Record a real payment and have someone else verify it — Receipts and refunds.
Idempotency — if the booking is already in the requested finance state,
finance-approve returns { ok: true, alreadyFinalized: true } without re-posting
journals or audit rows.
Side-effects on approval (non-rejection):
- Auto-generate
TicketRecordrows for every passenger (generateTicketRecordsForBooking). - Auto-create the persistent
Invoicerow (ensureBookingInvoice) — idempotent via bookingId +referenceType='booking'. - Push-notify ops team via
sendPushToUsers.
Tax returns
TDS (Section 26Q)
Handler: handleFinanceTds at src/lib/api.ts:24289.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/tds/summary |
GET | finance.tds.view |
TDS deductions summary |
/finance/tds/deductions |
GET | finance.tds.view |
Deduction-level detail |
/finance/tds/26q |
GET | finance.tds.export |
Generate Section 26Q CSV for filing |
Deductions are triggered inline in supplier-payment flows when the supplier has a TDS rate
set — see handleSuppliers at src/lib/api.ts:17650 which calls
requirePermission('finance.tds.deduct').
GST
| Route | Method | Permission | Handler | Purpose |
|---|---|---|---|---|
/finance/gst-summary |
GET | finance.reports.gst_summary.view |
handleFinanceGstSummary |
Output/input GST totals for a period |
/finance/gstr-1 |
GET | finance.reports.gst_summary.view |
handleFinanceGstr1 |
GSTR-1 return JSON (outward supplies) |
/finance/gstr-3b |
GET | finance.reports.gst_summary.view |
handleFinanceGstr3b |
GSTR-3B return JSON |
Financial reports
| Route | Method | Permission | Handler | Purpose |
|---|---|---|---|---|
/finance/day-book |
GET | (read-only) | handleFinanceDayBook |
Day book by date |
/finance/trial-balance |
GET | (read-only) | handleFinanceTrialBalance |
Balanced trial balance |
/finance/balance-sheet |
GET | (read-only) | handleFinanceBalanceSheet |
Assets / liabilities / equity |
/finance/profit-loss |
GET | (read-only) | handleFinanceProfitLoss |
P&L with expense categorisation |
/finance/cash-flow |
GET | (read-only) | handleFinanceCashFlow |
Cash flow statement |
/finance/account-statement |
GET | (read-only) | handleFinanceAccountStatement |
Per-account running statement |
/finance/pl-summary |
GET | (read-only) | handleFinancePlSummary |
Compact P&L summary for Dashboard |
/finance/group-profitability |
GET | (read-only) | handleFinanceGroupProfitability |
Per-group P&L |
/finance/aging |
GET | (read-only) | handleFinanceAging |
AR aging (customer receivables by bucket) |
/finance/aging-payables |
GET | (read-only) | handleFinanceAgingPayables |
AP aging (supplier payables) |
/finance/aging-report |
GET | (read-only) | handleFinanceAgingReport |
Combined AR+AP aging |
/finance/receivables-payables |
GET | (read-only) | handleReceivablesPayables |
Net receivable vs payable |
/finance/settlement-report |
GET | (read-only) | handleFinanceSettlementReport |
Settlement activity summary |
/finance/settlement-ledgerwise |
GET | finance.reports.settlements.view |
handleFinanceSettlementLedgerwise |
Settlements grouped by ledger |
/finance/cancellation-report |
GET | finance.reports.settlements.view |
handleFinanceCancellationReport |
Airline + B2B cancellations |
/finance/pending-settlements |
GET | (read-only) | handlePendingSettlements |
Unsettled supplier/partner rows |
/finance/supplier-transactions |
GET | finance.view |
handleFinanceSupplierTransactionsAll |
All supplier transactions |
/finance/years |
GET, POST | finance.edit |
handleFinanceYears |
Fiscal years management |
/finance/airline-cancellations |
GET, POST | finance.create |
handleAirlineCancellations |
Airline cancellation postings |
/finance/b2b-cancellations |
GET, POST | GET finance.view; POST finance.create or inventory.b2b_cancellations.file |
handleB2BCancellations |
B2B partner cancellation filing: {offerId, seats, refundAmount, buyerCancellationCharge, cancelWithSupplier?, supplierCancellationCharge?, supplierRefund?, supplierApprovalRef?, supplierNotes?, filedNotes?, reason?} → {ok, cancellationId, status: 'pending_approval', filingJournalEntryId: null}. A caller without finance.create who holds inventory.b2b_cancellations.file is sent through DB file_b2b_cancellation (one transaction; the answer adds alreadyFiled) — INV-014. /:id/approve and /:id/reject stay finance.cancellations.approve_b2b (decide_b2b_cancellation; never the filer) |
/finance/tickets |
GET | (read-only) | handleFinanceTickets |
Ticketing-finance cross-report |
Invoices (persistent document)
Handler: handleFinanceInvoices at src/lib/api.ts:26111.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/invoices |
GET | (read-only) | List invoices |
/finance/invoices |
POST | finance.create |
Create invoice manually |
/finance/invoices/:id |
PATCH | finance.edit |
Edit invoice |
/finance/invoices/schedule |
POST | finance.create |
Schedule recurring invoice |
Auto-issuance on finance-approve
Most invoices are created automatically by handleFinanceBookings when finance
approves a booking. Manual POST /finance/invoices is reserved for back-office
corrections.
Unposted accounting entries (posting failures)
Handler: handleFinancePostingFailures in src/lib/api.ts.
A row in PostingFailure is a business record that exists with no journal behind it,
because the posting failed somewhere it could not be rolled back — a passenger cancellation
whose seats have already been released, a transfer that has already moved, a B2B sale whose
seats are already gone. Anywhere the row can be rolled back (booking, airline block and
hotel creation) the operation fails instead and nothing is written, so nothing reaches this
queue. See FIN-030.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/posting-failures |
GET | finance.view / bookings.view / approvals.view (RLS) |
Open failures (?status=all for history) |
/finance/posting-failures/:id/retry |
POST | finance.create |
Re-post the lines the failure recorded (retry_posting_failure) |
/finance/posting-failures/:id/resolve |
POST | finance.create |
Close one after posting it by hand |
The response is { open, rows }. Each row carries the operation, what it was posting
against, the error, and the payload the posting was attempted with — enough to re-post it
as a manual voucher. The card at the top of the Finance page shows the open ones.
Retry
POST /finance/posting-failures/:id/retry re-posts the vouchers the failure recorded at
payload.retry.vouchers, through the same database path a fresh posting takes: a plain
voucher goes through fin_post_system_voucher and lands pending; anything carrying a
reversalOfEntryId goes through post_journal_reversal, which keeps the reversal cap and
"status follows the original" (FIN-031). On success the row is resolved, with a note naming
how many vouchers posted.
The attempt is recorded either way — retryCount, lastRetryAt, lastRetryBy and
lastRetryError — so a row that keeps failing shows that it has been tried and why it will
not post. A retry of an already-resolved row posts nothing and returns
{ alreadyResolved: true }.
What cannot be retried. A row whose payload carries no retry blob: rows recorded
before this existed, and postings that failed before their accounting lines were built —
an account that could not be resolved, a missing exchange rate. The call refuses those with
"this entry did not record the accounting lines it tried to post", and the card says
Post by hand instead of offering a button that cannot work.
A failure record is evidence
PostingFailure rows cannot be edited or deleted, by anyone — only resolved, which
stamps who closed it and why (FIN-031). Writes to the table are open to any staff user
on purpose: the row is written on the failure path of someone else's action, and if
recording it needed a finance permission the miss would be silent again.
Bank imports & reconciliation
Handler: handleBankImports at src/lib/api.ts:27147.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/bank-imports |
GET | finance.view |
List imports |
/finance/bank-imports |
POST | finance.edit |
Upload a bank statement CSV |
/finance/bank-imports/:id |
GET | finance.view |
One import + its lines |
/finance/bank-imports/:id/lines |
GET | finance.view |
All lines of one import |
/finance/bank-imports/:id/auto-match |
POST | finance.edit |
Auto-match lines to existing payments |
/finance/bank-imports/:id/lines/:lineId/match |
POST | finance.edit |
Manually match one line to a payment |
Stock adjustment
/finance/stock-adjustment — handler handleFinanceStockAdjustment
(src/lib/api.ts:22227). Posts inventory write-downs/up. Permission: finance.create.
Finance PIN (page lock)
Handler: handleFinancePin at src/lib/api.ts:39656.
The PIN locks the whole Finance page, not single actions. FinancePinGate wraps the
page (src/pages/finance/Finance.tsx, src/components/finance/FinancePinGate.tsx):
- On the first visit the user sets a 4-digit PIN. After that the page asks for it before it shows anything.
- An unlock is kept in the browser tab's
sessionStorage. After 20 minutes with no mouse or keyboard activity the page locks again. A new tab asks again. - Five wrong PINs lock PIN entry for 15 minutes.
- Forgot PIN? e-mails a 6-digit code, valid for 15 minutes. The code and a new PIN unlock the page.
- If
/finance/pin/statusfails, the gate is skipped and the page opens.
The PIN is a screen lock in the browser, not a security boundary. Every finance route checks
its own permission. The PIN routes check only that someone is signed in; they do not call
requirePermission.
| Route | Method | Permission | Purpose |
|---|---|---|---|
/finance/pin/status |
GET | signed in | Is a PIN set? Is entry locked? |
/finance/pin/set |
POST | signed in (own PIN) | Set the first PIN; a change goes through reset |
/finance/pin/verify |
POST | signed in (own PIN) | Check a typed PIN |
/finance/pin/request-reset |
POST | signed in (own PIN) | E-mail a reset code |
/finance/pin/reset |
POST | signed in, with the e-mailed code | Set a new PIN |
Finance config
The single-row FinanceConfig (at key id='default') holds prefixes, default currency,
GST rate, and the GL account codes used by every posting helper. Edited via
PUT /admin/finance-config — see Admin. Requires finance.edit.
GET /finance/transactions — the Transactions feed
?paged=1&limit=&cursor=&withTotal=1&q=&type=receipt|invoice|ledger|journal&status=&from=&to=&sort=date|amount|type|status|reference&dir=
— one keyset page of finance_transactions_page (finance.view, checked in the function).
from / to are YYYY-MM-DD, Indian days, both included. Each row: id (pay-…, inv-…,
led-…, jv-…), type, sourceId, date, reference, amount, direction, status,
bookingNo, bookingId, method, receiptNo, memo, narrations, referenceType,
journalEntryId, balanceDue, createdByName, verifiedByName, recordedByCurrentUser
(Journals §5b).