@kernhq/module-billing 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import type { AdminWorkspaceRow, BillingApi, Invoice, Plan, Subscription, WorkspaceBilling } from './index.js'
2
+
3
+ /**
4
+ * Billing with no backend, for `pnpm dev:mock` and the end-to-end tests.
5
+ *
6
+ * It satisfies the same contract types as the real client, so no view has a second code path for
7
+ * demos. What it deliberately does *not* do is take a payment: `checkout` and `portal` throw, because
8
+ * the one thing a demo must never be able to imply is that money moved.
9
+ */
10
+
11
+ const GB = 1024 ** 3
12
+ const iso = (daysFromNow: number) => new Date(Date.now() + daysFromNow * 86_400_000).toISOString()
13
+
14
+ let plans: Plan[] = [
15
+ {
16
+ id: 'plan-team',
17
+ slug: 'team',
18
+ name: 'Team',
19
+ description: 'The same Kern, run by us.',
20
+ priceMinor: 800,
21
+ currency: 'usd',
22
+ interval: 'month',
23
+ perSeat: true,
24
+ trialDays: 14,
25
+ limits: {
26
+ seats: 25,
27
+ storageBytes: 50 * GB,
28
+ modules: null,
29
+ sso: false,
30
+ auditRetentionDays: 90,
31
+ apiRateLimit: null,
32
+ },
33
+ stripePriceId: 'price_demo_team',
34
+ highlights: ['Everything in self-hosted', 'Daily backups, kept 30 days'],
35
+ published: true,
36
+ order: 10,
37
+ createdAt: iso(-40),
38
+ updatedAt: iso(-2),
39
+ },
40
+ {
41
+ id: 'plan-business',
42
+ slug: 'business',
43
+ name: 'Business',
44
+ description: 'For companies with a compliance team.',
45
+ priceMinor: 1600,
46
+ currency: 'usd',
47
+ interval: 'month',
48
+ perSeat: true,
49
+ trialDays: 14,
50
+ limits: {
51
+ seats: null,
52
+ storageBytes: 250 * GB,
53
+ modules: null,
54
+ sso: true,
55
+ auditRetentionDays: 730,
56
+ apiRateLimit: null,
57
+ },
58
+ stripePriceId: 'price_demo_business',
59
+ highlights: ['Everything in Team', 'SSO over OIDC and SAML'],
60
+ published: true,
61
+ order: 20,
62
+ createdAt: iso(-40),
63
+ updatedAt: iso(-2),
64
+ },
65
+ ]
66
+
67
+ const subscription: Subscription = {
68
+ workspaceId: 'ws-demo' as Subscription['workspaceId'],
69
+ planId: 'plan-team',
70
+ planName: 'Team',
71
+ planSlug: 'team',
72
+ status: 'trialing',
73
+ seatsPurchased: 12,
74
+ trialEndsAt: iso(9),
75
+ currentPeriodEnd: iso(21),
76
+ cancelAtPeriodEnd: false,
77
+ stripeCustomerId: 'cus_demo',
78
+ stripeSubscriptionId: 'sub_demo',
79
+ }
80
+
81
+ const invoices: Invoice[] = [
82
+ {
83
+ id: 'inv-2',
84
+ workspaceId: subscription.workspaceId,
85
+ number: 'KERN-0002',
86
+ status: 'paid',
87
+ totalMinor: 9600,
88
+ currency: 'usd',
89
+ periodStart: iso(-30),
90
+ periodEnd: iso(0),
91
+ hostedUrl: null,
92
+ pdfUrl: null,
93
+ createdAt: iso(-30),
94
+ },
95
+ {
96
+ id: 'inv-1',
97
+ workspaceId: subscription.workspaceId,
98
+ number: 'KERN-0001',
99
+ status: 'paid',
100
+ totalMinor: 8800,
101
+ currency: 'usd',
102
+ periodStart: iso(-60),
103
+ periodEnd: iso(-30),
104
+ hostedUrl: null,
105
+ pdfUrl: null,
106
+ createdAt: iso(-60),
107
+ },
108
+ ]
109
+
110
+ const adminRows: AdminWorkspaceRow[] = [
111
+ {
112
+ workspaceId: 'ws-demo' as AdminWorkspaceRow['workspaceId'],
113
+ workspaceName: 'Acme',
114
+ workspaceSlug: 'acme',
115
+ planName: 'Team',
116
+ planSlug: 'team',
117
+ status: 'trialing',
118
+ seatsUsed: 12,
119
+ seatsPurchased: 12,
120
+ storageBytes: 18 * GB,
121
+ trialEndsAt: iso(9),
122
+ currentPeriodEnd: iso(21),
123
+ monthlyMinor: 9600,
124
+ currency: 'usd',
125
+ overridden: false,
126
+ stripeCustomerId: 'cus_demo',
127
+ },
128
+ {
129
+ workspaceId: 'ws-two' as AdminWorkspaceRow['workspaceId'],
130
+ workspaceName: 'Northwind',
131
+ workspaceSlug: 'northwind',
132
+ planName: 'Business',
133
+ planSlug: 'business',
134
+ status: 'active',
135
+ seatsUsed: 48,
136
+ seatsPurchased: 50,
137
+ storageBytes: 190 * GB,
138
+ trialEndsAt: null,
139
+ currentPeriodEnd: iso(12),
140
+ monthlyMinor: 80000,
141
+ currency: 'usd',
142
+ overridden: true,
143
+ stripeCustomerId: 'cus_demo2',
144
+ },
145
+ {
146
+ workspaceId: 'ws-three' as AdminWorkspaceRow['workspaceId'],
147
+ workspaceName: 'Tiny Studio',
148
+ workspaceSlug: 'tiny',
149
+ planName: null,
150
+ planSlug: null,
151
+ status: 'past_due',
152
+ seatsUsed: 3,
153
+ seatsPurchased: 3,
154
+ storageBytes: 2 * GB,
155
+ trialEndsAt: null,
156
+ currentPeriodEnd: iso(-4),
157
+ monthlyMinor: 2400,
158
+ currency: 'usd',
159
+ overridden: false,
160
+ stripeCustomerId: null,
161
+ },
162
+ ]
163
+
164
+ const notInDemo = (what: string) => {
165
+ throw new Error(`${what} is not available without a backend`)
166
+ }
167
+
168
+ export function createMockBillingApi(): BillingApi {
169
+ return {
170
+ plans: {
171
+ list: async ({ includeUnpublished }: { includeUnpublished?: boolean }) =>
172
+ includeUnpublished ? plans : plans.filter((p) => p.published),
173
+ public: async () => plans.filter((p) => p.published),
174
+ upsert: async (input: Record<string, unknown>) => {
175
+ const next = {
176
+ ...(input as Plan),
177
+ id: (input.id as string) ?? `plan-${plans.length + 1}`,
178
+ createdAt: iso(0),
179
+ updatedAt: iso(0),
180
+ }
181
+ plans = plans.some((p) => p.id === next.id)
182
+ ? plans.map((p) => (p.id === next.id ? next : p))
183
+ : [...plans, next]
184
+ return next
185
+ },
186
+ setPublished: async ({ id, published }: { id: string; published: boolean }) => {
187
+ plans = plans.map((p) => (p.id === id ? { ...p, published } : p))
188
+ return plans.find((p) => p.id === id)!
189
+ },
190
+ archive: async ({ id }: { id: string }) => {
191
+ plans = plans.filter((p) => p.id !== id)
192
+ return { ok: true as const }
193
+ },
194
+ },
195
+ subscription: {
196
+ get: async (): Promise<WorkspaceBilling> => ({
197
+ subscription,
198
+ usage: { seats: 12, storageBytes: 18 * GB, updatedAt: iso(0) },
199
+ limits: plans[0]!.limits,
200
+ active: true,
201
+ // true, so the demo shows the plan picker rather than the "not set up" note; the checkout
202
+ // itself still refuses
203
+ paymentsEnabled: true,
204
+ }),
205
+ invoices: async () => ({ items: invoices, nextCursor: null }),
206
+ checkout: async () => notInDemo('Checkout'),
207
+ portal: async () => notInDemo('The billing portal'),
208
+ },
209
+ admin: {
210
+ workspaces: async ({ q }: { q?: string }) => ({
211
+ items: q
212
+ ? adminRows.filter((r) => r.workspaceName.toLowerCase().includes(q.toLowerCase()))
213
+ : adminRows,
214
+ nextCursor: null,
215
+ }),
216
+ setPlan: async () => subscription,
217
+ override: async () => subscription,
218
+ extendTrial: async () => subscription,
219
+ setStatus: async () => subscription,
220
+ },
221
+ } as unknown as BillingApi
222
+ }
@@ -0,0 +1,60 @@
1
+ import { defineClientModule } from '@kernhq/ui'
2
+ import { BILLING_PERMISSIONS } from '../contract.js'
3
+ import { billingMessageBundles, t } from './i18n.js'
4
+
5
+ /**
6
+ * Billing as the shell sees it.
7
+ *
8
+ * No navigation: what a workspace pays is not somewhere people go, it is somewhere an owner goes
9
+ * once a month. One workspace settings page for the customer, and two instance pages for whoever
10
+ * runs the instance — which on a self-hosted Kern is the same person, and on Kern Cloud is us.
11
+ *
12
+ * The instance pages carry no `capability` and are not filtered on whether this workspace has
13
+ * billing enabled: the console is not about a workspace, and an operator looking at what every
14
+ * workspace is billed must still see the screen from a workspace that has it switched off.
15
+ *
16
+ * Labels are getters because a module is defined once at import time while the interface language
17
+ * can change afterwards; reading them on render keeps them in the language actually chosen.
18
+ */
19
+ export const billingClientModule = defineClientModule({
20
+ id: 'billing',
21
+ name: 'Billing',
22
+ icon: 'credit-card',
23
+ messages: billingMessageBundles,
24
+
25
+ settingsPages: [
26
+ {
27
+ id: 'plan',
28
+ get label() {
29
+ return t('settings_nav')
30
+ },
31
+ icon: 'credit-card',
32
+ scope: 'workspace',
33
+ permission: BILLING_PERMISSIONS.view,
34
+ order: 60,
35
+ component: () => import('./settings/PlanSettings.svelte'),
36
+ },
37
+ {
38
+ id: 'subscriptions',
39
+ get label() {
40
+ return t('admin_subscriptions_nav')
41
+ },
42
+ icon: 'credit-card',
43
+ scope: 'instance',
44
+ order: 20,
45
+ component: () => import('./admin/SubscriptionsAdmin.svelte'),
46
+ },
47
+ {
48
+ id: 'plans',
49
+ get label() {
50
+ return t('admin_plans_nav')
51
+ },
52
+ icon: 'tag',
53
+ scope: 'instance',
54
+ order: 30,
55
+ component: () => import('./admin/PlansAdmin.svelte'),
56
+ },
57
+ ],
58
+ })
59
+
60
+ export default billingClientModule
@@ -0,0 +1,356 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Card,
6
+ EmptyState,
7
+ messageLocale,
8
+ ProgressBar,
9
+ Skeleton,
10
+ session,
11
+ Table,
12
+ TableCell,
13
+ TableHeader,
14
+ TableRow,
15
+ Tooltip,
16
+ toast,
17
+ } from '@kernhq/ui'
18
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
19
+ import { getBillingApi } from '../api-instance.js'
20
+ import { t } from '../i18n.js'
21
+ import type { Plan } from '../index.js'
22
+ import {
23
+ BILLING_PERMISSIONS,
24
+ formatBytes,
25
+ formatMoney,
26
+ planBlockedReason,
27
+ trialDaysLeft,
28
+ usageRatio,
29
+ } from '../index.js'
30
+
31
+ /**
32
+ * What this workspace is on, what it uses, and what it has been charged.
33
+ *
34
+ * Everything money-shaped is read from the server rather than worked out here: a second opinion
35
+ * about somebody's bill, computed in a browser, is the one kind of disagreement this screen must not
36
+ * be able to have.
37
+ */
38
+
39
+ const api = getBillingApi()
40
+ const queryClient = useQueryClient()
41
+
42
+ /**
43
+ * The shell passes these; a module page does not read the router.
44
+ *
45
+ * `$app/state` is a SvelteKit alias, and a package is type-checked on its own — reaching for the
46
+ * router here fails standalone even though it resolves inside the app. `ModuleRoute` already hands
47
+ * every module page the workspace it is rendering.
48
+ */
49
+ interface Props {
50
+ workspaceId: string
51
+ workspaceSlug: string
52
+ }
53
+ const { workspaceId, workspaceSlug: slug }: Props = $props()
54
+ const canManage = $derived(session.can(BILLING_PERMISSIONS.manage))
55
+ const locale = $derived(messageLocale())
56
+
57
+ const billing = createQuery(() => ({
58
+ queryKey: ['billing', 'subscription', workspaceId],
59
+ queryFn: () => api.subscription.get({ workspaceId }),
60
+ enabled: Boolean(workspaceId),
61
+ }))
62
+
63
+ const plans = createQuery(() => ({
64
+ queryKey: ['billing', 'plan', 'offered'],
65
+ queryFn: () => api.plans.list({ includeUnpublished: false }),
66
+ enabled: Boolean(workspaceId),
67
+ }))
68
+
69
+ const invoices = createQuery(() => ({
70
+ queryKey: ['billing', 'invoice', workspaceId],
71
+ queryFn: () => api.subscription.invoices({ workspaceId, limit: 24 }),
72
+ enabled: Boolean(workspaceId),
73
+ }))
74
+
75
+ const data = $derived(billing.data)
76
+ const sub = $derived(data?.subscription ?? null)
77
+ const usage = $derived(data?.usage ?? { seats: 0, storageBytes: 0, updatedAt: '' })
78
+
79
+ const STATUS_LABEL: Record<string, () => string> = {
80
+ trialing: () => t('status_trialing'),
81
+ active: () => t('status_active'),
82
+ past_due: () => t('status_past_due'),
83
+ canceled: () => t('status_canceled'),
84
+ suspended: () => t('status_suspended'),
85
+ }
86
+ const STATUS_TONE: Record<string, 'info' | 'success' | 'warning' | 'danger' | 'grey'> = {
87
+ trialing: 'info',
88
+ active: 'success',
89
+ past_due: 'warning',
90
+ canceled: 'grey',
91
+ suspended: 'danger',
92
+ }
93
+
94
+ const trialLeft = $derived(trialDaysLeft(sub?.trialEndsAt ?? null))
95
+
96
+ function priceNote(plan: Plan): string {
97
+ if (plan.priceMinor === 0) return t('free')
98
+ if (plan.interval === 'year') return plan.perSeat ? t('per_user_year') : t('per_year')
99
+ return plan.perSeat ? t('per_user_month') : t('per_month')
100
+ }
101
+
102
+ // every number a person reads goes through Intl, counts included — a Latin seat count beside a
103
+ // Persian byte count is the one untranslated thing on the screen
104
+ const nf = $derived(new Intl.NumberFormat(locale))
105
+ const dateFmt = $derived(new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }))
106
+ const day = (iso: string | null) => (iso ? dateFmt.format(new Date(iso)) : '—')
107
+
108
+ /** Sends the person to Stripe. Kept as a mutation so the button can show it is working. */
109
+ const checkout = createMutation(() => ({
110
+ mutationFn: (planSlug: string) => api.subscription.checkout({ workspaceId, planSlug }),
111
+ onSuccess: ({ url }) => {
112
+ window.location.href = url
113
+ },
114
+ onError: (e: Error) => toast.error(e.message),
115
+ }))
116
+
117
+ const portal = createMutation(() => ({
118
+ mutationFn: () => api.subscription.portal({ workspaceId, returnPath: `/${slug}/settings/billing/plan` }),
119
+ onSuccess: ({ url }) => {
120
+ window.location.href = url
121
+ },
122
+ onError: (e: Error) => toast.error(e.message),
123
+ }))
124
+
125
+ const reload = () => {
126
+ void queryClient.invalidateQueries({ queryKey: ['billing'] })
127
+ }
128
+ </script>
129
+
130
+ <svelte:head><title>{t('title')} · {t('common.settings')}</title></svelte:head>
131
+
132
+ <!-- `limit` is already formatted by the caller: bytes have to arrive as "50 GB", never as the
133
+ number itself, and a snippet that took a number could not tell the two apart. -->
134
+ {#snippet meter(label: string, used: string, limit: string | null, ratio: number | null)}
135
+ <div class="grid gap-1.5">
136
+ <div class="flex items-baseline justify-between gap-3">
137
+ <span class="text-[13px] text-[var(--kern-ink-700)]">{label}</span>
138
+ <span class="font-[var(--kern-font-mono)] text-[12px] text-[var(--kern-ink-400)]">
139
+ {limit === null ? used : t('usage_of', { used, limit })}
140
+ </span>
141
+ </div>
142
+ {#if ratio === null}
143
+ <div class="text-[12px] text-[var(--kern-ink-400)]">{t('unlimited')}</div>
144
+ {:else}
145
+ <ProgressBar value={ratio * 100} tone={ratio >= 1 ? 'danger' : ratio > 0.85 ? 'info' : 'accent'} />
146
+ {/if}
147
+ </div>
148
+ {/snippet}
149
+
150
+ <div class="grid gap-6">
151
+ <header class="grid gap-1">
152
+ <h1 class="text-[20px] font-medium text-[var(--kern-ink-900)]">{t('title')}</h1>
153
+ <p class="text-[13px] text-[var(--kern-ink-400)]">{t('subtitle')}</p>
154
+ </header>
155
+
156
+ {#if billing.isPending}
157
+ <Skeleton class="h-[132px] w-full rounded-[var(--kern-r-md)]" />
158
+ <Skeleton class="h-[180px] w-full rounded-[var(--kern-r-md)]" />
159
+ {:else if billing.isError}
160
+ <EmptyState title={t('error')} icon="triangle-alert">
161
+ {#snippet actions()}
162
+ <Button variant="secondary" onclick={reload}>{t('retry')}</Button>
163
+ {/snippet}
164
+ </EmptyState>
165
+ {:else}
166
+ <!-- current plan -->
167
+ <Card>
168
+ <div class="grid gap-4 p-5">
169
+ <div class="flex flex-wrap items-start justify-between gap-3">
170
+ <div class="grid gap-1">
171
+ <span class="text-[12px] text-[var(--kern-ink-400)]">{t('current_plan')}</span>
172
+ <div class="flex items-center gap-2">
173
+ <span class="text-[17px] font-medium text-[var(--kern-ink-900)]">
174
+ {sub?.planName ?? t('no_plan')}
175
+ </span>
176
+ {#if sub}
177
+ <Badge tone={STATUS_TONE[sub.status] ?? 'grey'}>
178
+ {(STATUS_LABEL[sub.status] ?? (() => t('status_active')))()}
179
+ </Badge>
180
+ {/if}
181
+ </div>
182
+ {#if !sub}
183
+ <p class="text-[13px] text-[var(--kern-ink-400)]">{t('no_plan_hint')}</p>
184
+ {:else if sub.status === 'trialing' && trialLeft !== null}
185
+ <p class="text-[13px] text-[var(--kern-ink-400)]">
186
+ {trialLeft === 0 ? t('trial_ends_today') : t('trial_days_left', { count: trialLeft })}
187
+ </p>
188
+ {:else if sub.currentPeriodEnd}
189
+ <p class="text-[13px] text-[var(--kern-ink-400)]">
190
+ {sub.cancelAtPeriodEnd ? t('cancels') : t('renews')}
191
+ {day(sub.currentPeriodEnd)}
192
+ </p>
193
+ {/if}
194
+ </div>
195
+
196
+ {#if data?.paymentsEnabled && sub?.stripeCustomerId}
197
+ <!-- disabled controls say why: a dead button with no explanation is a bug -->
198
+ <Tooltip text={t('no_permission')} disabled={canManage}>
199
+ {#snippet children(props)}
200
+ <span {...props}>
201
+ <Button
202
+ variant="secondary"
203
+ disabled={!canManage || portal.isPending}
204
+ loading={portal.isPending}
205
+ onclick={() => portal.mutate()}
206
+ >
207
+ {t('manage_payment')}
208
+ </Button>
209
+ </span>
210
+ {/snippet}
211
+ </Tooltip>
212
+ {/if}
213
+ </div>
214
+
215
+ {#if sub?.status === 'past_due'}
216
+ <p class="rounded-[var(--kern-r-sm)] bg-[var(--kern-warning-tint)] px-3 py-2 text-[13px] text-[var(--kern-ink-900)]">
217
+ {t('past_due_hint')}
218
+ </p>
219
+ {:else if sub?.status === 'suspended'}
220
+ <p class="rounded-[var(--kern-r-sm)] bg-[var(--kern-danger-tint)] px-3 py-2 text-[13px] text-[var(--kern-ink-900)]">
221
+ {t('suspended_hint')}
222
+ </p>
223
+ {/if}
224
+
225
+ <div class="grid gap-4 sm:grid-cols-2">
226
+ {@render meter(
227
+ t('seats'),
228
+ nf.format(usage.seats),
229
+ data?.limits.seats == null ? null : nf.format(data.limits.seats),
230
+ usageRatio(usage.seats, data?.limits.seats ?? null),
231
+ )}
232
+ {@render meter(
233
+ t('storage'),
234
+ formatBytes(usage.storageBytes, locale),
235
+ data?.limits.storageBytes == null ? null : formatBytes(data.limits.storageBytes, locale),
236
+ usageRatio(usage.storageBytes, data?.limits.storageBytes ?? null),
237
+ )}
238
+ </div>
239
+ </div>
240
+ </Card>
241
+
242
+ <!-- what can be bought -->
243
+ {#if !data?.paymentsEnabled}
244
+ <p class="text-[13px] text-[var(--kern-ink-400)]">{t('payments_disabled')}</p>
245
+ {:else if plans.isPending}
246
+ <Skeleton class="h-[160px] w-full rounded-[var(--kern-r-md)]" />
247
+ {:else if (plans.data ?? []).length === 0}
248
+ <EmptyState title={t('no_plans')} description={t('no_plans_hint')} icon="tag" />
249
+ {:else}
250
+ <!-- `grid-auto-rows:1fr` + `display:grid` on the cell, so a longer description does not leave
251
+ the card beside it visibly short -->
252
+ <ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3" style="grid-auto-rows: 1fr">
253
+ {#each plans.data ?? [] as plan (plan.id)}
254
+ {@const isCurrent = sub?.planId === plan.id}
255
+ {@const blocked = planBlockedReason(plan, { seats: usage.seats })}
256
+ <li class="grid">
257
+ <Card class="grid content-between gap-4 p-4">
258
+ <div class="grid gap-2">
259
+ <div class="flex items-center justify-between gap-2">
260
+ <span class="text-[15px] font-medium text-[var(--kern-ink-900)]">{plan.name}</span>
261
+ {#if isCurrent}<Badge tone="accent">{t('current')}</Badge>{/if}
262
+ </div>
263
+ <div class="flex items-baseline gap-1.5">
264
+ <span class="text-[22px] font-medium text-[var(--kern-ink-900)]">
265
+ {formatMoney(plan.priceMinor, plan.currency, locale)}
266
+ </span>
267
+ <span class="text-[12px] text-[var(--kern-ink-400)]">{priceNote(plan)}</span>
268
+ </div>
269
+ {#if plan.description}
270
+ <p class="text-[13px] text-[var(--kern-ink-700)]">{plan.description}</p>
271
+ {/if}
272
+ {#if plan.highlights.length}
273
+ <ul class="mt-1 grid gap-1">
274
+ {#each plan.highlights as h (h)}
275
+ <li class="text-[12.5px] text-[var(--kern-ink-700)]">{h}</li>
276
+ {/each}
277
+ </ul>
278
+ {/if}
279
+ </div>
280
+ <!-- A plan smaller than the workspace names the number it cannot fit, rather than
281
+ refusing on submit with nothing to act on. -->
282
+ <Tooltip
283
+ text={blocked === 'seats'
284
+ ? t('blocked_seats', {
285
+ seats: nf.format(plan.limits.seats ?? 0),
286
+ used: nf.format(usage.seats),
287
+ })
288
+ : t('no_permission')}
289
+ disabled={blocked === null && canManage}
290
+ >
291
+ {#snippet children(props)}
292
+ <span class="block w-full" {...props}>
293
+ <Button
294
+ class="w-full"
295
+ variant={isCurrent ? 'secondary' : 'primary'}
296
+ disabled={isCurrent || !canManage || blocked !== null || checkout.isPending}
297
+ loading={checkout.isPending && checkout.variables === plan.slug}
298
+ onclick={() => checkout.mutate(plan.slug)}
299
+ >
300
+ {isCurrent ? t('current') : t('choose_plan')}
301
+ </Button>
302
+ </span>
303
+ {/snippet}
304
+ </Tooltip>
305
+ </Card>
306
+ </li>
307
+ {/each}
308
+ </ul>
309
+ {/if}
310
+
311
+ <!-- invoices -->
312
+ <section class="grid gap-2">
313
+ <h2 class="text-[14px] font-medium text-[var(--kern-ink-900)]">{t('invoices_section')}</h2>
314
+ {#if invoices.isPending}
315
+ <Skeleton class="h-[96px] w-full rounded-[var(--kern-r-md)]" />
316
+ {:else if (invoices.data?.items ?? []).length === 0}
317
+ <EmptyState
318
+ title={t('invoices_empty')}
319
+ description={t('invoices_empty_hint')}
320
+ icon="file-text"
321
+ compact
322
+ />
323
+ {:else}
324
+ <div class="overflow-x-auto">
325
+ <Table columns="minmax(120px,1fr) minmax(120px,1fr) minmax(100px,auto) minmax(80px,auto)">
326
+ <TableHeader>
327
+ <TableCell header>{t('invoice_number')}</TableCell>
328
+ <TableCell header>{t('invoice_date')}</TableCell>
329
+ <TableCell header end>{t('invoice_amount')}</TableCell>
330
+ <TableCell header end></TableCell>
331
+ </TableHeader>
332
+ {#each invoices.data?.items ?? [] as inv (inv.id)}
333
+ <TableRow>
334
+ <TableCell>{inv.number ?? '—'}</TableCell>
335
+ <TableCell>{day(inv.createdAt)}</TableCell>
336
+ <TableCell end>{formatMoney(inv.totalMinor, inv.currency, locale)}</TableCell>
337
+ <TableCell end>
338
+ {#if inv.hostedUrl}
339
+ <a
340
+ class="text-[13px] text-[var(--kern-accent-deep)] underline-offset-2 hover:underline"
341
+ href={inv.hostedUrl}
342
+ target="_blank"
343
+ rel="noreferrer noopener"
344
+ >
345
+ {t('invoice_view')}
346
+ </a>
347
+ {/if}
348
+ </TableCell>
349
+ </TableRow>
350
+ {/each}
351
+ </Table>
352
+ </div>
353
+ {/if}
354
+ </section>
355
+ {/if}
356
+ </div>