@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.
@@ -258,7 +258,23 @@ export declare function billingRouter(kernel: Kernel): {
258
258
  get: import("@orpc/server").Procedure<import("@orpc/server").MergedInitialContext<import("@kernhq/kernel").RequestContext & Record<never, never>, import("@kernhq/kernel").RequestContext & Record<never, never> & Omit<import("@kernhq/kernel").RequestContext & Record<never, never>, keyof import("@kernhq/kernel").RequestContext> & Omit<import("@kernhq/kernel").RequestContext & Record<never, never>, "workspaceId" | "kernel" | "principal" | "requestId" | "ip" | "headers">, import("@kernhq/kernel").RequestContext>, Omit<import("@orpc/server").MergedCurrentContext<import("@kernhq/kernel").RequestContext, {
259
259
  workspaceId: string;
260
260
  kernel: Kernel;
261
- principal: import("@kernhq/contracts").Principal;
261
+ principal: {
262
+ kind: "service" | "user" | "api_key" | "anonymous";
263
+ userId: (string & import("zod").$brand<"UserId">) | null;
264
+ email: string | null;
265
+ name: string | null;
266
+ locale: "en" | "fa" | "ar" | "de";
267
+ instanceAdmin: boolean;
268
+ service: string | null;
269
+ memberships: {
270
+ workspaceId: string & import("zod").$brand<"WorkspaceId">;
271
+ role: "owner" | "admin" | "member" | "guest";
272
+ roleIds: string[];
273
+ groupIds: string[];
274
+ status: "active" | "suspended" | "invited";
275
+ }[];
276
+ permissionVersion: number;
277
+ };
262
278
  requestId: string;
263
279
  ip: string;
264
280
  headers: Record<string, string | string[] | undefined>;
@@ -325,7 +341,23 @@ export declare function billingRouter(kernel: Kernel): {
325
341
  invoices: import("@orpc/server").Procedure<import("@orpc/server").MergedInitialContext<import("@kernhq/kernel").RequestContext & Record<never, never>, import("@kernhq/kernel").RequestContext & Record<never, never> & Omit<import("@kernhq/kernel").RequestContext & Record<never, never>, keyof import("@kernhq/kernel").RequestContext> & Omit<import("@kernhq/kernel").RequestContext & Record<never, never>, "workspaceId" | "kernel" | "principal" | "requestId" | "ip" | "headers">, import("@kernhq/kernel").RequestContext>, Omit<import("@orpc/server").MergedCurrentContext<import("@kernhq/kernel").RequestContext, {
326
342
  workspaceId: string;
327
343
  kernel: Kernel;
328
- principal: import("@kernhq/contracts").Principal;
344
+ principal: {
345
+ kind: "service" | "user" | "api_key" | "anonymous";
346
+ userId: (string & import("zod").$brand<"UserId">) | null;
347
+ email: string | null;
348
+ name: string | null;
349
+ locale: "en" | "fa" | "ar" | "de";
350
+ instanceAdmin: boolean;
351
+ service: string | null;
352
+ memberships: {
353
+ workspaceId: string & import("zod").$brand<"WorkspaceId">;
354
+ role: "owner" | "admin" | "member" | "guest";
355
+ roleIds: string[];
356
+ groupIds: string[];
357
+ status: "active" | "suspended" | "invited";
358
+ }[];
359
+ permissionVersion: number;
360
+ };
329
361
  requestId: string;
330
362
  ip: string;
331
363
  headers: Record<string, string | string[] | undefined>;
@@ -375,7 +407,23 @@ export declare function billingRouter(kernel: Kernel): {
375
407
  checkout: import("@orpc/server").Procedure<import("@orpc/server").MergedInitialContext<import("@kernhq/kernel").RequestContext & Record<never, never>, import("@kernhq/kernel").RequestContext & Record<never, never> & Omit<import("@kernhq/kernel").RequestContext & Record<never, never>, keyof import("@kernhq/kernel").RequestContext> & Omit<import("@kernhq/kernel").RequestContext & Record<never, never>, "workspaceId" | "kernel" | "principal" | "requestId" | "ip" | "headers">, import("@kernhq/kernel").RequestContext>, Omit<import("@orpc/server").MergedCurrentContext<import("@kernhq/kernel").RequestContext, {
376
408
  workspaceId: string;
377
409
  kernel: Kernel;
378
- principal: import("@kernhq/contracts").Principal;
410
+ principal: {
411
+ kind: "service" | "user" | "api_key" | "anonymous";
412
+ userId: (string & import("zod").$brand<"UserId">) | null;
413
+ email: string | null;
414
+ name: string | null;
415
+ locale: "en" | "fa" | "ar" | "de";
416
+ instanceAdmin: boolean;
417
+ service: string | null;
418
+ memberships: {
419
+ workspaceId: string & import("zod").$brand<"WorkspaceId">;
420
+ role: "owner" | "admin" | "member" | "guest";
421
+ roleIds: string[];
422
+ groupIds: string[];
423
+ status: "active" | "suspended" | "invited";
424
+ }[];
425
+ permissionVersion: number;
426
+ };
379
427
  requestId: string;
380
428
  ip: string;
381
429
  headers: Record<string, string | string[] | undefined>;
@@ -411,7 +459,23 @@ export declare function billingRouter(kernel: Kernel): {
411
459
  portal: import("@orpc/server").Procedure<import("@orpc/server").MergedInitialContext<import("@kernhq/kernel").RequestContext & Record<never, never>, import("@kernhq/kernel").RequestContext & Record<never, never> & Omit<import("@kernhq/kernel").RequestContext & Record<never, never>, keyof import("@kernhq/kernel").RequestContext> & Omit<import("@kernhq/kernel").RequestContext & Record<never, never>, "workspaceId" | "kernel" | "principal" | "requestId" | "ip" | "headers">, import("@kernhq/kernel").RequestContext>, Omit<import("@orpc/server").MergedCurrentContext<import("@kernhq/kernel").RequestContext, {
412
460
  workspaceId: string;
413
461
  kernel: Kernel;
414
- principal: import("@kernhq/contracts").Principal;
462
+ principal: {
463
+ kind: "service" | "user" | "api_key" | "anonymous";
464
+ userId: (string & import("zod").$brand<"UserId">) | null;
465
+ email: string | null;
466
+ name: string | null;
467
+ locale: "en" | "fa" | "ar" | "de";
468
+ instanceAdmin: boolean;
469
+ service: string | null;
470
+ memberships: {
471
+ workspaceId: string & import("zod").$brand<"WorkspaceId">;
472
+ role: "owner" | "admin" | "member" | "guest";
473
+ roleIds: string[];
474
+ groupIds: string[];
475
+ status: "active" | "suspended" | "invited";
476
+ }[];
477
+ permissionVersion: number;
478
+ };
415
479
  requestId: string;
416
480
  ip: string;
417
481
  headers: Record<string, string | string[] | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/server/router.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAgC,MAAM,gBAAgB,CAAA;AAgC1E,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsH3C"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/server/router.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,MAAM,EAAgC,MAAM,gBAAgB,CAAA;AAgC1E,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsH3C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernhq/module-billing",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Kern billing module: plans, entitlements, subscriptions and Stripe — the mechanism that lets any Kern instance sell seats on itself",
5
5
  "homepage": "https://kernaio.com",
6
6
  "license": "AGPL-3.0-only",
@@ -43,8 +43,8 @@
43
43
  "./migrations": "./migrations"
44
44
  },
45
45
  "dependencies": {
46
- "@kernhq/contracts": "^0.2.0",
47
- "@kernhq/kernel": "^0.2.0",
46
+ "@kernhq/contracts": "^0.5.1",
47
+ "@kernhq/kernel": "^0.6.0",
48
48
  "@kernhq/sdk": "^0.1.0",
49
49
  "@orpc/contract": "^1.15.0",
50
50
  "@orpc/server": "^1.15.0",
@@ -54,17 +54,37 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@kernhq/tsconfig": "^0.1.0",
57
+ "@kernhq/ui": "^0.7.0",
58
+ "@tanstack/svelte-query": "^6.1.0",
57
59
  "@types/node": "^24.0.0",
58
60
  "@types/pg": "^8.15.0",
59
61
  "drizzle-kit": "^0.31.0",
60
62
  "pg": "^8.16.0",
63
+ "svelte": "^5.46.0",
64
+ "svelte-check": "^4.0.0",
61
65
  "typescript": "~5.9.3",
62
66
  "vitest": "^4.0.0"
63
67
  },
68
+ "peerDependencies": {
69
+ "@kernhq/ui": "^0.7.0",
70
+ "@tanstack/svelte-query": "^6.1.0",
71
+ "svelte": "^5.46.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@kernhq/ui": {
75
+ "optional": true
76
+ },
77
+ "@tanstack/svelte-query": {
78
+ "optional": true
79
+ },
80
+ "svelte": {
81
+ "optional": true
82
+ }
83
+ },
64
84
  "scripts": {
65
85
  "build": "tsc -p tsconfig.json",
66
86
  "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
67
- "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
87
+ "typecheck": "tsc -p tsconfig.json --noEmit && svelte-check --tsconfig ./tsconfig.client.json --threshold error",
68
88
  "lint": "biome check .",
69
89
  "test": "vitest run",
70
90
  "db:generate": "drizzle-kit generate",
@@ -0,0 +1,404 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Checkbox,
6
+ Dialog,
7
+ DropdownMenu,
8
+ EmptyState,
9
+ Field,
10
+ IconButton,
11
+ Input,
12
+ type MenuItem,
13
+ messageLocale,
14
+ Select,
15
+ Skeleton,
16
+ Table,
17
+ TableCell,
18
+ TableHeader,
19
+ TableRow,
20
+ Textarea,
21
+ toast,
22
+ } from '@kernhq/ui'
23
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
24
+ import { getBillingApi } from '../api-instance.js'
25
+ import { t } from '../i18n.js'
26
+ import type { Plan } from '../index.js'
27
+ import { formatMoney } from '../index.js'
28
+
29
+ /**
30
+ * The catalogue: what this instance sells.
31
+ *
32
+ * A plan is data rather than code precisely so that this screen exists — the price an instance
33
+ * charges, what it includes, and what its marketing page shows are one row, edited here, instead of
34
+ * a constant in a repository and a second copy on a website that drift apart.
35
+ *
36
+ * A limit left empty means unlimited. The set of limits is fixed by the contract: an administrator
37
+ * decides the values, never the keys, because a key nothing enforces is a promise nobody keeps.
38
+ */
39
+
40
+ const api = getBillingApi()
41
+ const queryClient = useQueryClient()
42
+ const locale = $derived(messageLocale())
43
+ const nf = $derived(new Intl.NumberFormat(locale))
44
+
45
+ const GB = 1024 ** 3
46
+
47
+ interface Draft {
48
+ id?: string
49
+ name: string
50
+ slug: string
51
+ description: string
52
+ price: string
53
+ currency: string
54
+ interval: 'month' | 'year'
55
+ perSeat: boolean
56
+ trialDays: string
57
+ seats: string
58
+ storageGb: string
59
+ sso: boolean
60
+ auditDays: string
61
+ stripePriceId: string
62
+ order: string
63
+ published: boolean
64
+ }
65
+
66
+ const emptyDraft = (): Draft => ({
67
+ name: '',
68
+ slug: '',
69
+ description: '',
70
+ price: '0',
71
+ currency: 'usd',
72
+ interval: 'month',
73
+ perSeat: true,
74
+ trialDays: '14',
75
+ seats: '',
76
+ storageGb: '',
77
+ sso: true,
78
+ auditDays: '',
79
+ stripePriceId: '',
80
+ order: '100',
81
+ published: false,
82
+ })
83
+
84
+ const toDraft = (p: Plan): Draft => ({
85
+ id: p.id,
86
+ name: p.name,
87
+ slug: p.slug,
88
+ description: p.description,
89
+ price: String(p.priceMinor / 100),
90
+ currency: p.currency,
91
+ interval: p.interval,
92
+ perSeat: p.perSeat,
93
+ trialDays: String(p.trialDays),
94
+ seats: p.limits.seats === null ? '' : String(p.limits.seats),
95
+ storageGb: p.limits.storageBytes === null ? '' : String(Math.round(p.limits.storageBytes / GB)),
96
+ sso: p.limits.sso,
97
+ auditDays: p.limits.auditRetentionDays === null ? '' : String(p.limits.auditRetentionDays),
98
+ stripePriceId: p.stripePriceId ?? '',
99
+ order: String(p.order),
100
+ published: p.published,
101
+ })
102
+
103
+ /** An empty field means "no limit", which is not the same as zero. */
104
+ const numOrNull = (v: string) => (v.trim() === '' ? null : Number(v))
105
+
106
+ let editing = $state<Draft | null>(null)
107
+ let archiving = $state<Plan | null>(null)
108
+
109
+ const plans = createQuery(() => ({
110
+ queryKey: ['billing', 'plan', 'all'],
111
+ queryFn: () => api.plans.list({ includeUnpublished: true }),
112
+ }))
113
+
114
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['billing'] })
115
+
116
+ const save = createMutation(() => ({
117
+ mutationFn: (d: Draft) =>
118
+ api.plans.upsert({
119
+ ...(d.id ? { id: d.id } : {}),
120
+ slug: d.slug.trim(),
121
+ name: d.name.trim(),
122
+ description: d.description.trim(),
123
+ priceMinor: Math.round(Number(d.price || 0) * 100),
124
+ currency: d.currency.trim().toLowerCase(),
125
+ interval: d.interval,
126
+ perSeat: d.perSeat,
127
+ trialDays: Number(d.trialDays || 0),
128
+ limits: {
129
+ seats: numOrNull(d.seats),
130
+ storageBytes: d.storageGb.trim() === '' ? null : Number(d.storageGb) * GB,
131
+ modules: null,
132
+ sso: d.sso,
133
+ auditRetentionDays: numOrNull(d.auditDays),
134
+ apiRateLimit: null,
135
+ },
136
+ stripePriceId: d.stripePriceId.trim() || null,
137
+ highlights: [],
138
+ published: d.published,
139
+ order: Number(d.order || 100),
140
+ }),
141
+ onSuccess: () => {
142
+ toast.success(t('admin_plan_saved'))
143
+ editing = null
144
+ void refresh()
145
+ },
146
+ onError: (e: Error) => toast.error(e.message),
147
+ }))
148
+
149
+ const setPublished = createMutation(() => ({
150
+ mutationFn: (v: { id: string; published: boolean }) => api.plans.setPublished(v),
151
+ onSuccess: (plan) => {
152
+ toast.success(
153
+ plan.published
154
+ ? t('admin_plan_published_toast', { name: plan.name })
155
+ : t('admin_plan_unpublished_toast', { name: plan.name }),
156
+ )
157
+ void refresh()
158
+ },
159
+ onError: (e: Error) => toast.error(e.message),
160
+ }))
161
+
162
+ const archive = createMutation(() => ({
163
+ mutationFn: (id: string) => api.plans.archive({ id }),
164
+ onSuccess: () => {
165
+ toast.success(t('admin_plan_archived_toast', { name: archiving?.name ?? '' }))
166
+ archiving = null
167
+ void refresh()
168
+ },
169
+ onError: (e: Error) => toast.error(e.message),
170
+ }))
171
+
172
+ function actionsFor(plan: Plan): MenuItem[] {
173
+ return [
174
+ { label: t('admin_plan_edit'), icon: 'pencil', onSelect: () => (editing = toDraft(plan)) },
175
+ {
176
+ label: plan.published ? t('admin_plan_unpublish') : t('admin_plan_publish'),
177
+ icon: plan.published ? 'eye-off' : 'eye',
178
+ // A plan with no Stripe price can still be published — it just cannot be bought, only
179
+ // assigned. The form says so rather than the menu refusing.
180
+ onSelect: () => setPublished.mutate({ id: plan.id, published: !plan.published }),
181
+ },
182
+ { type: 'separator' },
183
+ {
184
+ label: t('admin_plan_archive'),
185
+ icon: 'archive',
186
+ danger: true,
187
+ onSelect: () => (archiving = plan),
188
+ },
189
+ ]
190
+ }
191
+
192
+ const intervalOptions = $derived([
193
+ { value: 'month', label: t('admin_plan_interval_month') },
194
+ { value: 'year', label: t('admin_plan_interval_year') },
195
+ ])
196
+ </script>
197
+
198
+ <svelte:head><title>{t('admin_plans_title')} · {t('common.admin')}</title></svelte:head>
199
+
200
+ <div class="grid gap-5">
201
+ <header class="flex flex-wrap items-start justify-between gap-3">
202
+ <div class="grid gap-1">
203
+ <h1 class="text-[20px] font-medium text-[var(--kern-ink-900)]">{t('admin_plans_title')}</h1>
204
+ <p class="text-[13px] text-[var(--kern-ink-400)]">{t('admin_plans_subtitle')}</p>
205
+ </div>
206
+ <Button onclick={() => (editing = emptyDraft())}>{t('admin_plan_new')}</Button>
207
+ </header>
208
+
209
+ {#if plans.isPending}
210
+ <Skeleton class="h-[200px] w-full rounded-[var(--kern-r-md)]" />
211
+ {:else if plans.isError}
212
+ <EmptyState title={t('admin_error')} icon="triangle-alert">
213
+ {#snippet actions()}
214
+ <Button variant="secondary" onclick={() => void refresh()}>{t('retry')}</Button>
215
+ {/snippet}
216
+ </EmptyState>
217
+ {:else if (plans.data ?? []).length === 0}
218
+ <EmptyState title={t('admin_plans_empty')} description={t('admin_plans_empty_hint')} icon="tag">
219
+ {#snippet actions()}
220
+ <Button onclick={() => (editing = emptyDraft())}>{t('admin_plan_new')}</Button>
221
+ {/snippet}
222
+ </EmptyState>
223
+ {:else}
224
+ <div class="overflow-x-auto">
225
+ <Table columns="minmax(160px,2fr) minmax(120px,1fr) minmax(110px,auto) minmax(90px,auto) minmax(110px,auto) 40px">
226
+ <TableHeader>
227
+ <TableCell header>{t('admin_plan_name')}</TableCell>
228
+ <TableCell header>{t('admin_plan_price')}</TableCell>
229
+ <TableCell header end>{t('admin_plan_seats')}</TableCell>
230
+ <TableCell header end>{t('admin_plan_storage_gb')}</TableCell>
231
+ <TableCell header>{t('status')}</TableCell>
232
+ <TableCell header end></TableCell>
233
+ </TableHeader>
234
+ {#each plans.data ?? [] as plan (plan.id)}
235
+ <TableRow>
236
+ <TableCell>
237
+ <div class="grid">
238
+ <span class="truncate text-[13px] text-[var(--kern-ink-900)]">{plan.name}</span>
239
+ <span class="truncate font-[var(--kern-font-mono)] text-[11px] text-[var(--kern-ink-400)]">
240
+ {plan.slug}
241
+ </span>
242
+ </div>
243
+ </TableCell>
244
+ <TableCell>
245
+ {formatMoney(plan.priceMinor, plan.currency, locale)}
246
+ <span class="text-[11px] text-[var(--kern-ink-400)]">
247
+ {plan.interval === 'year' ? t('per_year') : t('per_month')}
248
+ </span>
249
+ </TableCell>
250
+ <TableCell end>{plan.limits.seats == null ? t('unlimited') : nf.format(plan.limits.seats)}</TableCell>
251
+ <TableCell end>
252
+ {plan.limits.storageBytes === null
253
+ ? t('unlimited')
254
+ : nf.format(Math.round(plan.limits.storageBytes / GB))}
255
+ </TableCell>
256
+ <TableCell>
257
+ <Badge tone={plan.published ? 'success' : 'grey'}>
258
+ {plan.published ? t('admin_plan_published') : t('admin_plan_draft')}
259
+ </Badge>
260
+ </TableCell>
261
+ <TableCell end>
262
+ <DropdownMenu items={actionsFor(plan)} align="end">
263
+ {#snippet trigger(props)}
264
+ <IconButton {...props} icon="ellipsis" size={28} label={t('actions')} />
265
+ {/snippet}
266
+ </DropdownMenu>
267
+ </TableCell>
268
+ </TableRow>
269
+ {/each}
270
+ </Table>
271
+ </div>
272
+ {/if}
273
+ </div>
274
+
275
+ <Dialog
276
+ open={editing !== null}
277
+ size="lg"
278
+ title={editing?.id ? t('admin_plan_edit') : t('admin_plan_new')}
279
+ onOpenChange={(o) => {
280
+ if (!o) editing = null
281
+ }}
282
+ >
283
+ {#snippet children()}
284
+ {#if editing}
285
+ <div class="grid gap-4">
286
+ <div class="grid gap-4 sm:grid-cols-2">
287
+ <Field label={t('admin_plan_name')}>
288
+ {#snippet children(id)}
289
+ <Input {id} bind:value={editing!.name} />
290
+ {/snippet}
291
+ </Field>
292
+ <Field label={t('admin_plan_slug')} hint={t('admin_plan_slug_hint')}>
293
+ {#snippet children(id)}
294
+ <Input {id} bind:value={editing!.slug} />
295
+ {/snippet}
296
+ </Field>
297
+ </div>
298
+
299
+ <Field label={t('admin_plan_description')}>
300
+ {#snippet children(id)}
301
+ <Textarea {id} bind:value={editing!.description} rows={2} />
302
+ {/snippet}
303
+ </Field>
304
+
305
+ <div class="grid gap-4 sm:grid-cols-3">
306
+ <Field label={t('admin_plan_price')} hint={t('admin_plan_price_hint')}>
307
+ {#snippet children(id)}
308
+ <Input {id} type="number" min="0" step="1" bind:value={editing!.price} />
309
+ {/snippet}
310
+ </Field>
311
+ <Field label={t('admin_plan_currency')}>
312
+ {#snippet children(id)}
313
+ <Input {id} bind:value={editing!.currency} />
314
+ {/snippet}
315
+ </Field>
316
+ <Field label={t('admin_plan_interval')}>
317
+ {#snippet children(id)}
318
+ <Select {id} bind:value={editing!.interval} options={intervalOptions} />
319
+ {/snippet}
320
+ </Field>
321
+ </div>
322
+
323
+ <div class="grid gap-4 sm:grid-cols-2">
324
+ <Checkbox
325
+ bind:checked={editing!.perSeat}
326
+ label={t('admin_plan_per_seat')}
327
+ description={t('admin_plan_per_seat_hint')}
328
+ />
329
+ <Field label={t('admin_plan_trial_days')}>
330
+ {#snippet children(id)}
331
+ <Input {id} type="number" min="0" bind:value={editing!.trialDays} />
332
+ {/snippet}
333
+ </Field>
334
+ </div>
335
+
336
+ <div class="grid gap-2">
337
+ <span class="text-[13px] font-medium text-[var(--kern-ink-900)]">{t('admin_plan_limits')}</span>
338
+ <span class="text-[12px] text-[var(--kern-ink-400)]">{t('admin_plan_limits_hint')}</span>
339
+ <div class="mt-1 grid gap-4 sm:grid-cols-3">
340
+ <Field label={t('admin_plan_seats')}>
341
+ {#snippet children(id)}
342
+ <Input {id} type="number" min="1" bind:value={editing!.seats} />
343
+ {/snippet}
344
+ </Field>
345
+ <Field label={t('admin_plan_storage_gb')}>
346
+ {#snippet children(id)}
347
+ <Input {id} type="number" min="0" bind:value={editing!.storageGb} />
348
+ {/snippet}
349
+ </Field>
350
+ <Field label={t('admin_plan_audit_days')}>
351
+ {#snippet children(id)}
352
+ <Input {id} type="number" min="1" bind:value={editing!.auditDays} />
353
+ {/snippet}
354
+ </Field>
355
+ </div>
356
+ <Checkbox bind:checked={editing!.sso} label={t('admin_plan_sso')} />
357
+ </div>
358
+
359
+ <div class="grid gap-4 sm:grid-cols-2">
360
+ <Field label={t('admin_plan_stripe_price')} hint={t('admin_plan_stripe_price_hint')}>
361
+ {#snippet children(id)}
362
+ <Input {id} bind:value={editing!.stripePriceId} placeholder="price_…" />
363
+ {/snippet}
364
+ </Field>
365
+ <Field label={t('admin_plan_order')}>
366
+ {#snippet children(id)}
367
+ <Input {id} type="number" bind:value={editing!.order} />
368
+ {/snippet}
369
+ </Field>
370
+ </div>
371
+
372
+ <Checkbox bind:checked={editing!.published} label={t('admin_plan_published')} />
373
+ </div>
374
+ {/if}
375
+ {/snippet}
376
+ {#snippet footer()}
377
+ <Button variant="secondary" onclick={() => (editing = null)}>{t('admin_cancel')}</Button>
378
+ <Button
379
+ loading={save.isPending}
380
+ disabled={!editing?.name.trim() || !editing?.slug.trim()}
381
+ onclick={() => editing && save.mutate($state.snapshot(editing))}
382
+ >
383
+ {t('admin_save')}
384
+ </Button>
385
+ {/snippet}
386
+ </Dialog>
387
+
388
+ <!-- Archiving withdraws a plan from sale; whoever is on it stays on it, and the dialog says so. -->
389
+ <Dialog
390
+ open={archiving !== null}
391
+ title={archiving ? t('admin_archive_title', { name: archiving.name }) : ''}
392
+ description={t('admin_archive_body')}
393
+ onOpenChange={(o) => {
394
+ if (!o) archiving = null
395
+ }}
396
+ >
397
+ {#snippet children()}{/snippet}
398
+ {#snippet footer()}
399
+ <Button variant="secondary" onclick={() => (archiving = null)}>{t('admin_cancel')}</Button>
400
+ <Button variant="danger" loading={archive.isPending} onclick={() => archiving && archive.mutate(archiving.id)}>
401
+ {t('admin_archive_confirm')}
402
+ </Button>
403
+ {/snippet}
404
+ </Dialog>