Finance API
Related documentation: Finance Module Backend · Access Control Integration · Error Contract
Purpose
Section titled “Purpose”This page documents the current Finance backend route surface at a practical level.
It is not a line-by-line dump of the Finance repo README. It focuses on the contract and runtime rules that matter for platform work.
Runtime Shape
Section titled “Runtime Shape”- runtime: Node / TypeScript backend
- route style:
src/api/**/route.ts - route namespace:
/api/* - health endpoint:
GET /health
Access Rules
Section titled “Access Rules”Internal tenant routes
Section titled “Internal tenant routes”Protected internal company-scoped Finance routes require:
- valid auth
- valid
x-org - membership / scope from Auth
financeentitlement from Core
If the selected company is not commercially entitled to Finance:
- status:
403 - code:
finance_addon_required
Vendor routes
Section titled “Vendor routes”Vendor routes use the vendor auth flow and vendor JWT claims.
They still operate inside Finance because vendor invoice submission and related operational workflows are Finance-owned.
Company selector
Section titled “Company selector”For many internal routes, the active company comes from:
x-org: <companyId>
Accepted value:
- canonical Core company UUID only
Finance rejects legacy company identifiers. Responses may enrich company/BU display fields from Core; Finance does not own or mirror company master rows.
Main Route Families
Section titled “Main Route Families”Company hard-delete (internal machine)
Section titled “Company hard-delete (internal machine)”Orchestrated by Core/Admin — not browser-facing. Auth: Authorization: Bearer <FINANCE_INTERNAL_API_KEY> (Admin/Venues callers). Xero cron uses PROMOTERS_INTERNAL_KEY (legacy INTERNAL_API_KEY still accepted).
| Method | Path | Purpose |
|---|---|---|
GET | /api/internal/companies/{companyId}/delete-preview | Count company-scoped Prisma rows; return blockers (ACTIVE_XERO_PIPELINE, ACTIVE_XERO_SYNC) |
POST | /api/internal/companies/{companyId}/purge | Hard-delete company rows in dependency order; keep global categories (companyId = null) |
POST body: { "mode": "purge", "force": false, "tombstoneCompanyId": "00000000-0000-0000-0000-000000000001" }. Returns 409 when blockers exist and force is false. Idempotent re-purge is safe.
Examples:
GET /api/admin/companies— rows includefinanceEnabled(Core add-on entitlement, 2026-07-13). Finance FE has no company switcher (2026-07-13): the active company is owned by the user’s base module via the sharedkisum_active_companycookie; Finance follows it (focus re-read) and blocks when unresolvable. The Company hub lives at/settings/company/*; Finance account settings are theme-only — identity (name/email/password) is edited in the user’s base module. SeeFrontend-Kisum-Finance/docs/FRONTEND_X_ORG.md.GET /api/admin/companies/{id}- Both companies routes populate
kisumCompanyLogo: sourced only from the Core company profilelogoUrl(the same logo the Promoters app shows);nullwhen no profile logo exists. The Finance FE sidebar company row renders the logo (initials fallback). GET /api/admin/business-units/by-companyGET /api/admin/business-units— read-only;POST|PUT|DELETEreturn410 org_master_write_removed- vendor / category / stats / audit-log / sync-error admin routes
Important rule:
- company master writes are removed from Finance
- business-unit writes are removed from Finance
- business-unit master writes happen in Core, driven by the persona app — never by Finance
Examples:
GET /api/billsGET /api/bills/{id}GET /api/bills/{id}/signed-pdfPOST /api/billsPATCH /api/bills/{id}
These are Finance-owned expense/AP workflows.
Document body — Phase A (2026-07-22)
Section titled “Document body — Phase A (2026-07-22)”POST /api/bills and POST /api/income accept line items. The four Xero
code fields are gone; send native foreign keys instead.
| Removed | Send instead | Value |
|---|---|---|
xeroAccountCode | ledgerAccountId | uuid from GET /api/ledger-accounts?kind=expense |
xeroTaxType | taxRateId | uuid from GET /api/tax-rates |
xeroWithholdingAccountCode | withholdingLedgerAccountId | uuid from GET /api/ledger-accounts?kind=liability |
xeroLineAmountType | lineAmountType | EXCLUSIVE | INCLUSIVE | NO_TAX |
{ "lineAmountType": "INCLUSIVE", "lines": [ { "description": "Artist fee", "quantity": 1, "unitAmount": 11000, "ledgerAccountId": "…", "taxRateId": "…" }, { "description": "Travel", "quantity": 1, "unitAmount": 500, "ledgerAccountId": "…", "taxRateId": null }, { "type": "SETTLEMENT_DEDUCTION", "description": "Damage", "quantity": 1, "unitAmount": -250 } ]}typeisSTANDARD(default),ADJUSTMENT, orSETTLEMENT_DEDUCTION.lineAmountandtaxAmountare never accepted — the server derives them. Sending them is ignored.- A
taxRateIdbelonging to another company returns 400. - Up to 200 lines per document.
Backward compatible: a caller that still sends amount plus customRows
(vendor portal, ticket-sales ingest, event-driven bills) works unchanged — the
server converts it into a one-line document.
Responses gain lines[], plus persisted baseAmount, subTotal,
taxTotal, total. customRows is still returned for one release but is now
derived from the lines, so the two cannot drift.
⚠️ amount is now the document TOTAL, not the base — it already includes
adjustments. Anything computing amount + customRowsTotal double-counts. The
old base is baseAmount.
⚠️ Xero mirror keys were renamed on every response: xeroInvoiceId →
legacyXeroInvoiceId, xeroSyncStatus → legacyXeroSyncStatus, and so on.
Backend and Frontend-Kisum-Finance must deploy together.
Banking, credit notes, budgets, contacts, POs, repeating (Phases B–E, 2026-07-22)
Section titled “Banking, credit notes, budgets, contacts, POs, repeating (Phases B–E, 2026-07-22)”POST /api/bank-statements— import CSV/OFX ({ bankAccountId, fileName?, content }); dedup on re-import.GET /api/bank-transactions?bankAccountId&status·GET /api/bank-transactions/{id}/suggestionsPOST /api/bank-transactions/{id}/reconcile—{ action: 'bill'|'income_term'|'direct'|'existing'|'exclude'|'unreconcile', … }GET|POST /api/bank-transfers— posts DR to-bank / CR from-bank.GET /api/reports/reconciliation— per-account unreconciled count + statement vs ledger balance.GET|POST /api/credit-notes·GET|PATCH|DELETE /api/credit-notes/{id}·POST /api/credit-notes/{id}/approve·POST /api/credit-notes/{id}/allocations(invoiceId|incomeId|bankAccountId= cash refund) ·DELETE …/allocations/{allocationId}. Withholding tax on credit notes is rejected (decision).GET|POST /api/journal-entriesnow supportsstatus: 'DRAFT',autoReversesOn;POST /api/journal-entries/{id}/postposts a draft,DELETEthe same path deletes one. Posted entries are never deleted.GET|POST /api/opening-balances— idempotent per company (opening-balance:{companyId}).- Reports:
GET /api/reports/general-ledger?from&to[&accountId]·GET /api/reports/cash-flow-statement·GET /api/reports/tax-return·GET /api/reports/budget-vs-actual?from=YYYY-MM&to=YYYY-MM. GET|POST /api/budgets— upsert{ entries: [{ ledgerAccountId, period: 'YYYY-MM', amount }] }; amount 0 deletes.GET /api/income/{id}/pdf(native invoice PDF) ·POST /api/income/{id}/send(emails the customer an S3 link; audit-logged).GET /api/customers/{id}/statement?from&to— running-balance statement.GET /api/contacts— unified vendor+customer view (linking model).GET|POST /api/repeating-documents·PATCH|DELETE /api/repeating-documents/{id}· cronPOST /api/cron/repeating-documents.GET|POST /api/purchase-orders·GET|DELETE /api/purchase-orders/{id}·POST …/approve·POST …/convert-to-bill(creates a DRAFT bill).- Cron:
POST /api/cron/journal-auto-reverse. - All families registered in
finance-permission-policy.ts(a repo test enforces this). - Bill/income detail responses include read-only
creditNotesAllocatedAmount(credit applied to the document; display-only, outstanding math unchanged). - Every document now carries a system
documentNumber(BILL-0001, INV-0001, CN-0001, PO-0001, MJ-0001), gapless per company, backfilled.
Income
Section titled “Income”Examples:
GET /api/incomeGET /api/income/{id}POST /api/incomePATCH /api/income/{id}- related income payment and summary helpers
These are Finance-owned revenue/AR workflows.
Machine Venue deposit mode:
POST /api/incomewithsource=venue_depositPATCH /api/income/{id}withsource=venue_deposit
Status: live
Primary caller:
Backend-Kisum-Venues
Purpose:
- create or update the real Finance-side deposit invoice while reusing the normal income/Xero flow
Boundary rules:
- machine-authenticated with the canonical
FINANCE_INTERNAL_API_KEY(PROMOTERS_INTERNAL_KEY/ legacyINTERNAL_API_KEYalso accepted) — since 2026-07-12 machine bearer tokens are resolved before session auth, so these gates are actually reachable - tenant still selected with
x-org - Venue must send a normal income-compatible payload, not a reduced pseudo-finance payload
Vendor
Section titled “Vendor”Examples:
POST /api/auth/vendor/loginPOST /api/auth/vendor/forgot-passwordGET /api/vendor/invoicesGET /api/vendor/invoices/{id}POST /api/vendor/upload- vendor profile / account / user routes
Promoters integration reads
Section titled “Promoters integration reads”All routes use the canonical Core company UUID. Finance company link/unlink routes and Mongo-company aliases were removed.
Examples:
GET /api/kisum/{companyId}GET /api/kisum/{companyId}/eventsGET /api/kisum/{companyId}/events/{eventId}GET /api/kisum/{companyId}/events/{eventId}/invoicesGET /api/kisum/{companyId}/events/{eventId}/income/summaryGET /api/kisum/{companyId}/events/{eventId}/income/highlightGET /api/kisum/event-expense/find-by-event/{id}
These remain supported read routes.
GET /api/kisum/{companyId}/events (bill/invoice “link to event” dropdown) calls Backend-Kisum-Promoters at PROMOTERS_INTERNAL_BASE_URL + /api/events, scoped with x-org = Core company UUID. Outbound auth prefers the caller’s Auth Bearer JWT (same session as Finance); the legacy Profile → Integrations KisumConnection token is only a fallback for machine/cron paths. Confirmed events only (status = confirmed after Promoters normalizes event_status).
Important current rule:
- company resolution uses Core only
- Finance stores no company master compatibility row
Profitability income read (Phase 6, 2026-07-12)
Section titled “Profitability income read (Phase 6, 2026-07-12)”GET /api/kisum/{companyId}/events/{eventId}/income/highlight returns per-category actualExpected (totals) alongside actual (paid) and accepts ?localCurrency= (defaults to the company base currency). Promoters’ profitability and performance analytics rebuild event income actuals from this endpoint (applying Promoters-side tax semantics), falling back to local data when Finance is unreachable or has no income for the event. With the expense highlights, this completes the Finance-as-source-of-truth read path for real event financials.
Kisum daily income ingest (ticket sales → Finance)
Section titled “Kisum daily income ingest (ticket sales → Finance)”Status: live (2026-07-12). Finance is the source of truth for real income, including ticket sales; Promoters pushes daily revenue rows here.
| Method | Path | Purpose |
|---|---|---|
POST | /api/kisum/income/daily/bulk | Bulk daily income rows (≤500 per request) |
POST | /api/kisum/income/daily | Single row (same envelope, exactly one row) |
GET | /api/kisum/income/daily | Read back ingested rows (companyId required; eventId, dateFrom, dateTo optional) |
Contract:
- machine-only:
Authorization: Bearer <FINANCE_INTERNAL_API_KEY>(PROMOTERS_INTERNAL_KEY/ legacyINTERNAL_API_KEYaccepted); nox-orgneeded — each row carries the canonical CorecompanyId - body:
{ sourceRunId?, rows: [{ companyId, eventId, eventTitle?, date (YYYY-MM-DD), currency, amount, quantity?, taxAmount?, sourceRef, ticketCategoryRef?, sourceVersion?, category?, customerName?, ticketVendor?, title?, postedBy?, notes? }] } - per ticket category (2026-08-01):
sourceRefispromoters:tickets:{event}:{vendor|none}:{category|all}:{currency}:{day}:v{n}andticketCategoryRefcarries Promoters’ stable per-event categorycode(never the display name, so renaming a tier is not a new category). The feed used to summarise a whole vendor-day, which made per-category vendor commission uncomputable in Finance.none/allare spelled out — a blank segment would let two different keys collide, and “no vendor” (box office) is not “vendor unknown”. BothticketCategoryRefandsourceVersionare stored onIncomeTerm, indexed withcompanyId, and echoed back in the row result - restatement, never mutation (2026-08-01): a corrected figure for a day Promoters has
already had accepted arrives as a new term at
sourceVersion + 1with its ownsourceRef. Editing the accepted term would silently move a receivable Finance has already booked - estimated amounts are never sent. Promoters excludes
value_source = ESTIMATEDsale lines (history migrated from the retired Mongo model, whose original prices no longer exist) from this feed. A ticket figure that looks invented is a Promoters-side bug - row kinds (Phase 3):
categorydefaults totickets(requiresticketVendor: { kisumId, name, … });fnb|sponsorship|merch|otherrows mirror manual ACTUAL event income from Promoters and requirecustomerName(sourceRef = event-income:{sourceId}) - idempotent upsert: ticket rows group into one
Incomeper (company, event, ticket vendor); non-ticket rows are oneIncomeper row; oneIncomeTermper row keyedkisumTermId = sourceRef; income amount/status recomputed from terms; paid/partial terms never overwritten - per-row result:
CREATED | UPDATED | UNCHANGED | SETTLED_SKIPPED | REJECTED_VALIDATION(non-entitled companies reject per row withFINANCE_NOT_ENABLED) - settlement finality (2026-07-13): once an income is
SUBMITTED(settled), ingest never mutates it again — rows targeting it returnSETTLED_SKIPPEDwith no term/journal changes - ingested income stays local-only (
workflowStatus: DRAFT, no Xero sync) — Xero outages can never block ingestion - caller: Promoters
POST|GET /cron/finance-income-daily-push(cron secret), rolling window default 3 days = retry mechanism; actualticketsevent-income rows are never pushed (sold-history is the only ticket-revenue source) - callers also include the per-event “Sync to Finance now” route in Promoters (
POST /api/integrations/finance/events/{eventId}/sync-income, tenant session auth) which pushes the event’s FULL sales + actual-income history and returns per-status counts
Ticket money — facts, settlements, direct payments (2026-08-01)
Section titled “Ticket money — facts, settlements, direct payments (2026-08-01)”Status: built, migration 20260801130000_ticket_money. Finance is the source of truth for what the promoter is owed and what actually arrived; Promoters owns the operational sale and holds no settlement ledger.
Four numbers stay apart on purpose — collapsing any two produces wrong cashflow: ticket sales (what customers bought), recognised revenue, receivable (what the ticket company owes), cash received (what reached the bank, or was paid to a third party on the promoter’s behalf).
| Method | Path | Purpose |
|---|---|---|
GET/POST | /api/ticket-settlements | List / record a vendor statement |
GET/PATCH | /api/ticket-settlements/{id} | Detail; status DRAFT → CONFIRMED → PAID, or VOID |
POST | /api/ticket-settlements/{id}/lines | Add a statement line |
POST | /api/ticket-settlements/{id}/reconcile | Compare against the expected receivable |
POST | /api/ticket-settlement-lines/{id}/allocations | Point a line at the days or bills it settles |
GET/POST | /api/ticket-deductions | Direct-payment reservations |
POST | /api/ticket-deductions/{id}/release | Release with a required reason |
GET | /api/ticket-receivables | Owed / settled / outstanding per ticket vendor |
Auth: user session + x-org; finance.settlement.view to read, finance.settlement.edit to write. A settlement is a cash document — reading it exposes the receivable position, writing it moves money against real bills.
Contract points that matter:
ticket_daily_factsholds the gross-to-net behind each receivable.IncomeTerm.amountis a single number, which is all a receivable needs; without the components a settlement cannot be reconciled line by line. Keyed on(companyId, sourceRef), the same anchor as the receivable.settledAmountbelongs to allocation and is never touched by the ingest — re-pushing sales must not reset what has been paid.- Settlements are period-scoped, never event-scoped. A vendor settles a period, and that period routinely covers several shows.
- Statement totals are stored as the vendor stated them, never recomputed from the lines. The gap between the two is the whole point of reconciliation.
- Ten line types, because a commission is an expense, a refund reverses revenue, a withheld tax is a prepayment and a direct payment settles someone else’s bill. One “deduction” bucket cannot be posted correctly.
- Reconciliation counts deductions as accounted for, not as a shortfall — the vendor kept the money against something it was owed, or paid it onward. Comparing cash alone would report a variance on every statement with commission.
VARIANCEis a separate status fromPARTIAL: disagreement is not incompleteness, and a variance is never auto-corrected. - Direct payments: reserve → settle → release. Reserving needs an approved bill (otherwise the approval chain is bypassed) and cannot exceed what is open on it. Allocating a
DIRECT_PAYMENTline pays the bill with methodticket_vendor_direct_paymentand settles the reservation in one transaction. Releasing requires a reason. Gross ticket revenue is never reduced — the retired Promoters implementation subtracted a directly-paid venue fee from ticket income, understating revenue and expenses at once. - The ledger is posted, at GROSS (2026-08-02). The promoter is the principal in a ticket sale, so revenue is what the customer paid and the ticket company’s cut is a cost of sale — receiving net changes only how the money travelled. A direct payment posts
Dr accounts payable / Cr accounts receivableper allocation with no bank line (that money never touched the promoter’s account). A settlement moving to PAID postsDr bank + commission + processing + withheld tax / Cr accounts receivable; the credit is the sum of the debits, so the receivable clears exactly. Posted atPAIDrather thanCONFIRMEDbecause confirming can precede the transfer landing. Voiding reverses both.
Cash flows (2026-07-12)
Section titled “Cash flows (2026-07-12)”GET /api/kisum/{companyId}/cash-flows?startDate&endDate — real dated cash movements: bill Payment rows (cash out) and IncomePayment rows (cash in), USD-converted, in the legacy Promoters cash-flow envelope (summary / transactions / incomeByCategory / expensesByCategory). Promoters GET /api/cash-flow consumes this Finance-first with a tenant-scoped local fallback. Auth: user session with income access, or machine bearer.
Income model (2026-07-12, Phase 3; lifecycle updated 2026-07-13)
Section titled “Income model (2026-07-12, Phase 3; lifecycle updated 2026-07-13)”- Income is capture-only (
DRAFT → SUBMITTED, no approval chain — decided 2026-07-12). - Ticket-income lifecycle semantics (2026-07-13):
DRAFT= LIVE (daily sync keeps accruing terms; counts everywhere — ledger, P&L, aging, Promoters profitability);SUBMITTED= SETTLED (amount final, ingest skips it, final ACCREC goes to Xero on submit). The Finance FE shows LIVE/SETTLED badges and a “Settle income” button forcategory=tickets. - Payments (2026-07-13):
mark-paidworks onDRAFTandSUBMITTED(advance ticket-company payouts arrive while still selling).termIdis now OPTIONAL — without it the amount is FIFO-allocated across the income’s open terms (oldestdueDatefirst), creating oneIncomePaymentper consumed term (batch tagged innotes, one ledger receipt journal per payment; a single Xero payment is linked once). WithtermId, per-term behavior is unchanged. Income stays gross — a payout only reduces the amount due. - Payments flow only through
PATCH /api/income/{id}actions; the standalone/api/income/{id}/payments*routes were removed. - HTTP
DELETE /api/income/{id}deletes DRAFT income only. - Read visibility: ADMIN/FINANCE (+ tenant superadmin / platform staff) company-wide; BU APPROVERs their BU; creators their own rows. Writes require ADMIN/FINANCE.
Examples:
- connect / callback
- sync
- accounts
- currencies
- taxes
- related finance integration helpers
These remain Finance-owned.
Important Behavioral Notes
Section titled “Important Behavioral Notes”Company write removal
Section titled “Company write removal”These Finance endpoints are intentionally disabled and return 405 company_master_write_removed:
POST /api/admin/companiesPUT /api/admin/companiesPATCH /api/admin/companiesDELETE /api/admin/companiesPOST /api/admin/companies/{id}/request-deletion
Use Core/Admin for company lifecycle.
Business units
Section titled “Business units”Finance reads business units and never writes them. BU master truth is in Core, and the structure is owned by the persona app (Promoters, Artists, Venues).
Current behavior:
GET /api/admin/business-unitsandGET /api/admin/business-units/by-companyread from CorePOST,PUT, andDELETE /api/admin/business-unitsreturn410with codeorg_master_write_removed- the Finance UI has no create/rename/archive/delete controls and links back to the persona app’s Company Profile
How Finance reads users from Auth
Section titled “How Finance reads users from Auth”Finance reads organization users and business-unit members through Auth’s machine routes with AUTH_INTERNAL_API_KEY:
GET /internal/admin/companies/{companyId}/users?module=financeGET /internal/admin/companies/{companyId}/business-units/{businessUnitId}/users
Not the user-scoped /internal/companies/* equivalents. Those authorize the token’s own principal (ActorCompanyRank >= 2), and Finance’s shared service account is a member of no tenant — so they return 403 for every company. Finance already authorizes the acting user in its own route handler, which is what the S2S rule requires.
Two traps this surfaced, both of which produced an empty list rather than an error:
- Auth’s envelope is
{ success: true, data: [...] }withdataas a bare array. Unwrapping onlydata.users/data.itemsyields nothing. - Swallowing failures into
return []makes a403indistinguishable from “this company has no users”. Finance’s readers now throw and the route answers502.
?module=finance is required, not cosmetic. Without it the screen lists every organization user, including people with no Finance access at all — a Finance workflow role on such a user does nothing, because they cannot open Finance. Finance must not compute this filter itself: the authoritative grant table depends on the company’s access-policy mode, an empty legacy grant list means all modules rather than none, and company owners/admins hold every entitled module regardless of grant rows. Auth owns that logic.
Finance role writes use the matching machine routes with the same key:
PATCH /internal/admin/companies/{companyId}/business-units/{businessUnitId}/finance-memberships/{userId}— BU role metadata (Submitter/Approver, approval limit, primary approver).PATCH /internal/admin/companies/{companyId}/finance-memberships/{userId}— Primary Finance Admin flag.PATCH /internal/admin/companies/{companyId}/company-approval-limit/{userId}— the company-membershipapprovalLimit(the admin-tier cap; body{ "approvalLimit": "<decimal>" | null },null= unlimited).
All carry X-Actor-User-Id = the signed-in Finance user, which Auth audits and re-checks for company membership. Finance previously called the Bearer variants with an AUTH_SERVICE_BEARER_TOKEN that was never configured, so every save failed with “Auth management service is unavailable” — invisible while the users list was empty. That env var is gone; AUTH_INTERNAL_API_KEY now covers both reads and writes.
AUTH_INTERNAL_API_KEY is required for the Company Users screen.
Finance may still set Finance workflow fields through PATCH /api/admin/finance-access (actions businessUnitApproval, primaryFinanceAdmin, and companyApprovalLimit): Submitter/Approver limits and primary approver on an existing business-unit membership, the Primary Finance Admin flag, and — for an organization admin — the company-wide (admin-tier) approval limit. That is role metadata, not organization structure — it never creates a membership or a BU assignment. companyApprovalLimit is gated by canManagePrimary (org admin / tenant super admin) and only accepted for a company-wide admin role.
Users and memberships
Section titled “Users and memberships”Finance is not the source of truth for:
- users
- company memberships
- business-unit memberships
Those come from Auth.
Entitlement enforcement
Section titled “Entitlement enforcement”Membership alone is not enough for tenant Finance access.
A company can only use Finance if Core says the company has the active finance add-on / enabled module.
Double-entry ledger (5.9, 2026-07-12)
Section titled “Double-entry ledger (5.9, 2026-07-12)”Finance now has a true accounting core: every bill approval/payment and income recognition/receipt posts a balanced journal entry (idempotent per business event; FX residuals to FX Gain/Loss; period-locked deletions become reversals; system accounts auto-managed). Routes: GET|POST /api/journal-entries (ledger listing + manual journals/opening balances), GET|PUT /api/period-lock, POST /api/admin/ledger/rebuild (idempotent backfill), GET /api/reports/trial-balance and GET /api/reports/balance-sheet (true statements with balance checks). Frontend: Reports page (balance sheet + trial balance) and Accounting → Journal tab (manual journals, period lock, rebuild). Deploy: prisma migrate deploy + one ledger rebuild per company.
Native accounting + reports (Phase 5, 2026-07-12)
Section titled “Native accounting + reports (Phase 5, 2026-07-12)”Finance owns native accounting configuration and reporting — Xero is an optional add-on:
| Method | Path | Purpose |
|---|---|---|
GET|POST / PATCH|DELETE | /api/bank-accounts[/:id] | Native bank accounts (payment picker) |
GET|POST / PATCH|DELETE | /api/ledger-accounts[/:id] | Native chart of accounts (?kind= filter) |
GET|POST / PATCH|DELETE | /api/tax-rates[/:id] | Native tax rates |
GET|POST / DELETE | /api/currencies[/:code] | Company-enabled currency list |
GET | /api/reports/profit-and-loss | Native P&L (accrual/cash, USD-normalized) |
GET | /api/reports/aging | AR/AP aging buckets |
PATCH | /api/admin/sync-errors/:id | Sync-error lifecycle: resolve / reopen / retry |
The /api/xero/* list endpoints (bank accounts, expense/revenue/liability accounts, tax rates, currencies) serve the native data with identical shapes when a company has no Xero connection. XERO_SYNC_MODE defaults to non-blocking: lifecycle Xero failures are recorded (InvoiceSyncError / income sync fields) and local operations proceed; the reconcile cron auto-closes errors that no longer reproduce. Balance sheet / trial balance intentionally wait for the double-entry ledger (long-term). Frontend surfaces: /accounting settings and /reports pages.
Payments without Xero (2026-07-12)
Section titled “Payments without Xero (2026-07-12)”Xero is optional. PATCH /api/bills/{id} action=mark-paid only validates the bank account against Xero when the company has an active Xero connection; non-connected companies record the provided bank account reference as-is. GET /api/xero/bank-accounts returns xeroConnected: boolean so UIs can switch to free-text account entry.
Machine bearer auth (2026-07-12)
Section titled “Machine bearer auth (2026-07-12)”Machine/integration bearer tokens (FINANCE_INTERNAL_API_KEY, PROMOTERS_INTERNAL_KEY with legacy INTERNAL_API_KEY fallback, EXTERNAL_API_TOKENS_JSON scoped tokens) are resolved before session auth, so machine callers get a real bearer auth mode with token label and companyIds/userIds scoping. Machine-only gates accept both finance-internal-api and kisum-internal-api labels.
Hardened in Phase 4 (2026-07-12): machine keys carry an explicit machine identity through the request context and get no session from getServerSession — they only work on bearer-aware routes with enforced scoping, and can no longer act as a blanket platform superadmin on arbitrary routes. External scoped tokens now pass the auth middleware (previously unusable). The legacy ?companyId= tenant query fallback was removed (x-org header only), the dead PENDING_PLATFORM gate was deleted, and Core entitlement lookups are cached in-process (default 60s, fail-closed, FINANCE_ENTITLEMENT_CACHE_TTL_MS).
Approval lifecycle concurrency (2026-07-12)
Section titled “Approval lifecycle concurrency (2026-07-12)”PATCH /api/bills/{id} lifecycle actions are guarded by optimistic locking on the invoice version column:
approve(final, partial, and re-approve of REJECTED),reject, andmark-paidonly write when the invoice still has the status andversionthe caller read; a concurrent action returns 409{ code: "VERSION_CONFLICT" }— clients should refresh and retry.mark-paidis atomic (payment + payment-slip file + status in one transaction); on any local failure the Xero payment created by that request is rolled back.- Rejecting a
PARTIALLY_PAIDbill is blocked — payments must be removed first. - Re-approving a REJECTED bill recreates + authorizes the Xero invoice (rejection voided it).
- Auto-rejection (daily cron) keys on
approvalDeadline, is recorded as a system audit entry (actorId: null,metadata.autoRejected: true), and voids the Xero draft. It is never attributed to the submitter. - Vendor submissions notify company admins (
VENDOR_INVOICE_RECEIVED); BU assignment writesINVOICE_BU_ASSIGNEDand notifies that BU’s approvers.
Error Guidance
Section titled “Error Guidance”Finance commonly returns:
400validation errors401unauthorized403forbidden / no company scope / no finance entitlement404missing resource405removed company master write behavior409conflict500internal error
The two most important current Finance-specific platform cases are:
403 finance_addon_required405 company_master_write_removed
Engineering Guidance
Section titled “Engineering Guidance”When extending Finance API behavior:
- do not add new company master write flows
- do not add new BU master ownership in Finance
- use Auth for user and membership truth
- use Core for company, BU, and entitlement truth
- only keep local compatibility rows where finance-domain persistence still requires them
For deeper implementation detail, the repo-local sources are still useful references:
Backend-Kisum-Finance/README_API.mdBackend-Kisum-Finance/README_DOCS.mdBackend-Kisum-Finance/README_AUTH_API.md
But this page should be treated as the docs-site summary of the current platform behavior.