Frontend Finance
Finance access boundary
Section titled “Finance access boundary”Finance business units are read-only Core data. The Company Users screen edits only approval settings on existing organization/BU memberships: BU approval limit, primary approver, Primary Finance Admin, and — for an organization admin — the company-wide approval limit. Who approves is derived from the organization level (see below). Invitations, module access, business-unit structure, and BU assignment link back to the user’s main persona app.
Approver level is derived and read-only (2026-07-21). Finance shows each business-unit row’s level — “Approver — tenant super admin / organization admin / business unit admin”, or “Submitter” — derived from the organization role set in the persona app. Only the approval limit and primary-approver flag are editable, and both are disabled for a submitter. The PATCH payload carries no role.
Company approval limit for an organization admin (2026-07-23). A company-wide approver approves across every unit by their company role, so no per-BU limit applies to them. For an organization admin the detail page shows one editable “Company approval limit (base currency)” field (payload { action: 'companyApprovalLimit', userId, approvalLimit }, gated by canManagePrimary); their per-BU rows read “Set by company approval limit above”. A bill above that limit escalates to the tenant super admin, who is the top of the ladder — unlimited, no settable cap (“Unlimited — approves company-wide”).
Shape (2026-07-21): /settings/company/users is a searchable roster — one row per user with a Primary Finance Admin badge and an Approver/Submitter count. Role editing lives on /settings/company/users/[userId]. Both read GET /admin/finance-access; there is no single-user endpoint, and the detail page selects its user from that payload. A user with no business-unit assignment shows an explanation pointing at the persona app — Finance can only set the workflow role on an assignment that already exists, and PATCH /admin/finance-access returns 409 if asked to do otherwise.
Related documentation: Finance Module Backend · Frontend Applications
Runtime status
Section titled “Runtime status”Frontend-Kisum-Finance is implemented.
Purpose
Section titled “Purpose”This is the finance workflow UI plus vendor portal. It consumes the separate Finance backend and does not own backend finance truth.
Implemented now
Section titled “Implemented now”Internal finance app
Section titled “Internal finance app”- login and forgot-password flows
- company dashboard
- bills
- invoices
- overdue bills
- vendors and vendor detail
- users and my-team
- company/business-unit pages
- Xero callback and company Xero pages
- settings pages — sidebar Settings opens
/settings/company/dashboard(company details only). Theme preference lives in the sidebar, not on the settings page. Legacy/company/*redirects to/settings/company/*. Finance does not offer sign-out — users log out from their base Kisum app or Auth. - in-app docs page
- sync/errors review pages
Vendor portal
Section titled “Vendor portal”- vendor login
- vendor dashboard/layout
- vendor bills list
- vendor bill detail
- vendor new bill submission
- vendor settings
Current route model
Section titled “Current route model”Representative live route families:
/(auth)/login/(auth)/forgot-password/(main)/(main)/bills/(main)/bills/[id]/(main)/invoices/(main)/invoices/[id]/(main)/vendors/(main)/vendors/[id]/(main)/users/(main)/my-team/(main)/settings/company/*— company dashboard, users, business units, Kisum, Xero (active company)/(main)/settings/organizations— superadmin company directory (legacy/companiesredirect)/(main)/settings/users— platform user directory (god/moderator)/(main)/settings/companies/[companyId]/*— superadmin company admin by URL id/(main)/errors/(main)/docs/vendor/login/vendor/bills/vendor/bills/new/vendor/bills/[id]
Vendor portal + payments (2026-07-12)
Section titled “Vendor portal + payments (2026-07-12)”- Vendor bill screens call the backend at
/api/vendor/invoices*(the UI routes stay at/vendor/bills*). The old/api/vendor/bills*fetch paths never existed on the backend — do not reintroduce them. PaymentDrawersupports companies without Xero:GET /api/xero/bank-accountsreturnsxeroConnected; whenfalsethe drawer shows a free-text “Bank account name or reference” input instead of the Xero account dropdown.
Still true
Section titled “Still true”- this frontend is not the owner of schema/migrations
- this frontend is not the owner of Xero/S3/SES/OpenAI/Gemini server credentials
- company master lifecycle is not owned here
Drift to avoid
Section titled “Drift to avoid”Docs should not describe this repo as a thin placeholder. It has a real internal app and a real vendor portal.
Cross-app company handoff (?setCompany=)
Section titled “Cross-app company handoff (?setCompany=)”Persona base apps (Promoters, Artists, Venues) open Finance with the user’s active Auth tenant pre-selected so multi-company users (including platform staff) skip the Finance company picker.
| Piece | Contract |
|---|---|
| Sender | Append ?setCompany=<Auth tenant UUID> to NEXT_PUBLIC_FINANCE_APP_URL links (optional extra params: depositId, eventId, …). Helpers: buildFinanceAppUrl() in each persona frontend. Company id source: kisum_active_company cookie / getCompanyId(). |
| Receiver (this app) | useSyncCompanyQueryParam resolves setCompany against accessible Finance companies by canonical Core Company.id, mirrors it to selectedCompanyId for header transport, and strips the query param. Fallback: read kisum_active_company (works on the shared *.kisum.io cookie domain; URL param is required for plain localhost cross-origin dev). |
| Backend | x-org accepts the canonical Core company UUID only. |
Authoritative FE detail: Frontend-Kisum-Finance/docs/FRONTEND_X_ORG.md (Cross-app handoff section).
x-org readiness — never fetch before the header exists (2026-07-22)
Section titled “x-org readiness — never fetch before the header exists (2026-07-22)”The active company id is mirrored to localStorage.selectedCompanyId, which is the only transport the global fetch patch has for the x-org header. That mirror is written synchronously inside CompanyContext.setSelectedCompany, not in a provider effect: React flushes child effects before parent effects, so a provider effect runs after every page’s data-loading effect, and the page’s company-scoped fetches leave without the header. Backend x-org.middleware.ts then answers 400 Bad Request on required routes — the symptom was two 400s in the console on every load of the Finance home page (GET /api/admin/stats?companyId=…, GET /api/admin/business-units/by-company?companyId=…) that succeeded on the retry.
Consumers gate company-scoped requests on xOrgReady (useCompanyContext(); also folded into useCompanySectionScope().ready):
xOrgReady = !loading && Boolean(selectedCompany?.id)!loading alone is not sufficient — it only means the context finished resolving, not that a company was found. Do not relax the backend x-org middleware to make first-paint 400s go away; rejecting a tenant-less request on a required route is the correct, fail-closed behavior. This is a frontend ordering problem and belongs in the frontend.
Design system alignment (2026-07-13, full redesign pass)
Section titled “Design system alignment (2026-07-13, full redesign pass)”Finance internal app and vendor portal follow the Kisum design system visual contract (3-0-kisum-design-system):
| Layer | Implementation |
|---|---|
| Body canvas | min-h-screen bg-gray-50 antialiased dark:bg-gray-900 on root <body> |
| Shell composer | components/app-shell/app-shell-layout.tsx — LoadingProvider → CompanyProvider → FinanceAddonGuardProvider → PageProvider → AppShellProvider → AppSidebar + MainColumn + PageHeader (Phase 3, 2026-07-24) |
| Side nav | Flat Promoters DOM: side-nav/app-sidebar.tsx (composer), nav-main.tsx (permission menu), nav-user.tsx (footer + theme + settings). Read-only company row with package subtitle, Finance chip, back link — no company switcher. Width via var(--nav-w) / ui/app-shell.tsx — no manual lg:ml-* |
| Main chrome | main-chrome/page-header.tsx (.k-appbar breadcrumb + urgent/default notifications), custom-breadcrumb.tsx, page-main-section.tsx |
| Main work area | White bg-main panel on the gray canvas with lg:border-l (MainColumn) |
| Loading overlay | LoadingSpinner uses lg:left-[var(--nav-w)] — legacy sidebar-layout.ts removed |
| Cards / stats | Dashboard top-level boxes are bg-muted/60 group panels with white bg-card inner tiles; cards elsewhere bg-card + border-gray-100 dark:border-white/10, rounded-xl max — zero gradients, no heavy shadows at rest (overlays max shadow-lg) |
| Buttons | Shared Button (src/components/ui/button.tsx): kisum (solid #8466AC, rounded-full) primary, kisum_outline / outline / ghost secondary — gradient CTAs removed app-wide |
| Accent | Kisum purple (kisum-600) for primary actions, active nav, badges only (Purple Budget Rule) |
| Typography | Inter (body/data) + Manrope on h1–h3 globally (base rule in globals.css). Text color rule: text-foreground/text-muted-foreground only (+text-destructive for validation) — colored text lives only in badges/buttons |
| Header / container | Single-line breadcrumb header (Kisum › Section › Page, h-14, no repeated page title); page content in the canonical container mx-auto max-w-7xl p-6 sm:p-8 lg:p-10 |
| Dark mode | Charcoal/zinc per DS dark spec: main #09090B, elevated surfaces #18181B, subtle outlines; default theme system |
Shared nav helpers: src/lib/kisum-nav.ts. Page chrome: src/components/app-shell/main-chrome/page-main-section.tsx (PageMainSection, PAGE_MAIN_SECTION_CLASS, PageSectionHeader). Product voice sidecars in the repo: PRODUCT.md + DESIGN.md.
Still future: vendor portal shell parity with Promoters AppShellProvider (internal app migrated 2026-07-24).
Effective permission intersection
Section titled “Effective permission intersection”Parallel-slot routing must match segment boundaries (2026-08-06)
Section titled “Parallel-slot routing must match segment boundaries (2026-08-06)”RootSlotSwitcher chooses between the children and @main slots by pathname.
It used pathname.startsWith('/vendor') for the vendor portal — and
'/vendors'.startsWith('/vendor') is true, so the internal Vendors page (an
@main route) was routed to the children slot, matched nothing there, and fell
through to default.tsx, which renders null.
The whole screen was unreachable, and nothing reported it: HTTP 200, an empty
<body>, no console error, no server error, no failing request.
Rule: slot roots match on the segment boundary — pathname === root || pathname.startsWith(root + '/') — never a bare prefix. The same switcher pattern
is used in Promoters, Artists and Venues.
?setCompany= must not be stripped with router.replace() (2026-08-06)
Section titled “?setCompany= must not be stripped with router.replace() (2026-08-06)”useSyncCompanyQueryParam removed the param with router.replace(). On a
redirect-only page (/settings/company → /settings/company/users) that
navigation races the server redirect() and kills the App Router with the same
changed-hook-count error. It is intermittent — it failed twice in one test run
and passed in the next on identical code — so it survives casual testing.
Use window.history.replaceState: tidying the address bar does not need a router
navigation, and cannot collide with a redirect in flight. ?setCompany= is the
documented cross-app handoff from the persona apps, so this is the main entry path.