Skip to content

Frontend Finance

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

Frontend-Kisum-Finance is implemented.

This is the finance workflow UI plus vendor portal. It consumes the separate Finance backend and does not own backend finance truth.

  • 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 login
  • vendor dashboard/layout
  • vendor bills list
  • vendor bill detail
  • vendor new bill submission
  • vendor settings

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 /companies redirect)
  • /(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 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.
  • PaymentDrawer supports companies without Xero: GET /api/xero/bank-accounts returns xeroConnected; when false the drawer shows a free-text “Bank account name or reference” input instead of the Xero account dropdown.
  • 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

Docs should not describe this repo as a thin placeholder. It has a real internal app and a real vendor portal.

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.

PieceContract
SenderAppend ?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).
Backendx-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):

LayerImplementation
Body canvasmin-h-screen bg-gray-50 antialiased dark:bg-gray-900 on root <body>
Shell composercomponents/app-shell/app-shell-layout.tsxLoadingProviderCompanyProviderFinanceAddonGuardProviderPageProviderAppShellProviderAppSidebar + MainColumn + PageHeader (Phase 3, 2026-07-24)
Side navFlat 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 chromemain-chrome/page-header.tsx (.k-appbar breadcrumb + urgent/default notifications), custom-breadcrumb.tsx, page-main-section.tsx
Main work areaWhite bg-main panel on the gray canvas with lg:border-l (MainColumn)
Loading overlayLoadingSpinner uses lg:left-[var(--nav-w)] — legacy sidebar-layout.ts removed
Cards / statsDashboard 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)
ButtonsShared Button (src/components/ui/button.tsx): kisum (solid #8466AC, rounded-full) primary, kisum_outline / outline / ghost secondary — gradient CTAs removed app-wide
AccentKisum purple (kisum-600) for primary actions, active nav, badges only (Purple Budget Rule)
TypographyInter (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 / containerSingle-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 modeCharcoal/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).

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 boundarypathname === 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.