Skip to content

Finance API

Related documentation: Finance Module Backend · Access Control Integration · Error Contract

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: Node / TypeScript backend
  • route style: src/api/**/route.ts
  • route namespace: /api/*
  • health endpoint: GET /health

Protected internal company-scoped Finance routes require:

  • valid auth
  • valid x-org
  • membership / scope from Auth
  • finance entitlement from Core

If the selected company is not commercially entitled to Finance:

  • status: 403
  • code: finance_addon_required

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.

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.

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).

MethodPathPurpose
GET/api/internal/companies/{companyId}/delete-previewCount company-scoped Prisma rows; return blockers (ACTIVE_XERO_PIPELINE, ACTIVE_XERO_SYNC)
POST/api/internal/companies/{companyId}/purgeHard-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 include financeEnabled (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 shared kisum_active_company cookie; 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. See Frontend-Kisum-Finance/docs/FRONTEND_X_ORG.md.
  • GET /api/admin/companies/{id}
  • Both companies routes populate kisumCompanyLogo: sourced only from the Core company profile logoUrl (the same logo the Promoters app shows); null when no profile logo exists. The Finance FE sidebar company row renders the logo (initials fallback).
  • GET /api/admin/business-units/by-company
  • GET /api/admin/business-units — read-only; POST|PUT|DELETE return 410 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/bills
  • GET /api/bills/{id}
  • GET /api/bills/{id}/signed-pdf
  • POST /api/bills
  • PATCH /api/bills/{id}

These are Finance-owned expense/AP workflows.

POST /api/bills and POST /api/income accept line items. The four Xero code fields are gone; send native foreign keys instead.

RemovedSend insteadValue
xeroAccountCodeledgerAccountIduuid from GET /api/ledger-accounts?kind=expense
xeroTaxTypetaxRateIduuid from GET /api/tax-rates
xeroWithholdingAccountCodewithholdingLedgerAccountIduuid from GET /api/ledger-accounts?kind=liability
xeroLineAmountTypelineAmountTypeEXCLUSIVE | 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 }
]
}
  • type is STANDARD (default), ADJUSTMENT, or SETTLEMENT_DEDUCTION.
  • lineAmount and taxAmount are never accepted — the server derives them. Sending them is ignored.
  • A taxRateId belonging 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: xeroInvoiceIdlegacyXeroInvoiceId, xeroSyncStatuslegacyXeroSyncStatus, 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}/suggestions
  • POST /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-entries now supports status: 'DRAFT', autoReversesOn; POST /api/journal-entries/{id}/post posts a draft, DELETE the 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} · cron POST /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.

Examples:

  • GET /api/income
  • GET /api/income/{id}
  • POST /api/income
  • PATCH /api/income/{id}
  • related income payment and summary helpers

These are Finance-owned revenue/AR workflows.

Machine Venue deposit mode:

  • POST /api/income with source=venue_deposit
  • PATCH /api/income/{id} with source=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 / legacy INTERNAL_API_KEY also 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

Examples:

  • POST /api/auth/vendor/login
  • POST /api/auth/vendor/forgot-password
  • GET /api/vendor/invoices
  • GET /api/vendor/invoices/{id}
  • POST /api/vendor/upload
  • vendor profile / account / user routes

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}/events
  • GET /api/kisum/{companyId}/events/{eventId}
  • GET /api/kisum/{companyId}/events/{eventId}/invoices
  • GET /api/kisum/{companyId}/events/{eventId}/income/summary
  • GET /api/kisum/{companyId}/events/{eventId}/income/highlight
  • GET /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.

MethodPathPurpose
POST/api/kisum/income/daily/bulkBulk daily income rows (≤500 per request)
POST/api/kisum/income/dailySingle row (same envelope, exactly one row)
GET/api/kisum/income/dailyRead back ingested rows (companyId required; eventId, dateFrom, dateTo optional)

Contract:

  • machine-only: Authorization: Bearer <FINANCE_INTERNAL_API_KEY> (PROMOTERS_INTERNAL_KEY / legacy INTERNAL_API_KEY accepted); no x-org needed — each row carries the canonical Core companyId
  • 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): sourceRef is promoters:tickets:{event}:{vendor|none}:{category|all}:{currency}:{day}:v{n} and ticketCategoryRef carries Promoters’ stable per-event category code (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 / all are spelled out — a blank segment would let two different keys collide, and “no vendor” (box office) is not “vendor unknown”. Both ticketCategoryRef and sourceVersion are stored on IncomeTerm, indexed with companyId, 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 + 1 with its own sourceRef. Editing the accepted term would silently move a receivable Finance has already booked
  • estimated amounts are never sent. Promoters excludes value_source = ESTIMATED sale 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): category defaults to tickets (requires ticketVendor: { kisumId, name, … }); fnb|sponsorship|merch|other rows mirror manual ACTUAL event income from Promoters and require customerName (sourceRef = event-income:{sourceId})
  • idempotent upsert: ticket rows group into one Income per (company, event, ticket vendor); non-ticket rows are one Income per row; one IncomeTerm per row keyed kisumTermId = 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 with FINANCE_NOT_ENABLED)
  • settlement finality (2026-07-13): once an income is SUBMITTED (settled), ingest never mutates it again — rows targeting it return SETTLED_SKIPPED with 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; actual tickets event-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).

MethodPathPurpose
GET/POST/api/ticket-settlementsList / record a vendor statement
GET/PATCH/api/ticket-settlements/{id}Detail; status DRAFT → CONFIRMED → PAID, or VOID
POST/api/ticket-settlements/{id}/linesAdd a statement line
POST/api/ticket-settlements/{id}/reconcileCompare against the expected receivable
POST/api/ticket-settlement-lines/{id}/allocationsPoint a line at the days or bills it settles
GET/POST/api/ticket-deductionsDirect-payment reservations
POST/api/ticket-deductions/{id}/releaseRelease with a required reason
GET/api/ticket-receivablesOwed / 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_facts holds the gross-to-net behind each receivable. IncomeTerm.amount is 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. settledAmount belongs 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. VARIANCE is a separate status from PARTIAL: 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_PAYMENT line pays the bill with method ticket_vendor_direct_payment and 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 receivable per allocation with no bank line (that money never touched the promoter’s account). A settlement moving to PAID posts Dr bank + commission + processing + withheld tax / Cr accounts receivable; the credit is the sum of the debits, so the receivable clears exactly. Posted at PAID rather than CONFIRMED because confirming can precede the transfer landing. Voiding reverses both.

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 for category=tickets.
  • Payments (2026-07-13): mark-paid works on DRAFT and SUBMITTED (advance ticket-company payouts arrive while still selling). termId is now OPTIONAL — without it the amount is FIFO-allocated across the income’s open terms (oldest dueDate first), creating one IncomePayment per consumed term (batch tagged in notes, one ledger receipt journal per payment; a single Xero payment is linked once). With termId, 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.

These Finance endpoints are intentionally disabled and return 405 company_master_write_removed:

  • POST /api/admin/companies
  • PUT /api/admin/companies
  • PATCH /api/admin/companies
  • DELETE /api/admin/companies
  • POST /api/admin/companies/{id}/request-deletion

Use Core/Admin for company lifecycle.

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-units and GET /api/admin/business-units/by-company read from Core
  • POST, PUT, and DELETE /api/admin/business-units return 410 with code org_master_write_removed
  • the Finance UI has no create/rename/archive/delete controls and links back to the persona app’s Company Profile

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=finance
  • GET /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: [...] } with data as a bare array. Unwrapping only data.users / data.items yields nothing.
  • Swallowing failures into return [] makes a 403 indistinguishable from “this company has no users”. Finance’s readers now throw and the route answers 502.

?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-membership approvalLimit (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.

Finance is not the source of truth for:

  • users
  • company memberships
  • business-unit memberships

Those come from Auth.

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.

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:

MethodPathPurpose
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-lossNative P&L (accrual/cash, USD-normalized)
GET/api/reports/agingAR/AP aging buckets
PATCH/api/admin/sync-errors/:idSync-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.

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/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, and mark-paid only write when the invoice still has the status and version the caller read; a concurrent action returns 409 { code: "VERSION_CONFLICT" } — clients should refresh and retry.
  • mark-paid is 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_PAID bill 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 writes INVOICE_BU_ASSIGNED and notifies that BU’s approvers.

Finance commonly returns:

  • 400 validation errors
  • 401 unauthorized
  • 403 forbidden / no company scope / no finance entitlement
  • 404 missing resource
  • 405 removed company master write behavior
  • 409 conflict
  • 500 internal error

The two most important current Finance-specific platform cases are:

  • 403 finance_addon_required
  • 405 company_master_write_removed

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.md
  • Backend-Kisum-Finance/README_DOCS.md
  • Backend-Kisum-Finance/README_AUTH_API.md

But this page should be treated as the docs-site summary of the current platform behavior.