Files
zooqueen 50b856c4e3 a discount is computed by the server, and there is nothing to type
The one discount mechanism is the platform promo: a SuperAdmin sets
{percentOff, start, end, plans, active}, commerce annotates each covered
plan with promoPercent, and subscribe_card.go subtracts it. chargeTotal
mirrors that arithmetic, so a covered plan is discounted with nothing
typed. It carries no code and nobody redeems it.

Three code-entry paths shipped beside it and none could work. The fleet
routes none of the addresses they called — /v1/coupon/validate,
/v1/billing/discount/validate and /v1/billing/subscriptions/{id}/promotion
are absent from GET /v1/openapi.json, whose /v1/billing product publishes
38 concrete paths and no catch-all to hide behind. The hero field on
/commerce also held its code in local state and handed it to nothing, so
a successful validation would have claimed "Code applied" over a cart
that never saw it.

Gone: the /commerce hero field, the subscription portal's discount input,
validateDiscountCode, applyDiscount, and addDeposit with the two handlers
that reached it — onAddFunds and onGrantCredit were declared by
OverviewDashboard and never invoked, and the add-funds affordance is
already a link to /topup.

discount-surface.test.ts holds the line: no placeholder may invite a
code, the client may name none of those addresses, and chargeTotal must
still subtract promoPercent. Mutation-checked, all three caught.
2026-08-17 08:36:44 -07:00

1075 lines
48 KiB
TypeScript

'use client'
import { useEffect, useState, useCallback, useMemo } from 'react'
// Every billing surface comes from ONE place: the local `lib/billing` barrel.
// Two reasons, and both still hold. The published @hanzo/ui/billing bundle drags
// in a first-party card form that reads the raw PAN into JS — Square Web Payments
// hosted fields are the only card entry path here, and scripts/pci-scope.mjs
// fails the build on an import of it. And @hanzo/ui 8 dropped ./billing anyway.
// The local copies were always the correct ones: PaymentMethodManager tokenizes
// through the Square SDK, and SubscriptionPortal gives per-seat plans a seat
// selector and paid tiers the honest activation flow rather than the mint-gated
// subscribe POST that 403s for non-platform callers.
import {
OverviewDashboard,
UsagePanel,
CostExplorer,
InvoicesPayments,
CreditsPanel,
TransactionsPanel,
SpendAlertsPanel,
PaymentMethodManager,
AccountMembers,
BusinessProfilePanel,
TaxCompliancePanel,
SubscriptionPortal,
GuidedSetup,
} from '@/lib/billing'
import type {
BillingSection,
BillingAccount,
BillingAccountMember,
BillingRole,
PaymentMethod,
Invoice,
BusinessProfile,
TaxSettings,
ComplianceItem,
SpendAlert,
UsageSummary,
CreditGrant,
TransactionRecord,
Subscription,
SubscriptionPlan,
} from '@/lib/billing'
import type { Balance, CardInput, CommerceBillingAccount, CommerceBillingAccountMember, CommerceInvoice, CommercePaymentMethod, CommerceSpendAlert, CommerceCreditGrant, Transaction, Plan, Subscription as CommerceSubscription } from '@/lib/commerce-client'
import { CommerceApiError } from '@/lib/commerce-client'
import type { IamUser } from '@/lib/iam-auth'
import { getAccessToken, isSuperAdmin, signOut } from '@/lib/iam-auth'
import { type BillingOrgConfig, BASE_PATH, SALES_URL } from '@/lib/config'
import { getCommerce } from '@/lib/commerce'
import { selectPlans } from '@/lib/plans'
import { OrgHeader, type HanzoOrg } from '@hanzogui/shell'
/* ── Sidebar section config ───────────────────────────────────── */
const SIDEBAR_SECTIONS: Array<{ id: BillingSection; label: string; icon: React.ReactNode; group: 'main' | 'manage' }> = [
{ id: 'overview', label: 'Overview', group: 'main', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg> },
{ id: 'usage', label: 'Usage', group: 'main', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v18h18" /><path d="M7 16l4-8 4 4 5-9" /></svg> },
{ id: 'invoices', label: 'Invoices', group: 'main', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z" /><path d="M8 7h8" /><path d="M8 11h8" /><path d="M8 15h4" /></svg> },
{ id: 'pricing', label: 'Pricing', group: 'main', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" /></svg> },
{ id: 'credits', label: 'Credits', group: 'main', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="8" cy="8" r="6" /><path d="M18.09 10.37A6 6 0 1 1 10.34 18" /><path d="M7 6h1v4" /><path d="M16.71 13.88l.7.71-2.82 2.82" /></svg> },
{ id: 'transactions', label: 'Transactions', group: 'main', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3l4 4-4 4" /><path d="M3 11h18" /><path d="M7 21l-4-4 4-4" /><path d="M21 13H3" /></svg> },
{ id: 'alerts', label: 'Budgets & Alerts', group: 'manage', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /></svg> },
{ id: 'payment', label: 'Payment Methods', group: 'manage', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2" /><path d="M2 10h20" /></svg> },
{ id: 'team', label: 'Team', group: 'manage', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg> },
{ id: 'settings', label: 'Settings', group: 'manage', icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" /></svg> },
]
const SECTION_TITLES: Record<BillingSection, string> = {
overview: 'Overview',
usage: 'Usage & Cost Analysis',
invoices: 'Invoices',
pricing: 'Pricing & Plans',
credits: 'Credits',
transactions: 'Transactions',
alerts: 'Budgets & Alerts',
payment: 'Payment Methods',
team: 'Team',
settings: 'Settings',
}
function getInitialSection(chosen?: string): BillingSection {
if (typeof window === 'undefined') return 'overview'
const hash = window.location.hash.replace('#', '') as BillingSection
if (hash && hash in SECTION_TITLES) return hash
// A plan carried in is a pricing intent whether or not the link spelled the
// hash, so the grid opens instead of the overview.
if (chosen) return 'pricing'
return 'overview'
}
interface BillingShellProps {
org: BillingOrgConfig
user: IamUser
/** A plan the buyer already picked — see SubscriptionPortal's `chosen`. */
chosen?: string
}
const EMPTY_SUBSCRIPTION: Subscription = {
id: '',
customerId: '',
planId: '',
status: 'active',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
}
const EMPTY_BUSINESS_PROFILE: BusinessProfile = {
legalName: '',
displayName: '',
registeredAddress: { line1: '', city: '', zip: '', country: 'US' },
}
const EMPTY_TAX_SETTINGS: TaxSettings = {
automaticCollection: false,
registrations: [],
}
function mapCommerceInvoice(ci: CommerceInvoice): Invoice {
return {
id: ci.id ?? '',
invoiceNumber: ci.number ?? ci.id ?? '',
date: ci.createdAt ? new Date(ci.createdAt) : new Date(),
dueDate: ci.dueAt ? new Date(ci.dueAt) : undefined,
amount: (ci.amount ?? 0) / 100,
tax: ci.tax ? ci.tax / 100 : undefined,
total: (ci.total ?? ci.amount ?? 0) / 100,
status: (ci.status as Invoice['status']) ?? 'pending',
pdfUrl: ci.pdfUrl ?? undefined,
currency: ci.currency ?? 'usd',
number: ci.number,
items: ci.items?.map((item) => ({
id: item.id ?? '',
description: item.description ?? '',
quantity: item.quantity ?? 1,
unitPrice: (item.unitPrice ?? 0) / 100,
total: (item.total ?? 0) / 100,
})),
}
}
function mapCommercePaymentMethod(cpm: CommercePaymentMethod): PaymentMethod {
return {
id: cpm.id ?? '',
type: (cpm.type as PaymentMethod['type']) ?? 'card',
is_default: cpm.isDefault ?? false,
created_at: cpm.createdAt ?? new Date().toISOString(),
isDefault: cpm.isDefault ?? false,
providerRef: cpm.providerRef,
card: cpm.card ? {
brand: cpm.card.brand ?? '',
last4: cpm.card.last4 ?? '',
exp_month: cpm.card.expMonth ?? 0,
exp_year: cpm.card.expYear ?? 0,
funding: cpm.card.funding ?? 'credit',
} : undefined,
crypto: cpm.crypto ? {
network: (cpm.crypto.network as PaymentMethod['crypto'] extends undefined ? never : NonNullable<PaymentMethod['crypto']>['network']) ?? 'ethereum',
walletAddress: cpm.crypto.walletAddress ?? '',
label: cpm.crypto.label ?? undefined,
} : undefined,
wire: cpm.wire ? {
bankName: cpm.wire.bankName ?? '',
accountHolder: cpm.wire.accountHolder ?? '',
routingNumber: cpm.wire.routingNumber ?? undefined,
accountLast4: cpm.wire.accountLast4 ?? undefined,
swift: cpm.wire.swift ?? undefined,
iban: cpm.wire.iban ?? undefined,
country: cpm.wire.country ?? undefined,
} : undefined,
paypal: cpm.paypal ? { email: cpm.paypal.email ?? '' } : undefined,
bank_account: cpm.bankAccount ? {
bankName: cpm.bankAccount.bankName ?? undefined,
last4: cpm.bankAccount.last4 ?? undefined,
country: cpm.bankAccount.country ?? undefined,
currency: cpm.bankAccount.currency ?? undefined,
} : undefined,
billing_details: cpm.billingDetails ? {
name: cpm.billingDetails.name ?? undefined,
email: cpm.billingDetails.email ?? undefined,
} : undefined,
}
}
function mapCommerceCreditGrant(cg: CommerceCreditGrant): CreditGrant {
return {
id: cg.id ?? '',
userId: cg.userId,
name: cg.name ?? 'Credit',
amountCents: cg.amountCents ?? 0,
remainingCents: cg.remainingCents ?? 0,
currency: cg.currency ?? 'usd',
expiresAt: cg.expiresAt,
priority: cg.priority,
eligibility: cg.eligibility,
voided: cg.voided ?? false,
active: cg.active ?? true,
createdAt: cg.createdAt,
}
}
function mapCommerceTransaction(tx: Transaction): TransactionRecord {
return {
id: tx.id ?? '',
type: tx.type,
amountCents: tx.amount ?? 0,
currency: tx.currency ?? 'usd',
description: tx.notes ?? (tx.metadata?.description as string | undefined),
balanceAfter: tx.metadata?.balanceAfter as number | undefined,
// commerce serialises tags as a comma-joined string when there is only one
tags: typeof tx.tags === 'string' ? tx.tags.split(',').filter(Boolean) : tx.tags,
createdAt: tx.createdAt ?? new Date().toISOString(),
}
}
function mapCommerceBillingAccount(cba: CommerceBillingAccount): BillingAccount {
return {
id: cba.id ?? '',
name: cba.name ?? '',
orgId: cba.orgId ?? '',
orgName: cba.orgName ?? '',
balance: cba.balance ?? 0,
currency: cba.currency ?? 'usd',
plan: cba.plan ?? 'free',
role: (cba.role as BillingRole) ?? 'viewer',
createdAt: cba.createdAt ?? new Date().toISOString(),
}
}
function mapCommerceBillingAccountMember(cbm: CommerceBillingAccountMember): BillingAccountMember {
return {
id: cbm.id ?? '',
userId: cbm.userId ?? '',
email: cbm.email ?? '',
name: cbm.name ?? '',
role: (cbm.role as BillingRole) ?? 'viewer',
addedAt: cbm.addedAt ?? new Date().toISOString(),
}
}
function mapCommerceSpendAlert(csa: CommerceSpendAlert): SpendAlert {
return {
id: csa.id ?? '',
title: csa.title ?? '',
threshold: csa.threshold ?? 0,
currency: csa.currency ?? 'USD',
triggeredAt: csa.triggeredAt ?? null,
createdAt: csa.createdAt ?? new Date().toISOString(),
updatedAt: csa.updatedAt ?? new Date().toISOString(),
}
}
// Subscription tiers shown on the pricing grid (the catalog also carries
// dns/world/social plans, which have their own product surfaces).
/** Normalise any API envelope (array / {data:[]} / {items:[]} etc.) to a plain array. */
function asList<T>(v: unknown): T[] {
if (Array.isArray(v)) return v as T[]
if (v !== null && typeof v === 'object') {
const o = v as Record<string, unknown>
for (const key of ['data', 'items', 'records', 'results', 'list', 'grants', 'transactions']) {
if (Array.isArray(o[key])) return o[key] as T[]
}
}
return []
}
export function BillingShell({ org, user, chosen }: BillingShellProps) {
const [balance, setBalance] = useState<Balance | null>(null)
const [subscription, setSubscription] = useState<Subscription>(EMPTY_SUBSCRIPTION)
const [plans, setPlans] = useState<SubscriptionPlan[]>([])
const [plansLoading, setPlansLoading] = useState(true)
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([])
const [invoices, setInvoices] = useState<Invoice[]>([])
const [businessProfile, setBusinessProfile] = useState<BusinessProfile>(EMPTY_BUSINESS_PROFILE)
const [taxSettings] = useState<TaxSettings>(EMPTY_TAX_SETTINGS)
const [complianceItems] = useState<ComplianceItem[]>([])
const [spendAlerts, setSpendAlerts] = useState<SpendAlert[]>([])
const [usage, setUsage] = useState<UsageSummary | null>(null)
const [usageLoading, setUsageLoading] = useState(false)
const [usageError, setUsageError] = useState<string | null>(null)
// Why a fetch produced nothing, per panel. An empty list and a failed read
// look identical once they reach a component, and on a money page the
// difference is the whole message: "you have no transactions" is a fact about
// the account, "we could not read them" is a fact about us. The commerce
// client no longer turns a 404 or a 502 into an empty success, so the
// rejection actually arrives here and the panel can say which one it has.
const [loadErrors, setLoadErrors] = useState<Record<string, string>>({})
const [creditGrants, setCreditGrants] = useState<CreditGrant[]>([])
const [creditBalance, setCreditBalance] = useState(0)
const [transactions, setTransactions] = useState<TransactionRecord[]>([])
const [transactionsLoading, setTransactionsLoading] = useState(false)
const [billingAccounts, setBillingAccounts] = useState<BillingAccount[]>([])
const [activeBillingAccountId, setActiveBillingAccountId] = useState<string | null>(null)
// The org the customer switched the header to — null is the signed-in
// subject (the token's owner slug, which every read below is keyed on).
const [subjectOrgId, setSubjectOrgId] = useState<string | null>(null)
const [accountMembers, setAccountMembers] = useState<BillingAccountMember[]>([])
const [userRole, setUserRole] = useState<BillingRole>('owner')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadBillingData = useCallback(async () => {
try {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.commerceUserId || user.email || user.sub
const [bal, planList, subs, invList, pmList, alertList, cgList, cBal, txList, acctList] = await Promise.allSettled([
commerce.getBalance(userId),
commerce.getPlans(),
commerce.getUserSubscriptions(userId),
commerce.getInvoices(userId, { limit: 50 }),
commerce.getPaymentMethods(userId),
commerce.getSpendAlerts(userId),
commerce.getCreditGrants(userId),
commerce.getCreditBalance(userId),
commerce.getTransactions(userId, { limit: 100 }),
commerce.listBillingAccounts(),
])
const failed: Record<string, string> = {}
// A refused read and an unreadable one are different sentences, and the
// client already knows which it has: CommerceApiError carries the HTTP
// status. Reading only .message renders an expired session as whatever
// the gateway put in the body — "Bad Gateway" for a customer whose only
// problem is a stale token, and no hint that signing in again fixes it.
const why = (r: PromiseSettledResult<unknown>) => {
if (r.status !== 'rejected') return ''
const e: unknown = r.reason
if (e instanceof CommerceApiError) {
// 401 and 403 are both refusals but they ask for different things:
// one is fixed by signing in again, the other never is, and sending
// a signed-in customer back to the login page fixes nothing twice.
if (e.status === 401) return 'Your session expired. Sign in again to see this.'
if (e.status === 403) return 'This account does not have access to this.'
return `Billing couldn't answer (${e.status}). ${e.message}`.trim()
}
// No status at all: the request never got an answer — timeout, abort,
// DNS. That is ours to say plainly rather than dress as a refusal.
return e instanceof Error ? e.message : String(e)
}
for (const [panel, result] of [
['balance', bal], ['plans', planList], ['subscriptions', subs],
['invoices', invList], ['payment', pmList], ['alerts', alertList],
['credits', cgList], ['creditBalance', cBal], ['transactions', txList],
['accounts', acctList],
] as const) {
if (result.status === 'rejected') failed[panel] = why(result)
}
setLoadErrors(failed)
if (bal.status === 'fulfilled') setBalance(bal.value)
if (planList.status === 'fulfilled') {
setPlans(selectPlans(asList<Plan>(planList.value)))
}
// Loading is over either way; a rejected fetch leaves plans empty and
// the portal shows an honest catalog error, never stale local copy.
setPlansLoading(false)
if (subs.status === 'fulfilled' && asList<CommerceSubscription>(subs.value).length > 0) {
const subsList = asList<CommerceSubscription>(subs.value)
const activeSub = subsList.find((s) => s.status === 'active' || s.status === 'trialing') ?? subsList[0]
setSubscription({
id: activeSub.id ?? '',
customerId: activeSub.userId ?? userId,
planId: activeSub.planId ?? '',
status: (activeSub.status as Subscription['status']) ?? 'active',
currentPeriodStart: activeSub.periodStart ? new Date(activeSub.periodStart) : new Date(),
currentPeriodEnd: activeSub.periodEnd ? new Date(activeSub.periodEnd) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
discount: activeSub.discount ?? null,
})
}
if (invList.status === 'fulfilled') {
setInvoices(asList<CommerceInvoice>(invList.value).map(mapCommerceInvoice))
}
if (pmList.status === 'fulfilled') {
setPaymentMethods(asList<CommercePaymentMethod>(pmList.value).map(mapCommercePaymentMethod))
}
if (alertList.status === 'fulfilled') {
setSpendAlerts(asList<CommerceSpendAlert>(alertList.value).map(mapCommerceSpendAlert))
}
if (cgList.status === 'fulfilled') {
const grants = asList<CommerceCreditGrant>(cgList.value).map(mapCommerceCreditGrant)
if (grants.length > 0) {
setCreditGrants(grants)
} else if (txList.status === 'fulfilled') {
// Starter credits are no longer issued, but accounts that hold one
// still hold real money. Commerce recorded it as a Deposit transaction
// tagged "starter-credit" (deliberately NOT a credit-grant — it nets
// into the balance ledger), so getCreditGrants() is empty and we
// synthesize a grant from that transaction to keep it visible.
const txs = asList<Transaction>(txList.value)
const creditTxs = txs.filter((tx) => tx.type === 'deposit' && (tx.tags === 'starter-credit' || (Array.isArray(tx.tags) && tx.tags.includes('starter-credit'))))
setCreditGrants(creditTxs.map((tx): CreditGrant => ({
id: tx.id ?? '',
// The tag is the source of truth for what this deposit is; the
// transaction's notes are freeform ("Welcome credit: $100.00 USD …")
// and make a poor row label.
name: 'Starter Credit',
amountCents: tx.amount ?? 0,
remainingCents: tx.amount ?? 0,
currency: tx.currency ?? 'usd',
expiresAt: tx.expiresAt,
active: true,
createdAt: tx.createdAt ?? new Date().toISOString(),
})))
}
}
if (cBal.status === 'fulfilled') {
const balances = cBal.value.balances ?? []
const usdBal = balances.find((b) => (b.currency ?? 'usd').toLowerCase() === 'usd')
const creditGrantBalance = usdBal?.available ?? 0
// Credit grants are a separate entity from the transaction-based balance.
// The starter credit is recorded as a transaction, so prefer the
// transaction balance when credit-grant balance is zero.
if (creditGrantBalance > 0) {
setCreditBalance(creditGrantBalance)
} else if (bal.status === 'fulfilled') {
setCreditBalance(bal.value.available ?? 0)
}
} else if (bal.status === 'fulfilled') {
// credit-balance endpoint failed — fall back to transaction balance
setCreditBalance(bal.value.available ?? 0)
}
if (txList.status === 'fulfilled') {
setTransactions(asList<Transaction>(txList.value).map(mapCommerceTransaction))
}
if (acctList.status === 'fulfilled' && asList<CommerceBillingAccount>(acctList.value).length > 0) {
const mapped = asList<CommerceBillingAccount>(acctList.value).map(mapCommerceBillingAccount)
setBillingAccounts(mapped)
// Set active to first account if none selected
setActiveBillingAccountId((prev) => prev ?? mapped[0].id)
// Set user role from active (or first) account
const active = mapped.find((a) => a.id === activeBillingAccountId) ?? mapped[0]
setUserRole(active.role)
// Load members for active account
try {
const members = await commerce.listAccountMembers(active.id)
setAccountMembers(members.map(mapCommerceBillingAccountMember))
} catch {
// Members endpoint optional
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load billing data')
} finally {
setLoading(false)
setPlansLoading(false)
}
}, [org, user])
const loadUsage = useCallback(async () => {
try {
const token = getAccessToken()
if (!token) return
setUsageLoading(true)
setUsageError(null)
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.commerceUserId || user.email || user.sub
const usageData = await commerce.getUsage(userId)
if (usageData && usageData.records && usageData.records.length > 0) {
setUsage({
totalCost: usageData.totalCost ?? 0,
currency: usageData.currency ?? 'usd',
period: {
start: usageData.period?.start ?? new Date().toISOString(),
end: usageData.period?.end ?? new Date().toISOString(),
},
records: usageData.records.map((r) => ({
meterId: (r.meterId as UsageSummary['records'][0]['meterId']) ?? 'api_calls',
label: r.label ?? r.meterId ?? '',
current: r.current ?? 0,
limit: r.limit ?? null,
unit: r.unit ?? '',
cost: r.cost ?? 0,
periodStart: r.periodStart ?? '',
periodEnd: r.periodEnd ?? '',
})),
})
}
} catch (e) {
// Usage does not block the UI, but a FAILURE is not an absence. Swallowing
// it left `usage` null, and null already meant "no usage this period" — so
// a 502 from the meter rendered as "No usage data available", telling a
// customer their account was idle while the service that knows was down.
// The two facts get two values now, and the panel says which one it has.
setUsageError(e instanceof Error ? e.message : 'Usage is unavailable right now.')
} finally {
setUsageLoading(false)
}
}, [org, user])
useEffect(() => {
loadBillingData()
loadUsage()
}, [loadBillingData, loadUsage])
const admin = isSuperAdmin(user)
// ── Handlers ──────────────────────────────────────────────────────────
const handleUpgrade = useCallback(async (planId: string) => {
// Paid-tier subscribe is mint-gated server-side (403 for non-platform
// callers). For overview-originated upgrades, land on the pricing section
// where the honest activation flow (top-up + request) lives.
const target = plans.find((p) => p.id === planId)
if (target && target.price > 0 && !admin) {
window.location.hash = 'pricing'
return
}
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.commerceUserId || user.email || user.sub
if (subscription.id) {
await commerce.updateSubscription(subscription.id, { planId })
} else {
await commerce.subscribe({ planId, userId })
}
await loadBillingData()
}, [org, user, subscription.id, loadBillingData, plans, admin])
const handleDowngrade = useCallback(async (planId: string) => {
await handleUpgrade(planId)
}, [handleUpgrade])
// Express paid checkout: charge a card already on file (paymentMethodId) and
// start the subscription in one call. Rethrows CommerceApiError (with .status)
// so the pricing grid can branch on 402/404/409/422/500 — handing card-fixable
// failures to the pay.hanzo.ai checkout and naming the rest. On success, refresh
// state to show the active plan. New-card entry lives at pay.hanzo.ai.
const handleSubscribeWithCard = useCallback(async (planId: string, card: CardInput, quantity: number, idempotencyKey: string) => {
const token = getAccessToken()
if (!token) throw new Error('Your session expired. Please sign in again.')
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.commerceUserId || user.email || user.sub
await commerce.subscribeWithCard(
{ ...card, planId, userId, quantity, currency: 'usd' },
{ idempotencyKey },
)
await loadBillingData()
}, [org, user, loadBillingData])
// Cards on file the checkout can CHARGE. Two tests, not one: it must be a card,
// and it must have a providerRef — the vaulted card a charge names. Commerce
// refuses a method without one ("saved payment method has no chargeable card"),
// so offering it put a dead option in the checkout and spent the customer's click
// on a 422. The label is the receipt name commerce stamps ("Visa ending in 4242")
// or the card facts, falling back to a generic that is still honest.
const savedCards = useMemo(() => paymentMethods
.filter((m) => m.type === 'card' && !!m.providerRef)
.map((m) => ({
id: m.id,
label: m.card?.brand && m.card?.last4
? `${m.card.brand} ending in ${m.card.last4}`
: 'Saved card',
isDefault: m.isDefault ?? m.is_default,
})), [paymentMethods])
const handleCancel = useCallback(async (_reason: string, _feedback?: string) => {
const token = getAccessToken()
if (!token || !subscription.id) return
const commerce = getCommerce(org.commerceUrl, token)
await commerce.cancelSubscription(subscription.id)
await loadBillingData()
}, [subscription.id, org, loadBillingData])
const handleAddPaymentMethod = useCallback(async (method: PaymentMethod) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.commerceUserId || user.email || user.sub
// If the method has a Square sourceId token, use the dedicated
// tokenizeAndAddCard flow which does real $1 pre-auth verification.
if (method._sourceToken && method.type === 'card') {
await commerce.tokenizeAndAddCard(method._sourceToken, userId)
await loadBillingData()
return
}
await commerce.addPaymentMethod({
userId,
type: method.type,
isDefault: method.is_default,
card: method.card ? {
brand: method.card.brand,
last4: method.card.last4,
expMonth: method.card.exp_month,
expYear: method.card.exp_year,
funding: method.card.funding,
} : undefined,
crypto: method.crypto,
wire: method.wire,
paypal: method.paypal,
bankAccount: method.bank_account ? {
bankName: method.bank_account.bankName,
last4: method.bank_account.last4,
country: method.bank_account.country,
currency: method.bank_account.currency,
} : undefined,
billingDetails: method.billing_details ? {
name: method.billing_details.name,
email: method.billing_details.email,
} : undefined,
})
setPaymentMethods((prev: PaymentMethod[]) => [...prev, method])
}, [org, user])
const handleRemovePaymentMethod = useCallback(async (id: string) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
await commerce.removePaymentMethod(id)
setPaymentMethods((prev: PaymentMethod[]) => prev.filter((m) => m.id !== id))
}, [org])
const handleSetDefaultPaymentMethod = useCallback(async (id: string) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.commerceUserId || user.email || user.sub
await commerce.setDefaultPaymentMethod(id, undefined, userId)
setPaymentMethods((prev: PaymentMethod[]) =>
prev.map((m) => ({ ...m, is_default: m.id === id, isDefault: m.id === id }))
)
}, [org, user])
const handleDownloadInvoice = useCallback(async (invoice: Invoice) => {
const url = invoice.pdfUrl ?? invoice.invoicePdfUrl
if (url) window.open(url, '_blank')
}, [])
const handleSaveBusinessProfile = useCallback(async (profile: BusinessProfile) => {
setBusinessProfile(profile)
}, [])
const handleToggleAutomaticTax = useCallback(async (_enabled: boolean) => {
// Persisted via Commerce API when available
}, [])
const handleCreateSpendAlert = useCallback(async (title: string, threshold: number) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.commerceUserId || user.email || user.sub
const result = await commerce.createSpendAlert({ userId, title, threshold, currency: 'usd' })
setSpendAlerts((prev) => [...prev, mapCommerceSpendAlert(result)])
}, [org, user])
const handleUpdateSpendAlert = useCallback(async (id: string, title: string, threshold: number) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const result = await commerce.updateSpendAlert(id, { title, threshold })
setSpendAlerts((prev) => prev.map((a) => a.id === id ? mapCommerceSpendAlert(result) : a))
}, [org])
const handleDeleteSpendAlert = useCallback(async (id: string) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
await commerce.deleteSpendAlert(id)
setSpendAlerts((prev) => prev.filter((a) => a.id !== id))
}, [org])
const handleSubscribeSupport = useCallback(async (tierId: string) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.commerceUserId || user.email || user.sub
await commerce.subscribe({ planId: `support-${tierId}`, userId })
await loadBillingData()
}, [org, user, loadBillingData])
const handleSwitchAccount = useCallback(async (accountId: string) => {
setActiveBillingAccountId(accountId)
const account = billingAccounts.find((a) => a.id === accountId)
if (account) setUserRole(account.role)
// Reload members for new account
try {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const members = await commerce.listAccountMembers(accountId)
setAccountMembers(members.map(mapCommerceBillingAccountMember))
} catch {
setAccountMembers([])
}
// Reload billing data
await loadBillingData()
}, [billingAccounts, org, loadBillingData])
const handleCreateBillingAccount = useCallback(async () => {
const name = window.prompt('Billing account name:')
if (!name) return
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const result = await commerce.createBillingAccount({ name, orgId: org.id })
const mapped = mapCommerceBillingAccount(result)
setBillingAccounts((prev) => [...prev, mapped])
setActiveBillingAccountId(mapped.id)
setUserRole(mapped.role)
}, [org])
const handleInviteMember = useCallback(async (email: string, role: BillingRole) => {
const token = getAccessToken()
if (!token || !activeBillingAccountId) return
const commerce = getCommerce(org.commerceUrl, token)
const result = await commerce.addAccountMember(activeBillingAccountId, { email, role })
setAccountMembers((prev) => [...prev, mapCommerceBillingAccountMember(result)])
}, [org, activeBillingAccountId])
const handleChangeMemberRole = useCallback(async (memberId: string, role: BillingRole) => {
const token = getAccessToken()
if (!token || !activeBillingAccountId) return
const commerce = getCommerce(org.commerceUrl, token)
const result = await commerce.updateMemberRole(activeBillingAccountId, memberId, role)
setAccountMembers((prev) =>
prev.map((m) => m.id === memberId ? mapCommerceBillingAccountMember(result) : m)
)
}, [org, activeBillingAccountId])
const handleRemoveMember = useCallback(async (memberId: string) => {
const token = getAccessToken()
if (!token || !activeBillingAccountId) return
const commerce = getCommerce(org.commerceUrl, token)
await commerce.removeAccountMember(activeBillingAccountId, memberId)
setAccountMembers((prev) => prev.filter((m) => m.id !== memberId))
}, [org, activeBillingAccountId])
// ── Subject switcher ─────────────────────────────────────────────────
//
// ONE switcher: the shell header's own dropdown, the same control every
// Hanzo app switches subject with. It is fed every subject billing can
// name — the signed-in subject first (the token's owner slug, named by
// what it is), then each billing account's org. An account scoped to the
// signed-in subject's own org enriches that row rather than appearing
// twice.
const organizations = useMemo<HanzoOrg[]>(() => {
const orgs: HanzoOrg[] = [
{ id: user.commerceUserId, name: user.commerceUserId, slug: user.commerceUserId },
]
for (const a of billingAccounts) {
if (!a.orgId) continue
const existing = orgs.find((o) => o.id === a.orgId)
if (existing) {
existing.role = a.role
continue
}
orgs.push({ id: a.orgId, name: a.orgName || a.name, role: a.role })
}
return orgs
}, [billingAccounts, user.commerceUserId])
// Switching re-scopes what the client can re-scope today: the account
// plane (role, members) switches for real, and every read is re-issued.
// commerce's EdgeAuth locks the money reads' subject to the token's owner
// claim server-side, so those follow the signed-in subject until commerce
// honors an org scope — the switch never pretends otherwise.
const handleOrgSwitch = useCallback(
(orgId: string) => {
setSubjectOrgId(orgId === user.commerceUserId ? null : orgId)
const account = billingAccounts.find((a) => a.orgId === orgId)
if (account) {
void handleSwitchAccount(account.id)
} else {
void loadBillingData()
}
},
[billingAccounts, handleSwitchAccount, loadBillingData, user.commerceUserId],
)
const handleLogout = useCallback(() => {
// Navigate to what signOut returns — IAM's end-session URL — so the session ends
// there too and the next sign-in actually asks who you are.
window.location.href = signOut(org.iamServerUrl, `${window.location.origin}${BASE_PATH}/`)
}, [org.iamServerUrl])
// ── Section routing ─────────────────────────────────────────────────
const [activeSection, setActiveSection] = useState<BillingSection>(() => getInitialSection(chosen))
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const navigate = useCallback((section: BillingSection) => {
setActiveSection(section)
setMobileMenuOpen(false)
window.history.replaceState(null, '', `#${section}`)
}, [])
// Sync hash changes (back/forward buttons)
useEffect(() => {
const onHashChange = () => {
const hash = window.location.hash.replace('#', '') as BillingSection
if (hash && hash in SECTION_TITLES) setActiveSection(hash)
}
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [])
// The overview IS the setup card until the account can pay — but only until
// then. An account holding real credit has plenty honest to show (its
// balance, its usage, its invoices), and walling that behind "choose a plan"
// once trapped a six-figure prepaid account in onboarding and swallowed its
// Add-credit click. So: funded accounts always see the dashboard; the card
// remains the whole overview for accounts with no way to pay yet, which is
// where "paying is not optional" genuinely applies. There is still no
// dismiss, and every other section stays reachable from the sidebar.
const funded = (balance?.available ?? 0) > 0 || creditBalance > 0
// Setup is incomplete only when we READ that it is. The card list and the
// subscription are the two facts the wall is built on, and a failed read
// leaves both looking empty — which put a paying customer behind onboarding
// for an outage, with nothing they could do to clear it.
const setupUnread = !!(loadErrors.payment || loadErrors.subscriptions || loadErrors.balance || loadErrors.creditBalance)
const setupIncomplete = !funded && !setupUnread && (paymentMethods.length === 0 || !subscription.id)
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center bg-bg">
<div className="text-center">
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-ink/10 border-t-ink/60" />
<p className="mt-4 text-[13px] text-ink/30">Loading billing…</p>
</div>
</main>
)
}
/* ── Sidebar nav content (shared by desktop + mobile) ──────── */
const sidebarNav = (
<nav className="flex flex-col gap-0.5 px-3 py-4">
{SIDEBAR_SECTIONS.map((s, i) => {
const prev = SIDEBAR_SECTIONS[i - 1]
const showSeparator = prev && prev.group !== s.group
return (
<div key={s.id}>
{showSeparator && <div className="my-2 border-t border-ink/[0.08]" />}
<button
type="button"
onClick={() => navigate(s.id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-colors ${
activeSection === s.id
? 'bg-ink/[0.08] text-text'
: 'text-ink/40 hover:text-ink/70 hover:bg-ink/[0.04]'
}`}
>
<span className="flex-shrink-0">{s.icon}</span>
{s.label}
</button>
</div>
)
})}
</nav>
)
/* ── Section content ────────────────────────────────────────── */
const renderSection = () => {
switch (activeSection) {
case 'overview':
if (setupIncomplete) {
return (
<GuidedSetup
hasPaymentMethod={paymentMethods.length > 0}
hasSubscription={!!subscription.id}
hasSpendAlert={spendAlerts.length > 0}
onAddPaymentMethod={() => navigate('payment')}
onPickPlan={() => navigate('pricing')}
onSetSpendLimit={() => navigate('alerts')}
/>
)
}
return (
<OverviewDashboard
subscription={subscription}
plans={plans}
paymentMethods={paymentMethods}
usage={usage}
usageLoading={usageLoading}
usageError={usageError}
balance={balance}
balanceError={loadErrors.balance ?? null}
paymentError={loadErrors.payment ?? null}
onRetry={loadBillingData}
creditGrants={creditGrants}
creditBalance={creditBalance}
isAdmin={admin}
userEmail={user.email}
invoices={invoices}
spendAlerts={spendAlerts}
onNavigate={(target: string) => navigate(target.split(':')[0] as BillingSection)}
onUpgrade={handleUpgrade}
onDowngrade={handleDowngrade}
onCancel={handleCancel}
businessProfile={businessProfile}
onSaveBusinessProfile={handleSaveBusinessProfile}
onAddPaymentMethod={handleAddPaymentMethod}
onRemovePaymentMethod={handleRemovePaymentMethod}
onSetDefaultPaymentMethod={handleSetDefaultPaymentMethod}
/>
)
case 'usage':
return (
<div className="space-y-6">
<UsagePanel usage={usage} loading={usageLoading} error={usageError} onRetry={loadUsage} />
<CostExplorer usage={usage} usageLoading={usageLoading} />
</div>
)
case 'invoices':
return <InvoicesPayments invoices={invoices} error={loadErrors.invoices ?? null} onRetry={loadBillingData} onDownloadInvoice={handleDownloadInvoice} />
case 'pricing':
return (
<SubscriptionPortal
subscription={subscription}
subscriptionError={loadErrors.subscriptions ?? null}
availablePlans={plans}
plansLoading={plansLoading}
plansError={loadErrors.plans ?? null}
onRetry={loadBillingData}
canDirectSubscribe={admin}
defaultView="plans"
checkoutUrl={org.payUrl}
onUpgrade={handleUpgrade}
onDowngrade={handleDowngrade}
onCancel={handleCancel}
onSubscribeWithCard={handleSubscribeWithCard}
savedCards={savedCards}
chosen={chosen}
/>
)
case 'credits':
return (
<CreditsPanel
creditGrants={creditGrants}
creditBalance={creditBalance}
error={loadErrors.credits ?? null}
balanceError={loadErrors.creditBalance ?? null}
onRetry={loadBillingData}
/>
)
case 'transactions':
return <TransactionsPanel transactions={transactions} loading={transactionsLoading} error={loadErrors.transactions ?? null} onRetry={loadBillingData} />
case 'alerts':
return (
<SpendAlertsPanel
alerts={spendAlerts}
error={loadErrors.alerts ?? null}
onRetry={loadBillingData}
onCreateAlert={handleCreateSpendAlert}
onUpdateAlert={handleUpdateSpendAlert}
onDeleteAlert={handleDeleteSpendAlert}
/>
)
case 'payment':
return (
<PaymentMethodManager
error={loadErrors.payment ?? null}
onRetry={loadBillingData}
paymentMethods={paymentMethods as any}
onAdd={handleAddPaymentMethod}
onRemove={handleRemovePaymentMethod}
onSetDefault={handleSetDefaultPaymentMethod}
/>
)
case 'team':
return (
<AccountMembers
error={loadErrors.accounts ?? null}
members={accountMembers}
userRole={userRole}
onInvite={handleInviteMember}
onChangeRole={handleChangeMemberRole}
onRemove={handleRemoveMember}
/>
)
case 'settings':
return (
<div className="space-y-6">
<BusinessProfilePanel profile={businessProfile} onSave={handleSaveBusinessProfile} />
<TaxCompliancePanel
taxSettings={taxSettings}
complianceItems={complianceItems}
onToggleAutomaticTax={handleToggleAutomaticTax}
/>
</div>
)
default:
return null
}
}
return (
<div className="flex min-h-screen flex-col bg-bg">
{/* ── Unified Hanzo Header ──────────────────────── */}
<OrgHeader
currentApp="Billing"
currentAppId="billing"
user={{ name: user.displayName ?? '', email: user.email ?? '', avatar: user.avatar ?? '' }}
organizations={organizations}
currentOrgId={subjectOrgId ?? user.commerceUserId}
onOrgSwitch={handleOrgSwitch}
onSignOut={handleLogout}
headerRight={
<button
type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="flex-shrink-0 rounded-lg p-1.5 text-ink/40 hover:bg-ink/[0.06] hover:text-ink/70 md:hidden"
aria-label="Toggle menu"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{mobileMenuOpen
? <><path d="M18 6L6 18" /><path d="M6 6l12 12" /></>
: <><path d="M4 6h16" /><path d="M4 12h16" /><path d="M4 18h16" /></>}
</svg>
</button>
}
/>
<div className="flex flex-1">
{/* ── Desktop sidebar ──────────────────────────────── */}
<aside className="hidden w-60 flex-shrink-0 border-r border-ink/[0.07] bg-bg md:block">
{sidebarNav}
</aside>
{/* ── Mobile sidebar drawer ────────────────────────── */}
{mobileMenuOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/60 md:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
<aside className="fixed inset-y-0 left-0 z-50 w-64 border-r border-ink/[0.07] bg-bg pt-14 md:hidden">
{sidebarNav}
</aside>
</>
)}
{/* ── Main content ─────────────────────────────────── */}
<main className="flex-1 overflow-y-auto">
{/* max-w-5xl is the design system's widest container and the same width
the public pricing page gives this plan grid — four-up cards and the
compare table stay readable beside the 240px rail, and the grid wraps
to 2-up below xl. (The scale stops at 5xl; 4xl was too narrow here,
squeezing the four cards and leaving the wide left gutter.) */}
<div className="mx-auto max-w-5xl px-8 py-8">
{error && (
<div className="mb-6 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
</div>
)}
{activeSection !== 'overview' && (
<h1 className="mb-6 text-2xl font-bold tracking-tight text-text">
{SECTION_TITLES[activeSection]}
</h1>
)}
{renderSection()}
</div>
</main>
</div>
</div>
)
}