Skip to content

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 AccountingPeriod throws 400.
  • 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: 403 with 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; otherwise 403 with "₹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 throws 409 — 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 (409 if 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_reversal itself does not refuse the maker. The database's control is at approval: approve_journal_entries never 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 skipped with reason: 'maker_checker_self_approval'.
  • Skips rows that another approver already flipped — returns in skipped with reason: '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 TicketRecord rows for every passenger (generateTicketRecordsForBooking).
  • Auto-create the persistent Invoice row (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/status fails, 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).