update subscription algorithm
This commit is contained in:
@@ -15,6 +15,7 @@ interface CompanyDetail {
|
||||
subscriptionPaymentRef: string | null
|
||||
createdAt: string
|
||||
subscription: {
|
||||
id: string
|
||||
plan: string
|
||||
billingPeriod: string
|
||||
status: string
|
||||
@@ -25,6 +26,10 @@ interface CompanyDetail {
|
||||
currentPeriodEnd: string | null
|
||||
cancelledAt: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
paymentPendingSince: string | null
|
||||
pastDueSince: string | null
|
||||
suspendedAt: string | null
|
||||
retryCount: number
|
||||
} | null
|
||||
brand: {
|
||||
displayName: string
|
||||
@@ -78,12 +83,21 @@ interface AuditLog {
|
||||
adminUser: { email: string } | null
|
||||
}
|
||||
|
||||
interface SubEvent {
|
||||
id: string
|
||||
eventType: string
|
||||
source: string
|
||||
payload: Record<string, any>
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
company: {
|
||||
name: string
|
||||
slug: string
|
||||
email: string
|
||||
phone: string
|
||||
status: string
|
||||
subscriptionPaymentRef: string
|
||||
}
|
||||
subscription: {
|
||||
@@ -168,6 +182,7 @@ function createFormState(company: CompanyDetail): FormState {
|
||||
slug: company.slug ?? '',
|
||||
email: company.email ?? '',
|
||||
phone: company.phone ?? '',
|
||||
status: company.status ?? 'TRIALING',
|
||||
subscriptionPaymentRef: company.subscriptionPaymentRef ?? '',
|
||||
},
|
||||
subscription: {
|
||||
@@ -227,6 +242,10 @@ export default function AdminCompanyDetailPage() {
|
||||
const [company, setCompany] = useState<CompanyDetail | null>(null)
|
||||
const [form, setForm] = useState<FormState | null>(null)
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
|
||||
const [subEvents, setSubEvents] = useState<SubEvent[]>([])
|
||||
const [subActioning, setSubActioning] = useState<string | null>(null)
|
||||
const [subOverrideReason, setSubOverrideReason] = useState('')
|
||||
const [showSubActions, setShowSubActions] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState(false)
|
||||
@@ -246,7 +265,14 @@ export default function AdminCompanyDetailPage() {
|
||||
if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found')
|
||||
setCompany(cJson.data)
|
||||
setForm(createFormState(cJson.data))
|
||||
setAuditLogs(aJson.data ?? [])
|
||||
setAuditLogs(aJson.data?.data ?? [])
|
||||
|
||||
const subId = cJson.data?.subscription?.id
|
||||
if (subId) {
|
||||
const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' })
|
||||
const eJson = await eRes.json()
|
||||
setSubEvents(Array.isArray(eJson.data) ? eJson.data : [])
|
||||
}
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
@@ -341,6 +367,29 @@ export default function AdminCompanyDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function doSubAction(action: string) {
|
||||
if (!company?.subscription?.id) return
|
||||
if (!subOverrideReason.trim()) { setError('A reason is required for subscription overrides'); return }
|
||||
setSubActioning(action)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ reason: subOverrideReason }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Action failed')
|
||||
setSubOverrideReason('')
|
||||
setShowSubActions(false)
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSubActioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStatus(status: string) {
|
||||
setActioning(true)
|
||||
setError(null)
|
||||
@@ -445,7 +494,11 @@ export default function AdminCompanyDetailPage() {
|
||||
<span className={LABEL_CLASS}>Phone</span>
|
||||
<input className={INPUT_CLASS} value={form.company.phone} onChange={(e) => updateSection('company', { phone: e.target.value })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>Company status</span>
|
||||
<p className={`mt-1 text-sm font-semibold ${STATUS_COLORS[form.company.status] ?? 'text-zinc-400'}`}>{form.company.status}</p>
|
||||
</div>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Subscription payment ref</span>
|
||||
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
|
||||
</label>
|
||||
@@ -470,16 +523,19 @@ export default function AdminCompanyDetailPage() {
|
||||
<option value="ANNUAL">ANNUAL</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>Status</span>
|
||||
<select className={INPUT_CLASS} value={form.subscription.status} onChange={(e) => updateSection('subscription', { status: e.target.value })}>
|
||||
<option value="TRIALING">TRIALING</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="PAST_DUE">PAST_DUE</option>
|
||||
<option value="CANCELLED">CANCELLED</option>
|
||||
<option value="UNPAID">UNPAID</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className={`mt-1 text-sm font-semibold ${STATUS_COLORS[form.subscription.status] ?? 'text-zinc-400'}`}>{form.subscription.status}</p>
|
||||
{company.subscription?.paymentPendingSince && (
|
||||
<p className="mt-0.5 text-xs text-zinc-500">Pending since {new Date(company.subscription.paymentPendingSince).toLocaleDateString()}</p>
|
||||
)}
|
||||
{company.subscription?.pastDueSince && (
|
||||
<p className="mt-0.5 text-xs text-orange-500">Past due since {new Date(company.subscription.pastDueSince).toLocaleDateString()}</p>
|
||||
)}
|
||||
{company.subscription?.suspendedAt && (
|
||||
<p className="mt-0.5 text-xs text-red-500">Suspended {new Date(company.subscription.suspendedAt).toLocaleDateString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Currency</span>
|
||||
<select className={INPUT_CLASS} value={form.subscription.currency} onChange={(e) => updateSection('subscription', { currency: e.target.value })}>
|
||||
@@ -680,53 +736,115 @@ export default function AdminCompanyDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="panel p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold">Admin actions</h2>
|
||||
<div className="space-y-3">
|
||||
{company.status === 'SUSPENDED' ? (
|
||||
<button onClick={() => changeStatus('ACTIVE')} disabled={actioning} className="w-full rounded-xl bg-emerald-900/40 py-2.5 text-sm font-semibold text-emerald-400 transition-colors hover:bg-emerald-900/60 disabled:opacity-50">
|
||||
Reactivate company
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => changeStatus('SUSPENDED')} disabled={actioning} className="w-full rounded-xl bg-red-950/40 py-2.5 text-sm font-semibold text-red-400 transition-colors hover:bg-red-950/60 disabled:opacity-50">
|
||||
Suspend company
|
||||
</button>
|
||||
)}
|
||||
{!deleteConfirm ? (
|
||||
<button onClick={() => setDeleteConfirm(true)} className="w-full rounded-xl bg-zinc-800 py-2.5 text-sm font-semibold text-zinc-400 transition-colors hover:bg-zinc-700">
|
||||
Delete company
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-xl border border-red-900/50 p-4">
|
||||
<p className="text-sm font-medium text-red-400">This will permanently delete all company data. Are you sure?</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setDeleteConfirm(false)} className="flex-1 rounded-lg bg-zinc-800 py-2 text-sm text-zinc-300">Cancel</button>
|
||||
<button onClick={deleteCompany} disabled={actioning} className="flex-1 rounded-lg bg-red-700 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:opacity-50">Delete</button>
|
||||
<div className="space-y-4">
|
||||
<div className="panel p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold">Company actions</h2>
|
||||
<div className="space-y-3">
|
||||
{company.status === 'SUSPENDED' ? (
|
||||
<button onClick={() => changeStatus('ACTIVE')} disabled={actioning} className="w-full rounded-xl bg-emerald-900/40 py-2.5 text-sm font-semibold text-emerald-400 transition-colors hover:bg-emerald-900/60 disabled:opacity-50">
|
||||
Reactivate company
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => changeStatus('SUSPENDED')} disabled={actioning} className="w-full rounded-xl bg-red-950/40 py-2.5 text-sm font-semibold text-red-400 transition-colors hover:bg-red-950/60 disabled:opacity-50">
|
||||
Suspend company
|
||||
</button>
|
||||
)}
|
||||
{!deleteConfirm ? (
|
||||
<button onClick={() => setDeleteConfirm(true)} className="w-full rounded-xl bg-zinc-800 py-2.5 text-sm font-semibold text-zinc-400 transition-colors hover:bg-zinc-700">
|
||||
Delete company
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-xl border border-red-900/50 p-4">
|
||||
<p className="text-sm font-medium text-red-400">This will permanently delete all company data. Are you sure?</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setDeleteConfirm(false)} className="flex-1 rounded-lg bg-zinc-800 py-2 text-sm text-zinc-300">Cancel</button>
|
||||
<button onClick={deleteCompany} disabled={actioning} className="flex-1 rounded-lg bg-red-700 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:opacity-50">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{company.subscription && (
|
||||
<div className="panel p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Subscription overrides</h2>
|
||||
<button onClick={() => setShowSubActions((v) => !v)} className="text-xs text-zinc-400 hover:text-zinc-200">
|
||||
{showSubActions ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
{showSubActions && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-zinc-500">Reason (required)</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500"
|
||||
placeholder="e.g. Customer requested extension…"
|
||||
value={subOverrideReason}
|
||||
onChange={(e) => setSubOverrideReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={() => doSubAction('reactivate')} disabled={!!subActioning} className="rounded-lg bg-emerald-950/50 py-2 text-xs font-semibold text-emerald-400 hover:bg-emerald-900/50 disabled:opacity-50">
|
||||
{subActioning === 'reactivate' ? '…' : 'Reactivate'}
|
||||
</button>
|
||||
<button onClick={() => doSubAction('extend-grace-period')} disabled={!!subActioning} className="rounded-lg bg-sky-950/50 py-2 text-xs font-semibold text-sky-400 hover:bg-sky-900/50 disabled:opacity-50">
|
||||
{subActioning === 'extend-grace-period' ? '…' : '+7d grace'}
|
||||
</button>
|
||||
<button onClick={() => doSubAction('suspend')} disabled={!!subActioning} className="rounded-lg bg-orange-950/50 py-2 text-xs font-semibold text-orange-400 hover:bg-orange-900/50 disabled:opacity-50">
|
||||
{subActioning === 'suspend' ? '…' : 'Suspend'}
|
||||
</button>
|
||||
<button onClick={() => doSubAction('cancel')} disabled={!!subActioning} className="rounded-lg bg-red-950/50 py-2 text-xs font-semibold text-red-400 hover:bg-red-900/50 disabled:opacity-50">
|
||||
{subActioning === 'cancel' ? '…' : 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-600">Retry count: {company.subscription.retryCount ?? 0} / 5</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{auditLogs.length > 0 && (
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="border-b border-zinc-800 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold">Recent audit events</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{auditLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-center justify-between px-6 py-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-zinc-200">{log.action}</span>
|
||||
<span className="ml-2 text-zinc-500">{log.resource}</span>
|
||||
{log.adminUser?.email ? <span className="ml-2 text-zinc-600">{log.adminUser.email}</span> : null}
|
||||
<div className="space-y-4">
|
||||
{subEvents.length > 0 && (
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="border-b border-zinc-800 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold">Subscription timeline</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60 max-h-72 overflow-y-auto">
|
||||
{subEvents.map((ev) => (
|
||||
<div key={ev.id} className="px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-sky-400">{ev.eventType}</span>
|
||||
<span className="text-xs text-zinc-600">{new Date(ev.occurredAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">source: {ev.source}</p>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-600">{new Date(log.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{auditLogs.length > 0 && (
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="border-b border-zinc-800 px-6 py-4">
|
||||
<h2 className="text-sm font-semibold">Recent audit events</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{auditLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-center justify-between px-6 py-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-zinc-200">{log.action}</span>
|
||||
<span className="ml-2 text-zinc-500">{log.resource}</span>
|
||||
{log.adminUser?.email ? <span className="ml-2 text-zinc-600">{log.adminUser.email}</span> : null}
|
||||
</div>
|
||||
<span className="text-xs text-zinc-600">{new Date(log.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -88,8 +88,9 @@ export default function AdminCompaniesPage() {
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
setCompanies(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||
setCompanies(list)
|
||||
setFiltered(list)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user