@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,317 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Dialog,
6
+ DropdownMenu,
7
+ EmptyState,
8
+ IconButton,
9
+ type MenuItem,
10
+ messageLocale,
11
+ SearchBox,
12
+ Select,
13
+ Skeleton,
14
+ StatTile,
15
+ Table,
16
+ TableCell,
17
+ TableHeader,
18
+ TableRow,
19
+ toast,
20
+ } from '@kernhq/ui'
21
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
22
+ import { getBillingApi } from '../api-instance.js'
23
+ import { t } from '../i18n.js'
24
+ import type { AdminWorkspaceRow, Plan } from '../index.js'
25
+ import { formatBytes, formatMoney } from '../index.js'
26
+
27
+ /**
28
+ * Every workspace on the instance, and what it pays.
29
+ *
30
+ * The operator's screen, not a workspace's — it deliberately crosses workspaces, which is why the
31
+ * console's layout gates the whole area on the instance-admin flag rather than on membership.
32
+ */
33
+
34
+ const api = getBillingApi()
35
+ const queryClient = useQueryClient()
36
+ const locale = $derived(messageLocale())
37
+
38
+ let query = $state('')
39
+ let suspending = $state<AdminWorkspaceRow | null>(null)
40
+ let planFor = $state<AdminWorkspaceRow | null>(null)
41
+ let chosenPlanId = $state<string>('')
42
+
43
+ const rows = createQuery(() => ({
44
+ // the search term is in the key, or a warm cache serves the previous term's rows and the box
45
+ // looks broken
46
+ queryKey: ['billing', 'admin-workspace', query],
47
+ queryFn: () => api.admin.workspaces({ q: query || undefined, limit: 200 }),
48
+ }))
49
+
50
+ const plans = createQuery(() => ({
51
+ queryKey: ['billing', 'plan', 'all'],
52
+ queryFn: () => api.plans.list({ includeUnpublished: true }),
53
+ }))
54
+
55
+ const items = $derived(rows.data?.items ?? [])
56
+ const totals = $derived({
57
+ workspaces: items.length,
58
+ paying: items.filter((r) => r.status === 'active').length,
59
+ suspended: items.filter((r) => r.status === 'suspended').length,
60
+ monthly: items.reduce((n, r) => n + r.monthlyMinor, 0),
61
+ currency: items.find((r) => r.monthlyMinor > 0)?.currency ?? 'usd',
62
+ })
63
+
64
+ const STATUS_LABEL: Record<string, () => string> = {
65
+ trialing: () => t('status_trialing'),
66
+ active: () => t('status_active'),
67
+ past_due: () => t('status_past_due'),
68
+ canceled: () => t('status_canceled'),
69
+ suspended: () => t('status_suspended'),
70
+ }
71
+ const STATUS_TONE: Record<string, 'info' | 'success' | 'warning' | 'danger' | 'grey'> = {
72
+ trialing: 'info',
73
+ active: 'success',
74
+ past_due: 'warning',
75
+ canceled: 'grey',
76
+ suspended: 'danger',
77
+ }
78
+
79
+ const nf = $derived(new Intl.NumberFormat(locale))
80
+ const dateFmt = $derived(new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }))
81
+ const day = (iso: string | null) => (iso ? dateFmt.format(new Date(iso)) : '—')
82
+
83
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['billing'] })
84
+
85
+ const setStatus = createMutation(() => ({
86
+ mutationFn: (v: { workspaceId: string; status: 'active' | 'suspended' }) => api.admin.setStatus(v),
87
+ onSuccess: (_r, v) => {
88
+ const name = items.find((i) => i.workspaceId === v.workspaceId)?.workspaceName ?? ''
89
+ toast.success(
90
+ v.status === 'suspended' ? t('admin_suspended_toast', { name }) : t('admin_resumed_toast', { name }),
91
+ )
92
+ suspending = null
93
+ void refresh()
94
+ },
95
+ onError: (e: Error) => toast.error(e.message),
96
+ }))
97
+
98
+ const setPlan = createMutation(() => ({
99
+ mutationFn: (v: { workspaceId: string; planId: string | null }) => api.admin.setPlan(v),
100
+ onSuccess: () => {
101
+ toast.success(t('admin_plan_changed_toast'))
102
+ planFor = null
103
+ void refresh()
104
+ },
105
+ onError: (e: Error) => toast.error(e.message),
106
+ }))
107
+
108
+ const extendTrial = createMutation(() => ({
109
+ mutationFn: (workspaceId: string) => api.admin.extendTrial({ workspaceId, days: 14 }),
110
+ onSuccess: () => {
111
+ toast.success(t('admin_trial_extended_toast'))
112
+ void refresh()
113
+ },
114
+ onError: (e: Error) => toast.error(e.message),
115
+ }))
116
+
117
+ const clearOverride = createMutation(() => ({
118
+ mutationFn: (workspaceId: string) => api.admin.override({ workspaceId, limits: null }),
119
+ onSuccess: () => void refresh(),
120
+ onError: (e: Error) => toast.error(e.message),
121
+ }))
122
+
123
+ function actionsFor(row: AdminWorkspaceRow): MenuItem[] {
124
+ const out: MenuItem[] = [
125
+ {
126
+ label: t('admin_action_set_plan'),
127
+ icon: 'tag',
128
+ onSelect: () => {
129
+ planFor = row
130
+ chosenPlanId = row.planSlug ? ((plans.data ?? []).find((p) => p.slug === row.planSlug)?.id ?? '') : ''
131
+ },
132
+ },
133
+ {
134
+ label: t('admin_action_extend_trial'),
135
+ icon: 'clock',
136
+ onSelect: () => extendTrial.mutate(row.workspaceId),
137
+ },
138
+ ]
139
+ if (row.overridden)
140
+ out.push({
141
+ label: t('admin_action_clear_override'),
142
+ icon: 'undo-2',
143
+ onSelect: () => clearOverride.mutate(row.workspaceId),
144
+ })
145
+ if (row.stripeCustomerId)
146
+ out.push({
147
+ label: t('admin_action_stripe'),
148
+ icon: 'external-link',
149
+ href: `https://dashboard.stripe.com/customers/${row.stripeCustomerId}`,
150
+ })
151
+ out.push({ type: 'separator' })
152
+ out.push(
153
+ row.status === 'suspended'
154
+ ? {
155
+ label: t('admin_action_resume'),
156
+ icon: 'play',
157
+ onSelect: () => setStatus.mutate({ workspaceId: row.workspaceId, status: 'active' }),
158
+ }
159
+ : {
160
+ label: t('admin_action_suspend'),
161
+ icon: 'slash',
162
+ danger: true,
163
+ onSelect: () => {
164
+ suspending = row
165
+ },
166
+ },
167
+ )
168
+ return out
169
+ }
170
+
171
+ const planOptions = $derived([
172
+ { value: '', label: t('admin_plan_none') },
173
+ ...(plans.data ?? []).map((p: Plan) => ({ value: p.id, label: p.name })),
174
+ ])
175
+ </script>
176
+
177
+ <svelte:head><title>{t('admin_subscriptions_title')} · {t('common.admin')}</title></svelte:head>
178
+
179
+ <div class="grid gap-5">
180
+ <header class="grid gap-1">
181
+ <h1 class="text-[20px] font-medium text-[var(--kern-ink-900)]">{t('admin_subscriptions_title')}</h1>
182
+ <p class="text-[13px] text-[var(--kern-ink-400)]">{t('admin_subscriptions_subtitle')}</p>
183
+ </header>
184
+
185
+ <div class="grid gap-3 sm:grid-cols-4" style="grid-auto-rows: 1fr">
186
+ <StatTile label={t('admin_workspaces')} value={nf.format(totals.workspaces)} />
187
+ <StatTile label={t('admin_paying')} value={nf.format(totals.paying)} />
188
+ <StatTile label={t('admin_suspended_count')} value={nf.format(totals.suspended)} />
189
+ <StatTile label={t('admin_mrr')} value={formatMoney(totals.monthly, totals.currency, locale)} />
190
+ </div>
191
+
192
+ <SearchBox bind:value={query} placeholder={t('admin_search')} />
193
+
194
+ {#if rows.isPending}
195
+ <Skeleton class="h-[220px] w-full rounded-[var(--kern-r-md)]" />
196
+ {:else if rows.isError}
197
+ <EmptyState title={t('admin_error')} icon="triangle-alert">
198
+ {#snippet actions()}
199
+ <Button variant="secondary" onclick={() => void refresh()}>{t('retry')}</Button>
200
+ {/snippet}
201
+ </EmptyState>
202
+ {:else if items.length === 0}
203
+ <EmptyState title={t('admin_empty')} description={t('admin_empty_hint')} icon="building" />
204
+ {:else}
205
+ <!-- the table scrolls inside itself; the page body must never scroll sideways -->
206
+ <div class="overflow-x-auto">
207
+ <Table
208
+ columns="minmax(150px,2fr) minmax(110px,1fr) minmax(104px,auto) minmax(68px,auto) minmax(84px,auto) minmax(96px,auto) minmax(84px,auto) 40px"
209
+ >
210
+ <TableHeader>
211
+ <TableCell header>{t('admin_col_workspace')}</TableCell>
212
+ <TableCell header>{t('admin_col_plan')}</TableCell>
213
+ <TableCell header>{t('admin_col_status')}</TableCell>
214
+ <TableCell header end>{t('admin_col_seats')}</TableCell>
215
+ <TableCell header end>{t('admin_col_storage')}</TableCell>
216
+ <TableCell header>{t('admin_col_renews')}</TableCell>
217
+ <TableCell header end>{t('admin_col_revenue')}</TableCell>
218
+ <TableCell header end></TableCell>
219
+ </TableHeader>
220
+ {#each items as row (row.workspaceId)}
221
+ <TableRow>
222
+ <TableCell>
223
+ <div class="grid">
224
+ <span class="truncate text-[13px] text-[var(--kern-ink-900)]">{row.workspaceName}</span>
225
+ <span class="truncate font-[var(--kern-font-mono)] text-[11px] text-[var(--kern-ink-400)]">
226
+ {row.workspaceSlug}
227
+ </span>
228
+ </div>
229
+ </TableCell>
230
+ <TableCell>
231
+ <div class="flex items-center gap-1.5">
232
+ <span class="truncate">{row.planName ?? t('no_plan')}</span>
233
+ {#if row.overridden}
234
+ <Badge tone="purple" title={t('admin_overridden_hint')}>
235
+ {t('admin_overridden')}
236
+ </Badge>
237
+ {/if}
238
+ </div>
239
+ </TableCell>
240
+ <TableCell>
241
+ {#if row.status}
242
+ <Badge tone={STATUS_TONE[row.status] ?? 'grey'}>
243
+ {(STATUS_LABEL[row.status] ?? (() => t('status_active')))()}
244
+ </Badge>
245
+ {:else}
246
+ <span class="text-[var(--kern-ink-400)]">—</span>
247
+ {/if}
248
+ </TableCell>
249
+ <TableCell end>
250
+ {row.seatsPurchased > 0
251
+ ? `${nf.format(row.seatsUsed)}/${nf.format(row.seatsPurchased)}`
252
+ : nf.format(row.seatsUsed)}
253
+ </TableCell>
254
+ <TableCell end>{formatBytes(row.storageBytes, locale)}</TableCell>
255
+ <TableCell>{day(row.currentPeriodEnd ?? row.trialEndsAt)}</TableCell>
256
+ <TableCell end>
257
+ {row.monthlyMinor > 0 ? formatMoney(row.monthlyMinor, row.currency, locale) : '—'}
258
+ </TableCell>
259
+ <TableCell end>
260
+ <DropdownMenu items={actionsFor(row)} align="end">
261
+ {#snippet trigger(props)}
262
+ <IconButton {...props} icon="ellipsis" size={28} label={t('actions')} />
263
+ {/snippet}
264
+ </DropdownMenu>
265
+ </TableCell>
266
+ </TableRow>
267
+ {/each}
268
+ </Table>
269
+ </div>
270
+ {/if}
271
+ </div>
272
+
273
+ <!-- Suspending withholds the service; it never deletes anything, and the dialog says so. -->
274
+ <Dialog
275
+ open={suspending !== null}
276
+ title={t('admin_suspend_title')}
277
+ description={suspending ? t('admin_suspend_body', { name: suspending.workspaceName }) : ''}
278
+ onOpenChange={(o) => {
279
+ if (!o) suspending = null
280
+ }}
281
+ >
282
+ {#snippet children()}{/snippet}
283
+ {#snippet footer()}
284
+ <Button variant="secondary" onclick={() => (suspending = null)}>{t('admin_cancel')}</Button>
285
+ <Button
286
+ variant="danger"
287
+ loading={setStatus.isPending}
288
+ onclick={() =>
289
+ suspending && setStatus.mutate({ workspaceId: suspending.workspaceId, status: 'suspended' })}
290
+ >
291
+ {t('admin_suspend_confirm')}
292
+ </Button>
293
+ {/snippet}
294
+ </Dialog>
295
+
296
+ <Dialog
297
+ open={planFor !== null}
298
+ title={planFor ? t('admin_set_plan_title', { name: planFor.workspaceName }) : ''}
299
+ description={t('admin_set_plan_body')}
300
+ onOpenChange={(o) => {
301
+ if (!o) planFor = null
302
+ }}
303
+ >
304
+ {#snippet children()}
305
+ <Select bind:value={chosenPlanId} options={planOptions} />
306
+ {/snippet}
307
+ {#snippet footer()}
308
+ <Button variant="secondary" onclick={() => (planFor = null)}>{t('admin_cancel')}</Button>
309
+ <Button
310
+ loading={setPlan.isPending}
311
+ onclick={() =>
312
+ planFor && setPlan.mutate({ workspaceId: planFor.workspaceId, planId: chosenPlanId || null })}
313
+ >
314
+ {t('admin_save')}
315
+ </Button>
316
+ {/snippet}
317
+ </Dialog>
@@ -0,0 +1,36 @@
1
+ import { getHost } from '@kernhq/ui'
2
+ import { type BillingApi, createBillingClient } from './index.js'
3
+ import { createMockBillingApi } from './mock.js'
4
+
5
+ /**
6
+ * The billing API client.
7
+ *
8
+ * Billing is hosted by core, so `/api/billing` is the same origin and the same session cookie as
9
+ * everything else. `PUBLIC_API_MOCK=1` swaps in the in-memory implementation from `./mock`, which
10
+ * satisfies the same contract types, so no view has a second code path for demos and end-to-end
11
+ * tests — with one deliberate hole: the demo's checkout and billing portal throw rather than
12
+ * pretending, because the one thing a demo must never imply is that money moved.
13
+ */
14
+ export type { BillingApi }
15
+
16
+ let cached: BillingApi | null = null
17
+
18
+ export function getBillingApi(): BillingApi {
19
+ if (cached) return cached
20
+ const host = getHost()
21
+ if (host.isMock) {
22
+ cached = createMockBillingApi()
23
+ return cached
24
+ }
25
+ cached = createBillingClient({
26
+ // The shell decides the origin. Same-origin in every real deployment — the dev server proxies
27
+ // /api and the reverse proxy routes it in production — so this module never names a port.
28
+ baseUrl: host.apiBaseUrl,
29
+ })
30
+ return cached
31
+ }
32
+
33
+ /** Test seam: lets tests install a fake without touching module state elsewhere. */
34
+ export function __setBillingApi(api: BillingApi | null) {
35
+ cached = api
36
+ }
@@ -6,7 +6,7 @@ import {
6
6
  planBlockedReason,
7
7
  trialDaysLeft,
8
8
  usageRatio,
9
- } from './index.js'
9
+ } from './format.js'
10
10
 
11
11
  describe('formatMoney', () => {
12
12
  it('shows a whole-unit price without decimals and a part-unit one with them', () => {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Pure formatting and derivation for billing screens.
3
+ *
4
+ * Separate from `index.ts` on purpose: the barrel re-exports the client module, which reaches
5
+ * Svelte components and the framework's rune-backed singletons. Anything importing one helper
6
+ * through the barrel drags all of that in — which is why `format.test.ts` began failing with
7
+ * "$state is not defined" the moment this package grew screens. Import the file, not the barrel.
8
+ */
9
+ /** Money, in the currency's smallest unit, rendered for a locale. */
10
+ export function formatMoney(minor: number, currency: string, locale = 'en'): string {
11
+ return new Intl.NumberFormat(locale, {
12
+ style: 'currency',
13
+ currency: currency.toUpperCase(),
14
+ // whole units read better on a pricing table, and Kern's plans are whole units
15
+ minimumFractionDigits: minor % 100 === 0 ? 0 : 2,
16
+ }).format(minor / 100)
17
+ }
18
+
19
+ /** Bytes as something a person reads, in binary units. */
20
+ export function formatBytes(bytes: number, locale = 'en'): string {
21
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
22
+ let n = bytes
23
+ let i = 0
24
+ while (n >= 1024 && i < units.length - 1) {
25
+ n /= 1024
26
+ i++
27
+ }
28
+ return `${new Intl.NumberFormat(locale, { maximumFractionDigits: n < 10 && i > 0 ? 1 : 0 }).format(n)} ${units[i]}`
29
+ }
30
+
31
+ /**
32
+ * How full a limit is, 0–1, or `null` when there is no limit.
33
+ * Clamped at 1 so a workspace that is over its limit renders a full bar rather than an overflowing
34
+ * one — being over is a state the interface has to be able to draw.
35
+ */
36
+ export function usageRatio(used: number, limit: number | null): number | null {
37
+ if (limit === null || limit <= 0) return null
38
+ return Math.min(1, used / limit)
39
+ }
40
+
41
+ /**
42
+ * Why a plan cannot be chosen right now, or `null` when it can.
43
+ *
44
+ * Returned as a reason rather than a boolean so the interface can *say* why the control is disabled.
45
+ * A disabled control with no explanation is a bug.
46
+ */
47
+ export function planBlockedReason(
48
+ plan: { limits: { seats: number | null } },
49
+ current: { seats: number },
50
+ ): 'seats' | null {
51
+ if (plan.limits.seats !== null && current.seats > plan.limits.seats) return 'seats'
52
+ return null
53
+ }
54
+
55
+ /** Whether a subscription still entitles the workspace to its plan. */
56
+ export function isEntitled(status: string | null): boolean {
57
+ return status === 'trialing' || status === 'active' || status === 'past_due'
58
+ }
59
+
60
+ /** Days left of a trial, or `null` when there is no trial running. */
61
+ export function trialDaysLeft(trialEndsAt: string | null, now = new Date()): number | null {
62
+ if (!trialEndsAt) return null
63
+ const ms = new Date(trialEndsAt).getTime() - now.getTime()
64
+ if (ms <= 0) return 0
65
+ return Math.ceil(ms / 86_400_000)
66
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Billing's own strings, in every locale the platform ships.
3
+ *
4
+ * A module ships separately from the app, so Paraglide cannot compile these — the shell merges
5
+ * them into the framework's message runtime when it registers this module, and `t()` resolves
6
+ * against the merged map. Keys are namespaced by module id, which is what keeps two modules from
7
+ * colliding in that one map.
8
+ *
9
+ * Bundles are thunks so a locale is only fetched when it is the one in use; English is the
10
+ * fallback and is therefore always loaded.
11
+ */
12
+ import { type Message, scopedT } from '@kernhq/ui'
13
+
14
+ export const en: Record<string, Message> = {}
15
+
16
+ export type BillingMessageKey = keyof typeof en
17
+
18
+ const ar: Record<string, Message> = {}
19
+
20
+ const de: Record<string, Message> = {}
21
+
22
+ const fa: Record<string, Message> = {}
23
+
24
+ const tr: Record<string, Message> = {}
25
+
26
+ /** In the shape `defineClientModule().messages` expects. */
27
+ export const billingMessageBundles = {
28
+ ar: async () => ar,
29
+ de: async () => de,
30
+ en: async () => en,
31
+ fa: async () => fa,
32
+ tr: async () => tr,
33
+ }
34
+
35
+ /** `t('settings_nav')` — the module id is implied. */
36
+ export const t = scopedT('billing')
@@ -1,7 +1,10 @@
1
1
  /**
2
- * What the app imports from this package: the contract's types, the permission keys, and the pure
3
- * functions a billing screen needs. The Svelte module manifest itself lives in the app
4
- * (`src/lib/modules/billing/client.ts`), because that is where the route components are.
2
+ * Billing's client half: the contract's types, the permission keys, the pure functions its screens
3
+ * need, its own strings, and the client module the shell registers.
4
+ *
5
+ * The screens live here too — the workspace plan page and the two instance console pages — rather
6
+ * than in the app. The shell mounts whatever the manifest declares, so deleting this package removes
7
+ * the feature completely.
5
8
  */
6
9
 
7
10
  export {
@@ -20,62 +23,14 @@ export {
20
23
  type WorkspaceBilling,
21
24
  } from '../contract.js'
22
25
  export { type BillingApi, createBillingClient } from './api.js'
23
-
24
- /** Money, in the currency's smallest unit, rendered for a locale. */
25
- export function formatMoney(minor: number, currency: string, locale = 'en'): string {
26
- return new Intl.NumberFormat(locale, {
27
- style: 'currency',
28
- currency: currency.toUpperCase(),
29
- // whole units read better on a pricing table, and Kern's plans are whole units
30
- minimumFractionDigits: minor % 100 === 0 ? 0 : 2,
31
- }).format(minor / 100)
32
- }
33
-
34
- /** Bytes as something a person reads, in binary units. */
35
- export function formatBytes(bytes: number, locale = 'en'): string {
36
- const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
37
- let n = bytes
38
- let i = 0
39
- while (n >= 1024 && i < units.length - 1) {
40
- n /= 1024
41
- i++
42
- }
43
- return `${new Intl.NumberFormat(locale, { maximumFractionDigits: n < 10 && i > 0 ? 1 : 0 }).format(n)} ${units[i]}`
44
- }
45
-
46
- /**
47
- * How full a limit is, 0–1, or `null` when there is no limit.
48
- * Clamped at 1 so a workspace that is over its limit renders a full bar rather than an overflowing
49
- * one — being over is a state the interface has to be able to draw.
50
- */
51
- export function usageRatio(used: number, limit: number | null): number | null {
52
- if (limit === null || limit <= 0) return null
53
- return Math.min(1, used / limit)
54
- }
55
-
56
- /**
57
- * Why a plan cannot be chosen right now, or `null` when it can.
58
- *
59
- * Returned as a reason rather than a boolean so the interface can *say* why the control is disabled.
60
- * A disabled control with no explanation is a bug.
61
- */
62
- export function planBlockedReason(
63
- plan: { limits: { seats: number | null } },
64
- current: { seats: number },
65
- ): 'seats' | null {
66
- if (plan.limits.seats !== null && current.seats > plan.limits.seats) return 'seats'
67
- return null
68
- }
69
-
70
- /** Whether a subscription still entitles the workspace to its plan. */
71
- export function isEntitled(status: string | null): boolean {
72
- return status === 'trialing' || status === 'active' || status === 'past_due'
73
- }
74
-
75
- /** Days left of a trial, or `null` when there is no trial running. */
76
- export function trialDaysLeft(trialEndsAt: string | null, now = new Date()): number | null {
77
- if (!trialEndsAt) return null
78
- const ms = new Date(trialEndsAt).getTime() - now.getTime()
79
- if (ms <= 0) return 0
80
- return Math.ceil(ms / 86_400_000)
81
- }
26
+ export { __setBillingApi, getBillingApi } from './api-instance.js'
27
+ export {
28
+ formatBytes,
29
+ formatMoney,
30
+ isEntitled,
31
+ planBlockedReason,
32
+ trialDaysLeft,
33
+ usageRatio,
34
+ } from './format.js'
35
+ export { type BillingMessageKey, billingMessageBundles, t } from './i18n.js'
36
+ export { billingClientModule, billingClientModule as default } from './module.js'