@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,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>
|