Skip to content

Finance Module Backend

Finance consumes Core business units read-only and Auth memberships. Organization-master writes are retired with 410 Gone. Finance may update only workflow roles/limits/primary flags on existing Auth memberships; organization invitations, access grants, and BU assignments stay in persona apps.

Related documentation: Backend Modules · Finance API · Data ownership · Access Matrix · Error Contract

Backend-Kisum-Finance is the Finance domain backend for Kisum.

It owns finance-domain workflows such as:

  • bills / expenses
  • income / revenue
  • vendor portal flows
  • Xero accounting integration
  • finance-linked files, notifications, and exchange-rate helpers
  • Kisum compatibility reads for event-linked finance data

It does not own:

  • user identity
  • sessions / JWT issuance
  • company memberships
  • business-unit memberships
  • company master data
  • business-unit master data
  • package / add-on / entitlement truth

Those ownership boundaries now matter in runtime, not only in architecture diagrams.

  • users
  • sessions
  • company memberships
  • business-unit memberships
  • permission / scope resolution for the acting user

Finance consumes Auth context. It is not allowed to become the source of truth for those relations again.

  • company master data
  • business-unit master data
  • packages
  • add-ons
  • company subscriptions
  • entitlements

Finance now depends on Core for:

  • company / business-unit master reads
  • company commercial entitlement checks
  • Kisum company id as part of canonical company resolution

Finance owns (financial domain source of truth)

Section titled “Finance owns (financial domain source of truth)”

Finance is the source of truth for financial workflow data stored in the Finance database: bills/invoices, income, vendor portal and vendor-finance links, Xero connection and sync state, finance-related files/notifications, and related audit rows. It is not the source of truth for company or business-unit master data (that is Core) or for users/memberships (that is Auth).

Concretely, Finance owns:

  • bills / invoices in the finance sense
  • income rows and income payments
  • venue-deposit invoices once Venue submits them through the Finance income flow
  • vendor finance workflows
  • Xero sync state
  • audit / notification behavior related to finance workflows
  • scalar companyId / businessUnitId (and other FKs) on finance tables that point at Core-owned ids — without re-defining company/BU master records in Finance

Finance access requires both Auth and Core

Section titled “Finance access requires both Auth and Core”

A company-scoped Finance request is allowed only when:

  1. the caller is authenticated
  2. Auth confirms the caller can act for the selected company
  3. Core confirms the selected company has the finance add-on / enabled module

If membership exists but the company is not entitled to Finance, Finance returns:

  • 403
  • code: finance_addon_required

For company-scoped internal routes, the active tenant is selected through x-org.

Accepted form:

  • canonical Core company UUID only

Finance rejects legacy company identifiers and has no company-linking fallback. It stores Core company/business-unit IDs directly on PostgreSQL finance records.

Current cross-service detail:

  • Backend-Kisum-Venues now reuses Finance POST|PATCH /api/income in machine source=venue_deposit mode for deposit invoices
  • Finance still runs the normal income/Xero flow and remains the source of truth for payment state

How company and business unit appear in Finance (no local org-master tables)

Section titled “How company and business unit appear in Finance (no local org-master tables)”

The Finance Prisma schema does not include Company or BusinessUnit models. Finance tables store Core canonical UUIDs in scalar columns (e.g. companyId, businessUnitId on Invoice, Income, XeroConnection, vendor links, etc.).

Master truth for company and business unit name, code, status, and lifecycle remains in Core. Finance resolves display and validation by calling Core (and related helpers) and, where needed, enriches API responses in memory (enrichRowsWithCoreOrg, Core company maps, etc.) — not via Prisma relations to local org tables.

Practical rule: never treat Finance as the system of record for creating or editing tenant companies or BUs; use Core (or Admin’s Core proxies) for that. Use Finance for AP/AR, vendors, Xero, and other finance-domain persistence keyed by those Core ids.

Finance company lifecycle is removed.

These company master write endpoints should not be used anymore:

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

Current behavior:

  • they return 405
  • code: company_master_write_removed

Finance business-unit management is removed.

Business units are still finance-relevant, but BU master truth is in Core and the structure is owned by the persona app (Promoters, Artists, Venues). These business-unit write endpoints are retired:

  • POST /api/admin/business-units
  • PUT /api/admin/business-units
  • DELETE /api/admin/business-units

Current behavior:

  • they return 410
  • code: org_master_write_removed
  • message: Business unit management has moved to your main Kisum app.

GET /api/admin/business-units and GET /api/admin/business-units/by-company remain, reading from Core. The Finance UI is read-only and links back to the persona app’s Company Profile; Finance cannot create, rename, archive, delete, or assign business units.

Status codes: company master writes were retired earlier and kept 405; business-unit writes use 410, matching the platform’s newer convention for a route that is deliberately gone rather than merely unsupported. Both are permanent — do not “fix” one to match the other.

Main route groups in the current implementation:

  • admin/*
  • bills/*
  • income/*
  • income-categories/*
  • vendor/*
  • xero/*
  • kisum/*
  • files/*
  • upload/*
  • notifications
  • exchange-rates/*
  • dashboard/*
  • auth/vendor/*

These are documented in more detail here:

Finance relies on Auth for:

  • GET /auth/me style user context resolution
  • company and BU membership truth
  • platform / company / BU access checks

Finance docs should never describe local Finance membership tables as the active runtime model.

Finance relies on Core for:

  • company reads
  • business-unit reads (writes retired — see above)
  • entitlements
  • company lookup by canonical Core UUID only

kisum/* routes remain supported compatibility routes for event-linked finance reads.

Important current rule:

  • company resolution uses the Core UUID only
  • Finance local company rows are no longer the first resolver for those routes

Since 2026-07-12, kisum/* also carries the write path for real income: Promoters pushes daily per-(event, ticket-vendor, day) ticket-sales rows AND mirrors manual actual event income (F&B, sponsorship, merch, other) to POST /api/kisum/income/daily/bulk (machine bearer, idempotent upsert by sourceRef). Finance is the source of truth for real income; Promoters keeps only estimates locally. Promoters GET /api/cash-flow also reads real cash movements from Finance (GET /api/kisum/{companyId}/cash-flows) first. See Finance API → “Kisum daily income ingest”.

Xero — an outbound mirror, never a dependency (Phase A, 2026-07-22)

Section titled “Xero — an outbound mirror, never a dependency (Phase A, 2026-07-22)”

Finance is the accounting source of truth. Xero is optional per company and receives a copy; nothing in the business logic asks Xero what is true.

Everything Xero owns now lives in a legacy_xero_* namespace. Eight tables were renamed (XeroConnectionlegacy_xero_connections, and so on) and every remaining xero* column became legacyXero*, so reading the database by hand can never confuse Xero’s mirrored copy with our own books. Zero xero* columns or Xero* tables remain.

Three fields used to look like Xero’s but were always ours, and are now native foreign keys:

Was (Xero code string)Is (native FK)Points at
xeroAccountCodedefaultLedgerAccountIdledger_accounts
xeroTaxTypedefaultTaxRateIdtax_rates
xeroWithholdingAccountCodewithholdingLedgerAccountIdledger_accounts
xeroLineAmountType ('Exclusive'…)lineAmountType (EXCLUSIVE | INCLUSIVE | NO_TAX)enum

src/lib/xero-account-mapping.ts is the only place that translates a native id into a Xero AccountCode / TaxType, and it runs at the moment a payload is built. System accounts (SYS-AP, EXP-TALENT-PROGRAMMING, …) are never sent — they mean nothing to Xero.

The chart of accounts and tax table are now seeded from the mirrored Xero catalogues, so a company that disconnects Xero keeps a working set: 161 ledger accounts and 84 tax rates on the reference tenant.

Xero sync stays non-blocking by default (XERO_SYNC_MODE): failures become sync errors and local operations proceed; the reconcile cron auto-closes resolved errors and admins can retry/resolve/reopen.

The Finance backend still owns:

  • connect / callback
  • sync
  • account / tax / currency reads
  • finance-domain Xero behavior

Line items and real tax (Phase A, 2026-07-22)

Section titled “Line items and real tax (Phase A, 2026-07-22)”

A document is its lines. invoice_lines and income_lines each carry quantity, unitAmount, their own ledgerAccountId and taxRateId, and the derived lineAmount (tax-exclusive net) and taxAmount.

src/lib/tax.ts is the only place that computes money: inclusive backs the tax out of the unit amount, exclusive adds it on top, no-tax forces zero. Rounding follows Xero — round each line to 2dp, then sum, never the reverse. lineAmount / taxAmount are never accepted from a client.

Sales tax now reaches the ledger. Before Phase A a $110 bill marked “inclusive, 10% GST” booked $110 of expense and $0 of tax. It now books $100 of expense and $10 to tax_receivable. Two system accounts were added:

  • tax_receivable — input tax on bills (an asset; recoverable)
  • tax_payable — output tax on income (a liability; owed)

The movement on those two accounts over a period is the GST/VAT return, which is what makes that report possible at all.

Tax is carved out of the document total, not added on top, so withholding behaviour is unchanged and a document with no tax posts exactly as it always did.

Phases B–E — the full accounting floor (2026-07-22)

Section titled “Phases B–E — the full accounting floor (2026-07-22)”

Finance now covers Xero’s core feature set natively:

Bank reconciliation (Phase B). bank_statements / bank_transactions (signed amounts, importHash dedup — OFX FITID when the bank provides one) / bank_transfers. CSV and OFX import; match suggestions against open bills and income terms (exact-amount candidates, scored by date and name similarity); reconcile actions: match-to-bill (creates the Payment through the same rules as manual mark-paid), match-to-income-term, spend/receive money (direct journal with tax through the tax engine — a taxed bank fee still reaches tax_receivable), transfer legs, exclude. Live bank feeds are deferred (need a feed aggregator); the model is feed-ready.

Credit notes + auditability (Phase C). credit_notes both sides (ACCPAYCREDIT / ACCRECCREDIT) reuse the document line pipeline; approval posts the exact inverse of the mirrored document; allocations track where the credit went and only cash refunds post (Xero behaviour). Gapless document_sequences per company (BILL-/INV-/CN-/PO-/MJ-), claimed inside the creating transaction, backfilled for existing rows. Manual journals gained DRAFT status (excluded from every report until posted), autoReversesOn + cron, and MJ numbers. Guided opening balances post one idempotent journal against Opening Balance Equity.

Accountant reports (Phase D). General Ledger (per-account opening balance, POSTED lines with running balance, closing), ledger-derived Cash Flow Statement (direct method, classified by counterpart account kind), GST/VAT return (ledger view = filing figures; document view = per-rate audit trail — the two must agree), budgets (account × month) with budget-vs-actual.

AR parity + contacts (Phase E). Native income invoice PDF (@react-pdf/renderer) and send-to-customer email (PDF uploaded to S3, linked — SES SendEmail cannot attach); customer statements with running balance; contacts as a LINKING model over Vendor/Customer (a destructive merge stays a supervised-phase candidate); repeating bills/invoices materialised by cron through the normal document pipeline; purchase orders (no GL posting — a commitment is not a liability) with convert-to-bill.

Ops: two new cron routes need external scheduling (daily, bearer CRON_SECRET): POST /api/cron/journal-auto-reverse, POST /api/cron/repeating-documents.

Deferred by decision: live bank feeds (aggregator contract), payment links (payment provider), quotes (Promoters owns event estimates), payroll / inventory / fixed assets (out of scope).

amount changed meaning — read this before consuming it

Section titled “amount changed meaning — read this before consuming it”

Invoice.amount / Income.amount are now the document TOTAL (every line, tax included). They used to be the BASE, with customRows added on top.

  • The old base is preserved as baseAmount.
  • subTotal, taxTotal, total are derived and persisted.
  • Anything that still computes amount + customRowsTotal double-counts. Use getDocumentFinancials() in src/lib/document-lines.ts.
  • customRows is deprecated. It is still served for one release, but derived from the lines rather than stored, so the two cannot disagree.

Cross-service: Promoters profitability and performance feeds read Finance amount and now receive the total. Verify before deploying.

Important Finance runtime dependencies now include:

  • Auth internal/user context integration
  • Core internal integration
  • Redis where configured
  • PostgreSQL / Prisma 7 (see Prisma 7 setup)
  • Kisum API
  • Xero API
  • S3-compatible file storage

Finance runs Prisma 7 (since 2026-07-17). Two consequences matter for anyone working on this repo:

  • The client is generated into the repo, not node_modules. It lands in src/generated/prisma (git-ignored, rebuilt by postinstall / npm run build). Import it as @/generated/prisma/client — importing @prisma/client is wrong; that package now only ships the runtime the generated code depends on.
  • Connection URLs are not in prisma/schema.prisma. Prisma 7 rejects them there. The Prisma CLI reads its datasource from prisma.config.ts (DATABASE_URL_DIRECT, falling back to DATABASE_URL), while the runtime connects through the @prisma/adapter-pg driver adapter — Prisma 7 has no built-in query engine.

Same command names as Auth/Core/Venues, but Finance stays on Prisma (not Go sqlmigrate):

TargetBehavior
make migrate-statusprisma migrate status
make migrate-upprisma migrate deploy (additive, non-destructive)
make migrate-downRefuses — Prisma has no automatic rollback; do not reset/drop. Undo with a new forward migration + migrate-up.
make db-migrateAlias for migrate-up

Never use prisma migrate reset / force-reset for routine local work.

DATABASE_URL is normalised in src/lib/prisma-connection.ts before it reaches the driver, because node-postgres does not read it the way the old Prisma engine did: connection_limit becomes the pg pool max, and sslmode=require is translated to “encrypt without verifying” (pg would otherwise treat it as verify-full and fail against DigitalOcean managed Postgres). Set DATABASE_CA_CERT or DATABASE_CA_CERT_PATH to verify the server certificate against a real CA.

Backend-Kisum-Promoters is also on Prisma 7 but still uses the legacy prisma-client-js generator, so its layout differs from Finance.

For the Core integration, Finance must have working values for:

  • CORE_INTERNAL_BASE_URL
  • CORE_INTERNAL_API_KEY

Without those, commercial entitlement verification and Core org-master synchronization will fail.

What is already implemented:

  • Finance entitlement enforcement through Core
  • Auth as the source of truth for users and memberships
  • Core as the source of truth for company / business-unit master data
  • company master writes removed from Finance
  • business-unit admin CRUD removed from Finance (410 org_master_write_removed); structure is owned by the persona app
  • remaining kisum/* company resolution moved to Core-first behavior
  • removal of Finance-local Company / BusinessUnit Prisma models; finance rows use scalar Core ids with runtime enrichment for responses

What still remains true:

  • Finance remains the persistence and workflow owner for invoices, income, vendors, and Xero integration — keyed by Core companyId / businessUnitId where those columns exist
  • Any older docs or code comments referring to “local company rows” in Finance should be read as historical; the current model is FK scalars + Core enrichment, not Finance-owned org master tables

When working in Backend-Kisum-Finance:

  • do not add new company master ownership here
  • do not add new business-unit master ownership here
  • prefer Auth for user / membership truth
  • prefer Core for company / BU / entitlement truth
  • persist finance-domain data in Finance; store only Core ids for tenant scope — enrich names and org fields from Core at read/API boundaries, not from Finance-local org tables
  • if code and older repo docs disagree, the implemented Auth / Core / Finance split above is the current platform rule

Finance now consumes Auth-owned BU memberships and company memberships, including approvalLimit, isPrimaryApprover, and isPrimaryFinanceAdmin. Only the primary Approver for the invoice BU may perform the BU step; Finance does not search another BU for an approver.

The BU approver is the business-unit admin. A unit’s approver step is handled by its BU admin (Auth business-unit role ADMIN, the department head — one per unit, so the unit’s primary approver with no flag needed), or by a legacy APPROVER row that still carries isPrimaryApprover. Finance’s session preserves the ADMIN role (it does not flatten it to APPROVER); recognising only APPROVER made a BU-admin-run unit look like it had no approver, so the flow skipped the manager step. An approver with no approvalLimit set is unlimited.

The approval ladder is amount-based: within the BU limit requires Approver and Admin; above the BU limit but within the Admin limit requires Admin and Tenant Superadmin; above the Admin limit requires Tenant Superadmin only. Null limits are unlimited. Finance workflow roles and the relevant finance.* permission are separate requirements.

All browser-facing Finance business routes pass through one exact route-to-permission policy. It covers bills, income and settlement, payments, reports, dashboard, accounting, vendors, uploads/files, tax/currency, Xero, notifications, and company settings. Internal machine routes are explicitly excluded; an unknown browser business route is denied until cataloged.

/api/dashboard/* is the landing page, not a report: it accepts any Finance read permission (finance.bill.view, finance.invoice.view, or finance.report.view) and its rows are already narrowed per user by getInvoiceScopeForUser. Only /api/reports/* requires finance.report.view.

Finance grants no finance.* permission of its own and no screen ticks them. They are derived by Auth from the organization level and business-unit roles set in the persona app’s member editor — see Permissions derived from the organization level. Finance owns only approval limits, primary-approver, and primary-finance-admin.

Approval limits are expressed in the company base currency — the same currency the approval ladder compares invoice totals against. /admin/finance-access returns baseCurrency, and the Finance user page shows it beside the field (label Approval limit (CUR) plus a currency prefix) so the amount is never ambiguous now that bills can be entered in any currency.

Because limits are in the base currency, every amount-vs-limit comparison must first convert the bill total to the base currency (convertAmount). The bill list and detail routes do this and return the flow computed on the converted amount — needsManager, needsAdmin, needsSuperAdmin, businessUnitLimitExceeded, approvalAmountBase, approvalBaseCurrency (helper approvalFlowFlags). The client renders those flags rather than recomputing, because it only receives the bill’s own-currency figure. A bill above the BU approver’s limit escalates past the manager (Manager step becomes Not Required) to Admin + SuperAdmin, and a BU admin cannot approve it. The unit’s approver is resolved as the department head (resolvePrimaryBuApprover excludes company-wide admin company roles, so a tenant super admin who also holds a business-unit ADMIN row is not mistaken for it).

The ladder also escalates by tier existence, not only by amount. Each required tier must have a person; if it does not, the requirement rolls up to the next tier that does — no BU approver → company admin; no company admin → tenant super admin. The tenant super admin is the top of the ladder and always present, and their approval fills every lower unset level, so they can complete a bill alone with no double step. approvalFlowFlags takes tier presence (hasBuApprover, hasCompanyAdmin) for this. Note: the company admin limit must be computed with highestAdminApprovalLimit() — it is null (no cap) when the company has no admin. A previous reduce(..., 0) returned 0 there, which made every bill skip the manager and go straight to super admin.

A company-wide approver (organization admin / tenant super admin) approves across every unit by virtue of their company role, not a per-unit assignment, so the Finance user page does not offer a per-BU limit for them (a per-BU row cannot be saved for them and would not feed the admin tier anyway). Instead, for an organization admin the page shows one editable “Company approval limit (base currency)” field. It saves via the companyApprovalLimit action of PATCH /admin/finance-access (gated by canManagePrimary — org admin / tenant super admin only), which calls Auth’s PATCH /internal/admin/companies/{companyId}/company-approval-limit/{userId} setter to write the company-membership approvalLimit. highestAdminApprovalLimit then reads that value, so a bill above the org admin’s limit escalates to the tenant super admin. The tenant super admin sits at the top of the ladder (approves alone, unlimited) and has no settable cap — their per-unit rows still read “Unlimited — approves company-wide”.

A business-unit department head’s Auth role is ADMIN (not APPROVER; company-membership ADMIN is the separate company-wide admin). Both the backend and the Finance frontend must treat business-unit role ADMIN or APPROVER as the unit’s approver — the FE permission hook and role helpers (getPrimaryRole, getDisplayRole, canApproveAmount, the nav badge, etc.) otherwise hide the Approve action from a real approver.

Auth company roles that carry no Finance authority

Section titled “Auth company roles that carry no Finance authority”

Auth company_memberships.role values MANAGER and SUBMITTER are the organization level Member. They must NOT map to Finance’s CompanyRole.FINANCEgetInvoiceScopeForUser reads a company FINANCE (or ADMIN) membership as “sees every invoice in the company”, so mapping a Member there gave ordinary staff company-wide visibility. Finance keeps the membership row with a null role, so the company link, approval limit, and primary-finance-admin flag survive while the person’s real authority stays their business-unit role.

Auth company roleFinance CompanyRole
TENANT_SUPERADMIN, ADMINADMIN
FINANCEFINANCE
MANAGER, SUBMITTER (Member)null

Deleted Auth users and invoice submitter display

Section titled “Deleted Auth users and invoice submitter display”

Finance stores opaque Auth user ids on Invoice.createdById / Income.createdById. Names are normally resolved live from Auth.

When a user account is hard-deleted from a persona app, Finance first runs POST /api/internal/users/{userId}/purge with mode: "anonymize" and { email, name }. That stamps createdByEmail and createdByName on owned rows without changing createdById. UI and emails fall back to the snapshot when Auth lookup returns nothing.

Platform Admin hard-delete continues to use mode: "purge" with per-company reassignment targets (different product rule).