@kernhq/module-billing 0.2.0 → 0.3.1

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.
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.1",
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,9 +43,9 @@
43
43
  "./migrations": "./migrations"
44
44
  },
45
45
  "dependencies": {
46
- "@kernhq/contracts": "^0.2.0",
47
- "@kernhq/kernel": "^0.2.0",
48
- "@kernhq/sdk": "^0.1.0",
46
+ "@kernhq/contracts": "^0.5.1",
47
+ "@kernhq/kernel": "^0.7.0",
48
+ "@kernhq/sdk": "^0.1.5",
49
49
  "@orpc/contract": "^1.15.0",
50
50
  "@orpc/server": "^1.15.0",
51
51
  "drizzle-orm": "^0.45.0",
@@ -54,17 +54,37 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@kernhq/tsconfig": "^0.1.0",
57
+ "@kernhq/ui": "^0.8.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.8.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>