Finance Module Backend
Organization boundary
Section titled “Organization boundary”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
Overview
Section titled “Overview”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.
Ownership Model
Section titled “Ownership Model”Auth owns
Section titled “Auth owns”- 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.
Core owns
Section titled “Core owns”- 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
Current Runtime Rules
Section titled “Current Runtime Rules”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:
- the caller is authenticated
- Auth confirms the caller can act for the selected company
- Core confirms the selected company has the
financeadd-on / enabled module
If membership exists but the company is not entitled to Finance, Finance returns:
403- code:
finance_addon_required
x-org still selects the tenant
Section titled “x-org still selects the tenant”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-Venuesnow reuses FinancePOST|PATCH /api/incomein machinesource=venue_depositmode 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.
What Finance No Longer Does
Section titled “What Finance No Longer Does”Finance company lifecycle is removed.
These company master write endpoints should not be used anymore:
POST /api/admin/companiesPUT /api/admin/companiesPATCH /api/admin/companiesDELETE /api/admin/companiesPOST /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-unitsPUT /api/admin/business-unitsDELETE /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.
Route Families
Section titled “Route Families”Main route groups in the current implementation:
admin/*bills/*income/*income-categories/*vendor/*xero/*kisum/*files/*upload/*notificationsexchange-rates/*dashboard/*auth/vendor/*
These are documented in more detail here:
Current Integration Boundaries
Section titled “Current Integration Boundaries”Finance relies on Auth for:
GET /auth/mestyle 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 (XeroConnection → legacy_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 |
|---|---|---|
xeroAccountCode | defaultLedgerAccountId | ledger_accounts |
xeroTaxType | defaultTaxRateId | tax_rates |
xeroWithholdingAccountCode | withholdingLedgerAccountId | ledger_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,totalare derived and persisted.- Anything that still computes
amount + customRowsTotaldouble-counts. UsegetDocumentFinancials()insrc/lib/document-lines.ts. customRowsis 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.
Environment / Runtime Dependencies
Section titled “Environment / Runtime Dependencies”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
Prisma 7 setup
Section titled “Prisma 7 setup”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 insrc/generated/prisma(git-ignored, rebuilt bypostinstall/npm run build). Import it as@/generated/prisma/client— importing@prisma/clientis 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 fromprisma.config.ts(DATABASE_URL_DIRECT, falling back toDATABASE_URL), while the runtime connects through the@prisma/adapter-pgdriver adapter — Prisma 7 has no built-in query engine.
Local migrations (Makefile)
Section titled “Local migrations (Makefile)”Same command names as Auth/Core/Venues, but Finance stays on Prisma (not Go sqlmigrate):
| Target | Behavior |
|---|---|
make migrate-status | prisma migrate status |
make migrate-up | prisma migrate deploy (additive, non-destructive) |
make migrate-down | Refuses — Prisma has no automatic rollback; do not reset/drop. Undo with a new forward migration + migrate-up. |
make db-migrate | Alias 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_URLCORE_INTERNAL_API_KEY
Without those, commercial entitlement verification and Core org-master synchronization will fail.
Migration Status
Section titled “Migration Status”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/BusinessUnitPrisma 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/businessUnitIdwhere 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
Practical Rule For Engineers
Section titled “Practical Rule For Engineers”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
Shared authorization and approval ladder
Section titled “Shared authorization and approval ladder”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.
Where Finance permissions come from
Section titled “Where Finance permissions come from”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.FINANCE
— getInvoiceScopeForUser 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 role | Finance CompanyRole |
|---|---|
TENANT_SUPERADMIN, ADMIN | ADMIN |
FINANCE | FINANCE |
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).