Files
billing/lib/components/billing-shell.tsx
zeekay abe9bd26f7 fix: resolve u.value.map crash + unify header with HanzoShell design
- Add asList<T>() normalizer to safely handle API envelopes ({data:[]}, etc.)
- Apply asList() to all Promise.allSettled result .map() calls (plans, invoices,
  payment methods, spend alerts, credit grants, transactions, billing accounts)
- Import CommerceSubscription alias to avoid Subscription type collision
- Header: official Hanzo H-mark logo (with shadow paths), bg-[#09090b]/90
- Add "← Account" back-link and app switcher (Account/Console/Chat/Platform)
- Consistent text sizing (11-13px), white/[0.07] borders, monochrome palette
2026-03-01 12:39:15 -08:00

960 lines
40 KiB
TypeScript

'use client'
import { useEffect, useState, useCallback } from 'react'
import {
OverviewDashboard,
UsagePanel,
CostExplorer,
InvoicesPayments,
SubscriptionPortal,
CreditsPanel,
TransactionsPanel,
SpendAlertsPanel,
PaymentMethodManager,
AccountMembers,
BusinessProfilePanel,
TaxCompliancePanel,
} from '@/lib/billing'
import type { BillingSection } from '@/lib/billing'
import type {
BillingAccount,
BillingAccountMember,
BillingRole,
Subscription,
SubscriptionPlan,
PaymentMethod,
Invoice,
BusinessProfile,
TaxSettings,
ComplianceItem,
SpendAlert,
UsageSummary,
DiscountCode,
CreditGrant,
TransactionRecord,
} from '@/lib/billing'
import type { Balance, CommerceBillingAccount, CommerceBillingAccountMember, CommerceInvoice, CommercePaymentMethod, CommerceSpendAlert, CommerceCreditGrant, Transaction, Plan, Subscription as CommerceSubscription } from '@/lib/commerce-client'
import type { IamUser } from '@/lib/iam-auth'
import { getAccessToken, logout } from '@/lib/iam-auth'
import { type BillingOrgConfig, isAdminUser, HANZO_PLANS, mergePlans } from '@/lib/config'
import { getCommerce } from '@/lib/commerce'
/* ── 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(): BillingSection {
if (typeof window === 'undefined') return 'overview'
const hash = window.location.hash.replace('#', '') as BillingSection
if (hash && hash in SECTION_TITLES) return hash
return 'overview'
}
interface BillingShellProps {
org: BillingOrgConfig
user: IamUser
}
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,
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.metadata?.description as string | undefined,
balanceAfter: tx.metadata?.balanceAfter as number | undefined,
tags: 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(),
}
}
/** 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']) {
if (Array.isArray(o[key])) return o[key] as T[]
}
}
return []
}
export function BillingShell({ org, user }: BillingShellProps) {
const [balance, setBalance] = useState<Balance | null>(null)
const [subscription, setSubscription] = useState<Subscription>(EMPTY_SUBSCRIPTION)
const [plans, setPlans] = useState<SubscriptionPlan[]>(HANZO_PLANS)
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 [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)
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.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(),
])
if (bal.status === 'fulfilled') setBalance(bal.value)
if (planList.status === 'fulfilled') {
const remotePlans: SubscriptionPlan[] = asList<Plan>(planList.value).map((p) => ({
id: p.slug ?? '',
name: p.name ?? '',
description: p.description ?? '',
price: p.price ?? 0,
billingPeriod: (p.interval as 'monthly' | 'yearly') ?? 'monthly',
features: [],
currency: p.currency ?? 'usd',
}))
setPlans(mergePlans(remotePlans))
}
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') {
setCreditGrants(asList<CommerceCreditGrant>(cgList.value).map(mapCommerceCreditGrant))
}
if (cBal.status === 'fulfilled') {
const balances = cBal.value.balances ?? []
const usdBal = balances.find((b) => (b.currency ?? 'usd').toLowerCase() === 'usd')
setCreditBalance(usdBal?.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)
}
}, [org, user])
const loadUsage = useCallback(async () => {
try {
const token = getAccessToken()
if (!token) return
setUsageLoading(true)
const commerce = getCommerce(org.commerceUrl, token)
const userId = 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 {
// Usage data is optional — don't block the UI
} finally {
setUsageLoading(false)
}
}, [org, user])
useEffect(() => {
loadBillingData()
loadUsage()
}, [loadBillingData, loadUsage])
// Auto-grant $5 starter credit for new users on first visit
useEffect(() => {
if (loading || creditGrants.length > 0) return
const autoGrant = async () => {
try {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.email || user.sub
await commerce.grantStarterCredit(userId)
await loadBillingData()
} catch {
// Starter credit may already exist or endpoint unavailable
}
}
autoGrant()
}, [loading, creditGrants.length, org, user, loadBillingData])
const admin = isAdminUser(user.email)
// ── Handlers ──────────────────────────────────────────────────────────
const handleAddFunds = useCallback(async (amount: number) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.email || user.sub
await commerce.addDeposit({ user: userId, amount: Math.round(amount * 100), currency: 'usd' })
const newBalance = await commerce.getBalance(userId)
setBalance(newBalance)
}, [org, user])
const handleGrantCredit = useCallback(async (email: string, amount: number) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
await commerce.addDeposit({ user: email, amount: Math.round(amount * 100), currency: 'usd', notes: `Admin credit grant by ${user.email}` })
if (email === user.email || email === user.sub) {
const newBalance = await commerce.getBalance(email)
setBalance(newBalance)
}
}, [org, user])
const handleUpgrade = useCallback(async (planId: string) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = 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])
const handleDowngrade = useCallback(async (planId: string) => {
await handleUpgrade(planId)
}, [handleUpgrade])
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 handleApplyDiscount = useCallback(async (code: string): Promise<DiscountCode | null> => {
const token = getAccessToken()
if (!token || !subscription.id) return null
const commerce = getCommerce(org.commerceUrl, token)
const result = await commerce.applyDiscount(subscription.id, code)
if (result) {
await loadBillingData()
return {
id: result.id ?? '',
code: result.code ?? code,
name: result.name ?? null,
kind: result.kind ?? 'percent',
value: result.value ?? 0,
currency: result.currency ?? null,
duration: result.duration ?? null,
durationInMonths: result.durationInMonths ?? null,
valid: result.valid ?? true,
}
}
return null
}, [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.email || user.sub
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)
await commerce.setDefaultPaymentMethod(id)
setPaymentMethods((prev: PaymentMethod[]) =>
prev.map((m) => ({ ...m, is_default: m.id === id, isDefault: m.id === id }))
)
}, [org])
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.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 handleClaimStarterCredit = useCallback(async () => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = user.email || user.sub
await commerce.grantStarterCredit(userId)
await loadBillingData()
}, [org, user, loadBillingData])
const handleSubscribeSupport = useCallback(async (tierId: string) => {
const token = getAccessToken()
if (!token) return
const commerce = getCommerce(org.commerceUrl, token)
const userId = 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])
const handleLogout = useCallback(() => {
logout()
window.location.href = '/'
}, [])
// ── Section routing ─────────────────────────────────────────────────
const [activeSection, setActiveSection] = useState<BillingSection>(getInitialSection)
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)
}, [])
const starterCredit = creditGrants.find((g) => g.active && g.name?.toLowerCase().includes('starter')) ?? null
if (loading) {
return (
<main className="flex min-h-screen items-center justify-center bg-[#09090b]">
<div className="text-center">
<div className="mx-auto h-5 w-5 animate-spin rounded-full border-2 border-white/10 border-t-white/60" />
<p className="mt-4 text-[13px] text-white/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-white/[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-white/[0.08] text-white'
: 'text-white/40 hover:text-white/70 hover:bg-white/[0.04]'
}`}
>
<span className="flex-shrink-0">{s.icon}</span>
{s.label}
</button>
</div>
)
})}
</nav>
)
/* ── Section content ────────────────────────────────────────── */
const renderSection = () => {
switch (activeSection) {
case 'overview':
return (
<OverviewDashboard
subscription={subscription}
plans={plans}
paymentMethods={paymentMethods}
usage={usage}
usageLoading={usageLoading}
balance={balance}
creditGrants={creditGrants}
creditBalance={creditBalance}
starterCredit={starterCredit}
onAddFunds={handleAddFunds}
onClaimStarterCredit={handleClaimStarterCredit}
isAdmin={admin}
onGrantCredit={admin ? handleGrantCredit : undefined}
userEmail={user.email}
invoices={invoices}
spendAlerts={spendAlerts}
onNavigate={(target: string) => navigate(target.split(':')[0] as BillingSection)}
onUpgrade={handleUpgrade}
onDowngrade={handleDowngrade}
onCancel={handleCancel}
onApplyDiscount={handleApplyDiscount}
businessProfile={businessProfile}
onSaveBusinessProfile={handleSaveBusinessProfile}
onAddPaymentMethod={handleAddPaymentMethod}
onRemovePaymentMethod={handleRemovePaymentMethod}
onSetDefaultPaymentMethod={handleSetDefaultPaymentMethod}
/>
)
case 'usage':
return (
<div className="space-y-6">
<UsagePanel usage={usage} loading={usageLoading} />
<CostExplorer usage={usage} usageLoading={usageLoading} />
</div>
)
case 'invoices':
return <InvoicesPayments invoices={invoices} onDownloadInvoice={handleDownloadInvoice} />
case 'pricing':
return (
<SubscriptionPortal
subscription={subscription}
availablePlans={plans}
onUpgrade={handleUpgrade}
onDowngrade={handleDowngrade}
onCancel={handleCancel}
onApplyDiscount={handleApplyDiscount}
/>
)
case 'credits':
return (
<CreditsPanel
creditGrants={creditGrants}
creditBalance={creditBalance}
onClaimStarterCredit={handleClaimStarterCredit}
/>
)
case 'transactions':
return <TransactionsPanel transactions={transactions} loading={transactionsLoading} />
case 'alerts':
return (
<SpendAlertsPanel
alerts={spendAlerts}
onCreateAlert={handleCreateSpendAlert}
onUpdateAlert={handleUpdateSpendAlert}
onDeleteAlert={handleDeleteSpendAlert}
/>
)
case 'payment':
return (
<PaymentMethodManager
paymentMethods={paymentMethods}
onAdd={handleAddPaymentMethod}
onRemove={handleRemovePaymentMethod}
onSetDefault={handleSetDefaultPaymentMethod}
/>
)
case 'team':
return (
<AccountMembers
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-[#09090b]">
{/* ── Header (shared HanzoHeader design) ──────────────────────── */}
<header className="sticky top-0 z-50 flex h-14 w-full items-center justify-between border-b border-white/[0.07] bg-[#09090b]/90 px-4 backdrop-blur-xl">
{/* Left: logo / breadcrumb / app switcher / mobile hamburger */}
<div className="flex min-w-0 items-center gap-2.5">
{/* Mobile hamburger */}
<button
type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="flex-shrink-0 rounded-lg p-1.5 text-white/40 hover:bg-white/[0.06] hover:text-white/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>
{/* Official Hanzo H-mark (with shadow paths + hover animation) */}
<a
href="https://hanzo.id/account"
className="flex-shrink-0 rounded transition-opacity hover:opacity-75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
aria-label="Hanzo Account"
>
<svg width="22" height="22" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg">
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff" />
<path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#DDDDDD" />
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff" />
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff" />
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff" />
<path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#DDDDDD" />
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff" />
</svg>
</a>
<span className="select-none text-white/[0.15]">/</span>
<span className="truncate text-[13px] font-medium text-white/50">Billing</span>
{/* App switcher */}
<div className="relative hidden md:block" id="billing-app-switcher">
<div className="ml-1 flex items-center gap-0.5">
{([
{ label: 'Account', href: 'https://hanzo.id/account' },
{ label: 'Console', href: 'https://console.hanzo.ai' },
{ label: 'Chat', href: 'https://chat.hanzo.ai' },
{ label: 'Platform', href: 'https://platform.hanzo.ai' },
] as { label: string; href: string }[]).map((app) => (
<a
key={app.label}
href={app.href}
className="rounded-md px-2.5 py-1 text-[11px] font-medium text-white/25 transition-colors hover:bg-white/[0.05] hover:text-white/60"
>
{app.label}
</a>
))}
</div>
</div>
</div>
{/* Right: user email + sign out */}
<div className="flex flex-shrink-0 items-center gap-2">
<span className="hidden text-[11px] text-white/30 sm:inline">{user.email}</span>
<a
href="https://hanzo.id/account"
className="hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] font-medium text-white/35 transition hover:bg-white/[0.05] hover:text-white/60 sm:flex"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
Account
</a>
<button
type="button"
onClick={handleLogout}
className="rounded-lg px-3 py-1.5 text-[11px] font-medium text-white/35 transition hover:bg-white/[0.05] hover:text-white/60"
>
Sign out
</button>
</div>
</header>
<div className="flex flex-1">
{/* ── Desktop sidebar ──────────────────────────────── */}
<aside className="hidden w-60 flex-shrink-0 border-r border-white/[0.07] bg-[#0d0d10] 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-white/[0.07] bg-[#0d0d10] pt-14 md:hidden">
{sidebarNav}
</aside>
</>
)}
{/* ── Main content ─────────────────────────────────── */}
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl 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-white">
{SECTION_TITLES[activeSection]}
</h1>
)}
{renderSection()}
</div>
</main>
</div>
</div>
)
}