Files
carmanagement/admin/src/app/dashboard/billing/page.tsx
T
root d7fb7b7a7b
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
redesign the homepage
2026-06-26 16:27:21 -04:00

1150 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import type { ReactNode } from 'react'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { useAdminI18n } from '@/components/I18nProvider'
import { ADMIN_API_BASE } from '@/lib/api'
interface BillingStats {
billingAccountCount: number
openInvoiceCount: number
pastDueInvoiceCount: number
paidInvoiceCount: number
accountsReceivableTotal: number
recognizedRevenueTotal: number
}
interface SubscriptionSummary {
id: string
plan: string
billingPeriod: string
status: string
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
interface CompanySummary {
id: string
name: string
email: string
slug: string
status: string
subscription: SubscriptionSummary | null
}
interface CreditBalance {
id: string
currency: string
balanceAmount: number
}
interface InvoiceLineItem {
id?: string
type?: string
description: string
quantity: number
unitAmount: number
amount: number
currency: string
periodStart?: string | null
periodEnd?: string | null
}
interface PaymentAttempt {
id: string
status: string
amount: number
currency: string
providerPaymentId?: string | null
failureCode?: string | null
failureMessage?: string | null
attemptedAt: string
}
interface CreditNote {
id: string
amount: number
currency: string
reason: string
status: string
createdAt: string
}
interface Refund {
id: string
amount: number
currency: string
reason: string
status: string
createdAt: string
}
interface TaxRecord {
id: string
taxAmount: number
taxRate?: number | null
taxExempt: boolean
taxType?: string | null
}
interface BillingEvent {
id: string
eventType: string
source: string
createdAt: string
}
interface BillingInvoice {
id: string
invoiceNumber: string | null
invoiceType: string
status: string
currency: string
totalAmount: number
amountPaid: number
amountDue: number
invoiceDate: string | null
dueAt: string | null
paidAt: string | null
createdAt: string
isSubscriptionBlocking: boolean
lineItems: InvoiceLineItem[]
paymentAttempts: PaymentAttempt[]
creditNotes: CreditNote[]
refunds: Refund[]
taxRecords: TaxRecord[]
}
interface BillingAccountSummary {
id: string
companyId: string
legalName: string
billingEmail: string
invoiceTerms: string
netTermsDays: number
taxId?: string | null
taxExempt: boolean
dunningPaused: boolean
createdAt: string
company: CompanySummary
invoices: BillingInvoice[]
creditBalances: CreditBalance[]
openBalance: number
paidBalance: number
creditBalance: number
}
interface BillingAccountDetail extends BillingAccountSummary {
billingAddress?: Record<string, unknown> | null
paymentMethods: Array<{
id: string
type: string
label?: string | null
brand?: string | null
last4?: string | null
isDefault: boolean
isActive: boolean
}>
creditLedgerEntries: Array<{
id: string
type: string
amount: number
currency: string
reason: string
createdAt: string
}>
events: BillingEvent[]
}
interface PaginatedResponse<T> {
data: T[]
total: number
page: number
pageSize: number
totalPages: number
stats?: BillingStats
}
interface DraftLineItem {
type: string
description: string
quantity: number
unitAmount: number
}
const ACCOUNT_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-300 bg-emerald-500/15',
TRIALING: 'text-sky-300 bg-sky-500/15',
PAYMENT_PENDING: 'text-amber-300 bg-amber-500/15',
PAST_DUE: 'text-rose-300 bg-rose-500/15',
SUSPENDED: 'text-red-300 bg-red-500/15',
CANCELLED: 'text-zinc-300 bg-zinc-700/60',
UNPAID: 'text-red-300 bg-red-500/15',
}
const INVOICE_STATUS_COLORS: Record<string, string> = {
DRAFT: 'text-zinc-300 bg-zinc-700/60',
OPEN: 'text-amber-300 bg-amber-500/15',
PAYMENT_PENDING: 'text-amber-300 bg-amber-500/15',
PARTIALLY_PAID: 'text-sky-300 bg-sky-500/15',
PAID: 'text-emerald-300 bg-emerald-500/15',
PAST_DUE: 'text-rose-300 bg-rose-500/15',
VOID: 'text-zinc-300 bg-zinc-700/60',
UNCOLLECTIBLE: 'text-red-200 bg-red-950/60',
REFUNDED: 'text-zinc-300 bg-zinc-700/60',
PARTIALLY_REFUNDED: 'text-zinc-300 bg-zinc-700/60',
}
const FIELD_CLASS =
'w-full rounded-xl border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none transition focus:border-blue-700'
const ACTION_PRIMARY_CLASS =
'rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400'
const ACTION_SECONDARY_CLASS =
'rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 hover:border-zinc-500'
const draftLineItem = (): DraftLineItem => ({
type: 'PROFESSIONAL_SERVICES',
description: '',
quantity: 1,
unitAmount: 0,
})
function money(amount: number, currency: string) {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount / 100)
}
function dateLabel(value: string | null | undefined) {
if (!value) return '—'
return new Date(value).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
}
function termsLabel(invoiceTerms: string, netTermsDays: number) {
if (invoiceTerms === 'DUE_ON_RECEIPT') return 'Due on receipt'
return `Net ${netTermsDays || invoiceTerms.replace('NET_', '')}`
}
export default function AdminBillingPage() {
const { language } = useAdminI18n()
const [isPending, startTransition] = useTransition()
const copy = {
en: {
title: 'Billing Console',
eyebrow: 'Platform Finance',
searchPlaceholder: 'Search company, billing contact, or slug',
all: 'All',
active: 'Active',
pastDue: 'Past Due',
suspended: 'Suspended',
cancelled: 'Cancelled',
accounts: 'Accounts',
receivables: 'Receivables',
openInvoices: 'Open invoices',
paidInvoices: 'Paid invoices',
revenue: 'Revenue',
billingAccounts: 'Billing Accounts',
selectAccount: 'Select a billing account to manage invoices, credits, dunning, and payment history.',
company: 'Company',
plan: 'Plan',
subscription: 'Subscription',
openBalance: 'Open balance',
credit: 'Credit',
dunning: 'Dunning',
accountDetails: 'Billing Account',
invoiceFeed: 'Invoices',
draftInvoice: 'Create Draft Invoice',
saveAccount: 'Save account',
pauseDunning: 'Pause dunning',
resumeDunning: 'Resume dunning',
createDraft: 'Create draft',
loading: 'Loading…',
noAccounts: 'No billing accounts found',
amountDue: 'Amount due',
amountPaid: 'Amount paid',
dueDate: 'Due date',
issueDate: 'Issue date',
actions: 'Actions',
lineItems: 'Line items',
payments: 'Payment attempts',
credits: 'Credits',
refunds: 'Refunds',
timeline: 'Timeline',
finalize: 'Finalize',
pay: 'Record payment',
retry: 'Retry payment',
void: 'Void',
writeoff: 'Write off',
refund: 'Refund',
creditNote: 'Credit note',
downloadPdf: 'PDF',
invoiceType: 'Invoice type',
blocking: 'Blocking invoice',
reason: 'Reason',
billingEmail: 'Billing email',
legalName: 'Legal name',
taxId: 'Tax ID',
taxExempt: 'Tax exempt',
invoiceTerms: 'Invoice terms',
selectedInvoice: 'Selected invoice',
noInvoices: 'No invoices yet',
},
fr: {
title: 'Console de facturation',
eyebrow: 'Finance plateforme',
searchPlaceholder: 'Rechercher une entreprise, un contact facturation ou un slug',
all: 'Tous',
active: 'Actifs',
pastDue: 'En retard',
suspended: 'Suspendus',
cancelled: 'Annulés',
accounts: 'Comptes',
receivables: 'Créances',
openInvoices: 'Factures ouvertes',
paidInvoices: 'Factures payées',
revenue: 'Revenu',
billingAccounts: 'Comptes de facturation',
selectAccount: 'Sélectionnez un compte de facturation pour gérer factures, crédits, relances et paiements.',
company: 'Entreprise',
plan: 'Plan',
subscription: 'Abonnement',
openBalance: 'Solde ouvert',
credit: 'Crédit',
dunning: 'Relance',
accountDetails: 'Compte de facturation',
invoiceFeed: 'Factures',
draftInvoice: 'Créer un brouillon',
saveAccount: 'Enregistrer',
pauseDunning: 'Mettre en pause',
resumeDunning: 'Reprendre',
createDraft: 'Créer le brouillon',
loading: 'Chargement…',
noAccounts: 'Aucun compte trouvé',
amountDue: 'Montant dû',
amountPaid: 'Montant payé',
dueDate: 'Échéance',
issueDate: 'Date émission',
actions: 'Actions',
lineItems: 'Lignes',
payments: 'Tentatives de paiement',
credits: 'Crédits',
refunds: 'Remboursements',
timeline: 'Historique',
finalize: 'Finaliser',
pay: 'Enregistrer paiement',
retry: 'Relancer paiement',
void: 'Annuler',
writeoff: 'Passer en perte',
refund: 'Rembourser',
creditNote: 'Avoir',
downloadPdf: 'PDF',
invoiceType: 'Type de facture',
blocking: 'Facture bloquante',
reason: 'Raison',
billingEmail: 'Email de facturation',
legalName: 'Raison sociale',
taxId: 'Identifiant fiscal',
taxExempt: 'Exonéré de taxe',
invoiceTerms: 'Conditions',
selectedInvoice: 'Facture sélectionnée',
noInvoices: 'Aucune facture',
},
ar: {
title: 'لوحة الفوترة',
eyebrow: 'مالية المنصة',
searchPlaceholder: 'ابحث عن الشركة أو جهة الفوترة أو الرابط',
all: 'الكل',
active: 'نشط',
pastDue: 'متأخر',
suspended: 'معلق',
cancelled: 'ملغى',
accounts: 'الحسابات',
receivables: 'الذمم',
openInvoices: 'الفواتير المفتوحة',
paidInvoices: 'الفواتير المدفوعة',
revenue: 'الإيراد',
billingAccounts: 'حسابات الفوترة',
selectAccount: 'اختر حساب فوترة لإدارة الفواتير والأرصدة ومحاولات الدفع.',
company: 'الشركة',
plan: 'الخطة',
subscription: 'الاشتراك',
openBalance: 'الرصيد المفتوح',
credit: 'الرصيد',
dunning: 'المتابعة',
accountDetails: 'حساب الفوترة',
invoiceFeed: 'الفواتير',
draftInvoice: 'إنشاء مسودة فاتورة',
saveAccount: 'حفظ',
pauseDunning: 'إيقاف المتابعة',
resumeDunning: 'استئناف المتابعة',
createDraft: 'إنشاء المسودة',
loading: 'جارٍ التحميل…',
noAccounts: 'لا توجد حسابات فوترة',
amountDue: 'المبلغ المستحق',
amountPaid: 'المبلغ المدفوع',
dueDate: 'تاريخ الاستحقاق',
issueDate: 'تاريخ الإصدار',
actions: 'الإجراءات',
lineItems: 'البنود',
payments: 'محاولات الدفع',
credits: 'الأرصدة',
refunds: 'الاستردادات',
timeline: 'السجل',
finalize: 'اعتماد',
pay: 'تسجيل الدفع',
retry: 'إعادة المحاولة',
void: 'إلغاء',
writeoff: 'شطب',
refund: 'استرداد',
creditNote: 'إشعار دائن',
downloadPdf: 'PDF',
invoiceType: 'نوع الفاتورة',
blocking: 'فاتورة حاجزة',
reason: 'السبب',
billingEmail: 'بريد الفوترة',
legalName: 'الاسم القانوني',
taxId: 'الرقم الضريبي',
taxExempt: 'معفى من الضريبة',
invoiceTerms: 'شروط الفاتورة',
selectedInvoice: 'الفاتورة المحددة',
noInvoices: 'لا توجد فواتير',
},
}[language]
const [accounts, setAccounts] = useState<BillingAccountSummary[]>([])
const [stats, setStats] = useState<BillingStats | null>(null)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [loadingList, setLoadingList] = useState(true)
const [listError, setListError] = useState<string | null>(null)
const [selectedCompanyId, setSelectedCompanyId] = useState<string | null>(null)
const [detail, setDetail] = useState<BillingAccountDetail | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
const [detailError, setDetailError] = useState<string | null>(null)
const [accountForm, setAccountForm] = useState({
legalName: '',
billingEmail: '',
taxId: '',
taxExempt: false,
invoiceTerms: 'DUE_ON_RECEIPT',
netTermsDays: 0,
})
const [invoiceDraft, setInvoiceDraft] = useState({
invoiceType: 'MANUAL',
isSubscriptionBlocking: false,
adminReason: '',
lineItems: [draftLineItem()],
})
const [selectedInvoiceId, setSelectedInvoiceId] = useState<string | null>(null)
const [payAmount, setPayAmount] = useState('')
const [creditAmount, setCreditAmount] = useState('')
const [creditReason, setCreditReason] = useState('')
const [refundAmount, setRefundAmount] = useState('')
const [refundReason, setRefundReason] = useState('')
const [voidReason, setVoidReason] = useState('')
const [writeoffReason, setWriteoffReason] = useState('')
const [actionError, setActionError] = useState<string | null>(null)
const [actionMessage, setActionMessage] = useState<string | null>(null)
const filters = [
{ key: '', label: copy.all },
{ key: 'ACTIVE', label: copy.active },
{ key: 'PAST_DUE', label: copy.pastDue },
{ key: 'SUSPENDED', label: copy.suspended },
{ key: 'CANCELLED', label: copy.cancelled },
]
const selectedInvoice = useMemo(
() => detail?.invoices.find((invoice) => invoice.id === selectedInvoiceId) ?? detail?.invoices[0] ?? null,
[detail, selectedInvoiceId],
)
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
credentials: 'include',
cache: 'no-store',
})
if (res.headers.get('content-type')?.includes('application/pdf')) {
return await (res.blob() as unknown as Promise<T>)
}
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? `Request failed (${res.status})`)
return json.data as T
}
async function fetchAccounts(q?: string, status?: string) {
setLoadingList(true)
setListError(null)
try {
const params = new URLSearchParams({ pageSize: '100' })
if (q) params.set('q', q)
if (status) params.set('status', status)
const data = await api<PaginatedResponse<BillingAccountSummary>>(`/admin/billing?${params.toString()}`)
setAccounts(data.data)
setStats(data.stats ?? null)
setSelectedCompanyId((current) => current ?? data.data[0]?.company.id ?? null)
} catch (error: any) {
setListError(error.message)
} finally {
setLoadingList(false)
}
}
async function fetchDetail(companyId: string) {
setLoadingDetail(true)
setDetailError(null)
try {
const data = await api<BillingAccountDetail>(`/admin/billing/${companyId}`)
setDetail(data)
setAccountForm({
legalName: data.legalName,
billingEmail: data.billingEmail,
taxId: data.taxId ?? '',
taxExempt: data.taxExempt,
invoiceTerms: data.invoiceTerms,
netTermsDays: data.netTermsDays,
})
setSelectedInvoiceId(data.invoices[0]?.id ?? null)
} catch (error: any) {
setDetailError(error.message)
} finally {
setLoadingDetail(false)
}
}
async function refreshSelectedAccount() {
await fetchAccounts(search || undefined, statusFilter || undefined)
if (selectedCompanyId) await fetchDetail(selectedCompanyId)
}
useEffect(() => {
const id = setTimeout(() => {
fetchAccounts(search || undefined, statusFilter || undefined)
}, 200)
return () => clearTimeout(id)
}, [search, statusFilter])
useEffect(() => {
if (!selectedCompanyId) return
fetchDetail(selectedCompanyId)
}, [selectedCompanyId])
function setDraftLine(index: number, key: keyof DraftLineItem, value: string | number) {
setInvoiceDraft((current) => ({
...current,
lineItems: current.lineItems.map((item, itemIndex) =>
itemIndex === index ? { ...item, [key]: value } : item,
),
}))
}
async function saveBillingAccount() {
if (!detail) return
setActionError(null)
setActionMessage(null)
try {
await api(`/admin/billing/accounts/${detail.id}`, {
method: 'PATCH',
body: JSON.stringify({
legalName: accountForm.legalName,
billingEmail: accountForm.billingEmail,
taxId: accountForm.taxId || null,
taxExempt: accountForm.taxExempt,
invoiceTerms: accountForm.invoiceTerms,
netTermsDays: accountForm.netTermsDays,
}),
})
setActionMessage('Billing account updated.')
await refreshSelectedAccount()
} catch (error: any) {
setActionError(error.message)
}
}
async function toggleDunning(paused: boolean) {
if (!detail) return
setActionError(null)
setActionMessage(null)
try {
await api(`/admin/billing/accounts/${detail.id}/${paused ? 'pause-dunning' : 'resume-dunning'}`, {
method: 'POST',
body: JSON.stringify({}),
})
setActionMessage(paused ? 'Dunning paused.' : 'Dunning resumed.')
await refreshSelectedAccount()
} catch (error: any) {
setActionError(error.message)
}
}
async function createDraftInvoice() {
if (!detail) return
setActionError(null)
setActionMessage(null)
try {
await api(`/admin/billing/accounts/${detail.id}/invoices`, {
method: 'POST',
body: JSON.stringify({
invoiceType: invoiceDraft.invoiceType,
isSubscriptionBlocking: invoiceDraft.isSubscriptionBlocking,
adminReason: invoiceDraft.adminReason || null,
lineItems: invoiceDraft.lineItems.map((item) => ({
...item,
unitAmount: Number(item.unitAmount),
quantity: Number(item.quantity),
})),
}),
})
setInvoiceDraft({
invoiceType: 'MANUAL',
isSubscriptionBlocking: false,
adminReason: '',
lineItems: [draftLineItem()],
})
setActionMessage('Draft invoice created.')
await refreshSelectedAccount()
} catch (error: any) {
setActionError(error.message)
}
}
async function runInvoiceAction(path: string, body?: Record<string, unknown>) {
if (!selectedInvoice) return
setActionError(null)
setActionMessage(null)
try {
await api(`/admin/billing/invoices/${selectedInvoice.id}/${path}`, {
method: 'POST',
body: JSON.stringify(body ?? {}),
})
setActionMessage('Invoice action completed.')
await refreshSelectedAccount()
} catch (error: any) {
setActionError(error.message)
}
}
async function downloadPdf() {
if (!selectedInvoice) return
setActionError(null)
try {
const blob = await api<Blob>(`/admin/billing/invoices/${selectedInvoice.id}/pdf`)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${selectedInvoice.invoiceNumber ?? selectedInvoice.id}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch (error: any) {
setActionError(error.message)
}
}
return (
<div className="space-y-6">
<div className="rdg-page-heading flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="rdg-page-kicker">{copy.eyebrow}</p>
<h1 className="mt-2 text-3xl font-black text-zinc-50">{copy.title}</h1>
</div>
<div className="w-full max-w-md">
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={copy.searchPlaceholder}
className="w-full rounded-2xl border border-zinc-800 bg-zinc-950 px-4 py-3 text-sm text-zinc-100 outline-none transition focus:border-blue-700"
/>
</div>
</div>
{stats && (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<StatCard label={copy.accounts} value={String(stats.billingAccountCount)} tone="text-zinc-50" />
<StatCard label={copy.receivables} value={money(stats.accountsReceivableTotal, 'MAD')} tone="text-amber-300" />
<StatCard label={copy.openInvoices} value={String(stats.openInvoiceCount)} tone="text-amber-300" />
<StatCard label={copy.paidInvoices} value={String(stats.paidInvoiceCount)} tone="text-emerald-300" />
<StatCard label={copy.revenue} value={money(stats.recognizedRevenueTotal, 'MAD')} tone="text-sky-300" />
</div>
)}
<div className="flex flex-wrap gap-2">
{filters.map((filter) => (
<button
key={filter.key}
onClick={() => startTransition(() => setStatusFilter(filter.key))}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
statusFilter === filter.key
? 'bg-emerald-500 text-black'
: 'bg-zinc-900 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200'
}`}
>
{filter.label}
</button>
))}
</div>
<div className="grid gap-6 xl:grid-cols-[1.15fr,1.85fr]">
<section className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-5 py-4">
<h2 className="text-sm font-semibold text-zinc-100">{copy.billingAccounts}</h2>
</div>
<div className="max-h-[76vh] overflow-y-auto">
{loadingList ? (
<div className="px-5 py-10 text-sm text-zinc-500">{copy.loading}</div>
) : listError ? (
<div className="px-5 py-10 text-sm text-red-400">{listError}</div>
) : accounts.length === 0 ? (
<div className="px-5 py-10 text-sm text-zinc-500">{copy.noAccounts}</div>
) : (
<div className="divide-y divide-zinc-800">
{accounts.map((account) => {
const isSelected = selectedCompanyId === account.company.id
const subscription = account.company.subscription
return (
<button
key={account.id}
onClick={() => setSelectedCompanyId(account.company.id)}
className={`w-full px-5 py-4 text-left transition ${isSelected ? 'bg-emerald-500/8' : 'hover:bg-zinc-900/80'}`}
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-semibold text-zinc-100">{account.company.name}</p>
<p className="mt-1 text-xs text-zinc-500">{account.billingEmail}</p>
</div>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${ACCOUNT_STATUS_COLORS[subscription?.status ?? 'CANCELLED'] ?? 'text-zinc-300 bg-zinc-700/60'}`}>
{subscription?.status ?? 'NO_SUBSCRIPTION'}
</span>
</div>
<div className="mt-4 grid grid-cols-3 gap-3 text-xs">
<Metric label={copy.plan} value={subscription?.plan ?? '—'} />
<Metric label={copy.openBalance} value={money(account.openBalance, account.creditBalances[0]?.currency ?? 'MAD')} />
<Metric label={copy.credit} value={money(account.creditBalance, account.creditBalances[0]?.currency ?? 'MAD')} />
</div>
<div className="mt-3 flex items-center justify-between text-[11px] text-zinc-500">
<span>{termsLabel(account.invoiceTerms, account.netTermsDays)}</span>
<span>{account.dunningPaused ? `${copy.dunning}: paused` : `${copy.dunning}: live`}</span>
</div>
</button>
)
})}
</div>
)}
</div>
</section>
<section className="space-y-6">
{!selectedCompanyId ? (
<div className="panel px-6 py-10 text-sm text-zinc-500">{copy.selectAccount}</div>
) : loadingDetail && !detail ? (
<div className="panel px-6 py-10 text-sm text-zinc-500">{copy.loading}</div>
) : detailError ? (
<div className="panel px-6 py-10 text-sm text-red-400">{detailError}</div>
) : detail ? (
<>
<section className="panel p-5">
<div className="flex flex-col gap-4 border-b border-zinc-800 pb-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-zinc-500">{copy.accountDetails}</p>
<h2 className="mt-2 text-2xl font-bold text-zinc-50">{detail.company.name}</h2>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<span className={`rounded-full px-2 py-1 font-semibold ${ACCOUNT_STATUS_COLORS[detail.company.subscription?.status ?? 'CANCELLED'] ?? 'text-zinc-300 bg-zinc-700/60'}`}>
{detail.company.subscription?.status ?? 'NO_SUBSCRIPTION'}
</span>
<span className="rounded-full bg-zinc-900 px-2 py-1 text-zinc-300">
{detail.company.subscription?.plan ?? 'No plan'} / {detail.company.subscription?.billingPeriod ?? '—'}
</span>
<span className="rounded-full bg-zinc-900 px-2 py-1 text-zinc-300">
{termsLabel(detail.invoiceTerms, detail.netTermsDays)}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-right text-sm sm:grid-cols-4">
<Metric label={copy.openBalance} value={money(detail.openBalance, detail.creditBalances[0]?.currency ?? 'MAD')} />
<Metric label={copy.credit} value={money(detail.creditBalance, detail.creditBalances[0]?.currency ?? 'MAD')} />
<Metric label={copy.amountPaid} value={money(detail.paidBalance, detail.creditBalances[0]?.currency ?? 'MAD')} />
<Metric label={copy.dunning} value={detail.dunningPaused ? 'Paused' : 'Live'} />
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<Field label={copy.legalName}>
<input
value={accountForm.legalName}
onChange={(event) => setAccountForm((current) => ({ ...current, legalName: event.target.value }))}
className={FIELD_CLASS}
/>
</Field>
<Field label={copy.billingEmail}>
<input
value={accountForm.billingEmail}
onChange={(event) => setAccountForm((current) => ({ ...current, billingEmail: event.target.value }))}
className={FIELD_CLASS}
/>
</Field>
<Field label={copy.taxId}>
<input
value={accountForm.taxId}
onChange={(event) => setAccountForm((current) => ({ ...current, taxId: event.target.value }))}
className={FIELD_CLASS}
/>
</Field>
<Field label={copy.invoiceTerms}>
<select
value={accountForm.invoiceTerms}
onChange={(event) => setAccountForm((current) => ({
...current,
invoiceTerms: event.target.value,
netTermsDays: event.target.value === 'DUE_ON_RECEIPT' ? 0 : current.netTermsDays || Number(event.target.value.replace('NET_', '')),
}))}
className={FIELD_CLASS}
>
<option value="DUE_ON_RECEIPT">Due on receipt</option>
<option value="NET_7">Net 7</option>
<option value="NET_15">Net 15</option>
<option value="NET_30">Net 30</option>
<option value="NET_45">Net 45</option>
<option value="NET_60">Net 60</option>
</select>
</Field>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={accountForm.taxExempt}
onChange={(event) => setAccountForm((current) => ({ ...current, taxExempt: event.target.checked }))}
className="rounded border-zinc-700 bg-zinc-950 text-emerald-500"
/>
{copy.taxExempt}
</label>
<button onClick={saveBillingAccount} className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400">
{copy.saveAccount}
</button>
<button
onClick={() => toggleDunning(!detail.dunningPaused)}
className="rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 hover:border-zinc-500"
>
{detail.dunningPaused ? copy.resumeDunning : copy.pauseDunning}
</button>
</div>
</section>
<section className="panel p-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-zinc-100">{copy.invoiceFeed}</h3>
{isPending || loadingDetail ? <span className="text-xs text-zinc-500">{copy.loading}</span> : null}
</div>
{detail.invoices.length === 0 ? (
<div className="mt-4 text-sm text-zinc-500">{copy.noInvoices}</div>
) : (
<div className="mt-4 grid gap-4 xl:grid-cols-[1.2fr,0.8fr]">
<div className="overflow-hidden rounded-2xl border border-zinc-800">
<table className="w-full text-sm">
<thead className="bg-zinc-950/80">
<tr className="text-left text-xs uppercase tracking-[0.16em] text-zinc-500">
<th className="px-4 py-3">Invoice</th>
<th className="px-4 py-3">{copy.amountDue}</th>
<th className="px-4 py-3">{copy.dueDate}</th>
<th className="px-4 py-3">{copy.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{detail.invoices.map((invoice) => (
<tr
key={invoice.id}
className={`cursor-pointer transition hover:bg-zinc-900/80 ${selectedInvoice?.id === invoice.id ? 'bg-emerald-500/8' : ''}`}
onClick={() => setSelectedInvoiceId(invoice.id)}
>
<td className="px-4 py-3">
<div>
<p className="font-medium text-zinc-100">{invoice.invoiceNumber ?? invoice.invoiceType}</p>
<p className="mt-1 text-xs text-zinc-500">{dateLabel(invoice.invoiceDate ?? invoice.createdAt)}</p>
</div>
</td>
<td className="px-4 py-3 text-zinc-200">{money(invoice.amountDue, invoice.currency)}</td>
<td className="px-4 py-3 text-zinc-400">{dateLabel(invoice.dueAt)}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${INVOICE_STATUS_COLORS[invoice.status] ?? 'text-zinc-300 bg-zinc-700/60'}`}>
{invoice.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{selectedInvoice && (
<div className="space-y-4 rounded-2xl border border-zinc-800 bg-zinc-950/80 p-4">
<div>
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">{copy.selectedInvoice}</p>
<div className="mt-2 flex items-center justify-between gap-3">
<div>
<h4 className="text-lg font-semibold text-zinc-50">{selectedInvoice.invoiceNumber ?? selectedInvoice.invoiceType}</h4>
<p className="mt-1 text-sm text-zinc-400">
{copy.issueDate}: {dateLabel(selectedInvoice.invoiceDate ?? selectedInvoice.createdAt)}
</p>
</div>
<span className={`rounded-full px-2 py-1 text-xs font-semibold ${INVOICE_STATUS_COLORS[selectedInvoice.status] ?? 'text-zinc-300 bg-zinc-700/60'}`}>
{selectedInvoice.status}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<Metric label={copy.amountDue} value={money(selectedInvoice.amountDue, selectedInvoice.currency)} />
<Metric label={copy.amountPaid} value={money(selectedInvoice.amountPaid, selectedInvoice.currency)} />
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.16em] text-zinc-500">{copy.lineItems}</p>
<div className="space-y-2">
{selectedInvoice.lineItems.map((item, index) => (
<div key={`${selectedInvoice.id}-${index}`} className="rounded-xl border border-zinc-800 px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm text-zinc-100">{item.description}</p>
<p className="text-xs text-zinc-500">
{item.quantity} × {money(item.unitAmount, item.currency)}
</p>
</div>
<p className="text-sm font-medium text-zinc-200">{money(item.amount, item.currency)}</p>
</div>
</div>
))}
</div>
</div>
<div className="grid gap-2">
{selectedInvoice.status === 'DRAFT' ? (
<button onClick={() => runInvoiceAction('finalize')} className={ACTION_PRIMARY_CLASS}>{copy.finalize}</button>
) : null}
{['OPEN', 'PAYMENT_PENDING', 'PAST_DUE', 'PARTIALLY_PAID'].includes(selectedInvoice.status) ? (
<>
<div className="grid grid-cols-[1fr,auto] gap-2">
<input
value={payAmount}
onChange={(event) => setPayAmount(event.target.value)}
placeholder="Amount in cents"
className={FIELD_CLASS}
/>
<button
onClick={() => runInvoiceAction('pay', payAmount ? { amount: Number(payAmount) } : {})}
className={ACTION_PRIMARY_CLASS}
>
{copy.pay}
</button>
</div>
<button onClick={() => runInvoiceAction('retry-payment')} className={ACTION_SECONDARY_CLASS}>{copy.retry}</button>
<div className="grid gap-2 sm:grid-cols-[1fr,auto]">
<input value={voidReason} onChange={(event) => setVoidReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} />
<button onClick={() => runInvoiceAction('void', { reason: voidReason })} className={ACTION_SECONDARY_CLASS}>{copy.void}</button>
</div>
<div className="grid gap-2 sm:grid-cols-[1fr,auto]">
<input value={writeoffReason} onChange={(event) => setWriteoffReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} />
<button onClick={() => runInvoiceAction('uncollectible', { reason: writeoffReason })} className={ACTION_SECONDARY_CLASS}>{copy.writeoff}</button>
</div>
<div className="grid gap-2 sm:grid-cols-[120px,1fr,auto]">
<input value={creditAmount} onChange={(event) => setCreditAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} />
<input value={creditReason} onChange={(event) => setCreditReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} />
<button onClick={() => runInvoiceAction('credit-notes', { amount: Number(creditAmount), reason: creditReason })} className={ACTION_SECONDARY_CLASS}>{copy.creditNote}</button>
</div>
</>
) : null}
{['PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'].includes(selectedInvoice.status) ? (
<div className="grid gap-2 sm:grid-cols-[120px,1fr,auto]">
<input value={refundAmount} onChange={(event) => setRefundAmount(event.target.value)} placeholder="Cents" className={FIELD_CLASS} />
<input value={refundReason} onChange={(event) => setRefundReason(event.target.value)} placeholder={copy.reason} className={FIELD_CLASS} />
<button onClick={() => runInvoiceAction('refunds', { amount: Number(refundAmount), reason: refundReason })} className={ACTION_SECONDARY_CLASS}>{copy.refund}</button>
</div>
) : null}
{selectedInvoice.invoiceNumber ? (
<button onClick={downloadPdf} className={ACTION_SECONDARY_CLASS}>{copy.downloadPdf}</button>
) : null}
</div>
<DetailList title={copy.payments} items={selectedInvoice.paymentAttempts.map((attempt) => ({
id: attempt.id,
primary: `${attempt.status} · ${money(attempt.amount, attempt.currency)}`,
secondary: `${dateLabel(attempt.attemptedAt)}${attempt.failureMessage ? ` · ${attempt.failureMessage}` : ''}`,
}))} />
<DetailList title={copy.credits} items={selectedInvoice.creditNotes.map((note) => ({
id: note.id,
primary: `${money(note.amount, note.currency)} · ${note.status}`,
secondary: note.reason,
}))} />
<DetailList title={copy.refunds} items={selectedInvoice.refunds.map((refund) => ({
id: refund.id,
primary: `${money(refund.amount, refund.currency)} · ${refund.status}`,
secondary: refund.reason,
}))} />
</div>
)}
</div>
)}
</section>
<section className="panel p-5">
<h3 className="text-sm font-semibold text-zinc-100">{copy.draftInvoice}</h3>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr,1fr,auto]">
<Field label={copy.invoiceType}>
<select
value={invoiceDraft.invoiceType}
onChange={(event) => setInvoiceDraft((current) => ({ ...current, invoiceType: event.target.value }))}
className={FIELD_CLASS}
>
<option value="MANUAL">Manual</option>
<option value="ONE_TIME">One time</option>
<option value="SUBSCRIPTION_RENEWAL">Subscription renewal</option>
<option value="SUBSCRIPTION_INITIAL">Subscription initial</option>
<option value="TRIAL_CONVERSION">Trial conversion</option>
<option value="CORRECTION">Correction</option>
</select>
</Field>
<Field label={copy.reason}>
<input
value={invoiceDraft.adminReason}
onChange={(event) => setInvoiceDraft((current) => ({ ...current, adminReason: event.target.value }))}
className={FIELD_CLASS}
/>
</Field>
<label className="flex items-end gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={invoiceDraft.isSubscriptionBlocking}
onChange={(event) => setInvoiceDraft((current) => ({ ...current, isSubscriptionBlocking: event.target.checked }))}
className="mb-3 rounded border-zinc-700 bg-zinc-950 text-emerald-500"
/>
<span className="mb-2">{copy.blocking}</span>
</label>
</div>
<div className="mt-4 space-y-3">
{invoiceDraft.lineItems.map((item, index) => (
<div key={index} className="grid gap-3 rounded-2xl border border-zinc-800 bg-zinc-950/60 p-4 lg:grid-cols-[180px,1fr,100px,140px,auto]">
<select value={item.type} onChange={(event) => setDraftLine(index, 'type', event.target.value)} className={FIELD_CLASS}>
<option value="PROFESSIONAL_SERVICES">Professional services</option>
<option value="SETUP_FEE">Setup fee</option>
<option value="SUBSCRIPTION_FEE">Subscription fee</option>
<option value="MANUAL_ADJUSTMENT">Manual adjustment</option>
<option value="DISCOUNT">Discount</option>
</select>
<input value={item.description} onChange={(event) => setDraftLine(index, 'description', event.target.value)} placeholder="Description" className={FIELD_CLASS} />
<input type="number" min={1} value={item.quantity} onChange={(event) => setDraftLine(index, 'quantity', Number(event.target.value))} className={FIELD_CLASS} />
<input type="number" value={item.unitAmount} onChange={(event) => setDraftLine(index, 'unitAmount', Number(event.target.value))} placeholder="Unit amount (cents)" className={FIELD_CLASS} />
<button
onClick={() => setInvoiceDraft((current) => ({
...current,
lineItems: current.lineItems.length === 1 ? current.lineItems : current.lineItems.filter((_, lineIndex) => lineIndex !== index),
}))}
className="rounded-xl border border-zinc-700 px-3 py-2 text-sm text-zinc-300 hover:border-zinc-500"
>
Remove
</button>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap gap-3">
<button
onClick={() => setInvoiceDraft((current) => ({ ...current, lineItems: [...current.lineItems, draftLineItem()] }))}
className="rounded-xl border border-zinc-700 px-4 py-2 text-sm font-semibold text-zinc-200 hover:border-zinc-500"
>
Add line
</button>
<button onClick={createDraftInvoice} className={ACTION_PRIMARY_CLASS}>{copy.createDraft}</button>
</div>
</section>
<section className="panel p-5">
<div className="grid gap-5 xl:grid-cols-2">
<DetailList title={copy.timeline} items={detail.events.map((event) => ({
id: event.id,
primary: event.eventType,
secondary: `${event.source} · ${dateLabel(event.createdAt)}`,
}))} />
<DetailList title={copy.credits} items={detail.creditLedgerEntries.map((entry) => ({
id: entry.id,
primary: `${money(entry.amount, entry.currency)} · ${entry.type}`,
secondary: entry.reason,
}))} />
</div>
</section>
</>
) : null}
</section>
</div>
{actionError ? <div className="panel p-4 text-sm text-red-400">{actionError}</div> : null}
{actionMessage ? <div className="panel p-4 text-sm text-emerald-300">{actionMessage}</div> : null}
</div>
)
}
function StatCard({ label, value, tone }: { label: string; value: string; tone: string }) {
return (
<div className="panel p-4">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">{label}</p>
<p className={`mt-2 text-2xl font-black ${tone}`}>{value}</p>
</div>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">{label}</p>
<p className="mt-1 text-sm font-semibold text-zinc-100">{value}</p>
</div>
)
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block">
<span className="mb-2 block text-xs uppercase tracking-[0.16em] text-zinc-500">{label}</span>
{children}
</label>
)
}
function DetailList({
title,
items,
}: {
title: string
items: Array<{ id: string; primary: string; secondary: string }>
}) {
return (
<div>
<p className="mb-2 text-xs uppercase tracking-[0.16em] text-zinc-500">{title}</p>
{items.length === 0 ? (
<div className="rounded-xl border border-zinc-800 px-3 py-3 text-sm text-zinc-500"></div>
) : (
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="rounded-xl border border-zinc-800 px-3 py-3">
<p className="text-sm text-zinc-100">{item.primary}</p>
<p className="mt-1 text-xs text-zinc-500">{item.secondary}</p>
</div>
))}
</div>
)}
</div>
)
}