@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.
- package/dist/server/router.d.ts +68 -4
- package/dist/server/router.d.ts.map +1 -1
- package/package.json +24 -4
- package/src/client/admin/PlansAdmin.svelte +404 -0
- package/src/client/admin/SubscriptionsAdmin.svelte +317 -0
- package/src/client/api-instance.ts +36 -0
- package/src/client/format.test.ts +1 -1
- package/src/client/format.ts +66 -0
- package/src/client/i18n.ts +36 -0
- package/src/client/index.ts +17 -62
- package/src/client/mock.ts +222 -0
- package/src/client/module.ts +60 -0
- package/src/client/settings/PlanSettings.svelte +356 -0
|
@@ -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
|
+
}
|
|
@@ -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')
|
package/src/client/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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'
|