@kernhq/module-billing 0.2.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.
Files changed (46) hide show
  1. package/LICENSE +662 -0
  2. package/dist/contract.d.ts +965 -0
  3. package/dist/contract.d.ts.map +1 -0
  4. package/dist/contract.js +265 -0
  5. package/dist/contract.js.map +1 -0
  6. package/dist/server/index.d.ts +11 -0
  7. package/dist/server/index.d.ts.map +1 -0
  8. package/dist/server/index.js +165 -0
  9. package/dist/server/index.js.map +1 -0
  10. package/dist/server/router.d.ts +697 -0
  11. package/dist/server/router.d.ts.map +1 -0
  12. package/dist/server/router.js +126 -0
  13. package/dist/server/router.js.map +1 -0
  14. package/dist/server/schema.d.ts +1030 -0
  15. package/dist/server/schema.d.ts.map +1 -0
  16. package/dist/server/schema.js +137 -0
  17. package/dist/server/schema.js.map +1 -0
  18. package/dist/server/services/entitlements.d.ts +3 -0
  19. package/dist/server/services/entitlements.d.ts.map +1 -0
  20. package/dist/server/services/entitlements.js +68 -0
  21. package/dist/server/services/entitlements.js.map +1 -0
  22. package/dist/server/services/plans.d.ts +20 -0
  23. package/dist/server/services/plans.d.ts.map +1 -0
  24. package/dist/server/services/plans.js +122 -0
  25. package/dist/server/services/plans.js.map +1 -0
  26. package/dist/server/services/stripe.d.ts +42 -0
  27. package/dist/server/services/stripe.d.ts.map +1 -0
  28. package/dist/server/services/stripe.js +267 -0
  29. package/dist/server/services/stripe.js.map +1 -0
  30. package/dist/server/services/subscriptions.d.ts +35 -0
  31. package/dist/server/services/subscriptions.d.ts.map +1 -0
  32. package/dist/server/services/subscriptions.js +193 -0
  33. package/dist/server/services/subscriptions.js.map +1 -0
  34. package/dist/server/services/usage.d.ts +40 -0
  35. package/dist/server/services/usage.d.ts.map +1 -0
  36. package/dist/server/services/usage.js +98 -0
  37. package/dist/server/services/usage.js.map +1 -0
  38. package/migrations/0000_init.sql +82 -0
  39. package/migrations/0001_rls.sql +18 -0
  40. package/migrations/meta/0000_snapshot.json +580 -0
  41. package/migrations/meta/_journal.json +20 -0
  42. package/package.json +73 -0
  43. package/src/client/api.ts +15 -0
  44. package/src/client/format.test.ts +64 -0
  45. package/src/client/index.ts +81 -0
  46. package/src/contract.ts +311 -0
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ formatBytes,
4
+ formatMoney,
5
+ isEntitled,
6
+ planBlockedReason,
7
+ trialDaysLeft,
8
+ usageRatio,
9
+ } from './index.js'
10
+
11
+ describe('formatMoney', () => {
12
+ it('shows a whole-unit price without decimals and a part-unit one with them', () => {
13
+ expect(formatMoney(800, 'usd')).toBe('$8')
14
+ expect(formatMoney(1650, 'usd')).toBe('$16.50')
15
+ })
16
+ })
17
+
18
+ describe('formatBytes', () => {
19
+ it('climbs binary units and keeps one decimal only where it means something', () => {
20
+ expect(formatBytes(0)).toBe('0 B')
21
+ expect(formatBytes(1024)).toBe('1 KB')
22
+ expect(formatBytes(1536)).toBe('1.5 KB')
23
+ expect(formatBytes(53_687_091_200)).toBe('50 GB')
24
+ })
25
+ })
26
+
27
+ describe('usageRatio', () => {
28
+ it('has no ratio when there is no limit', () => {
29
+ expect(usageRatio(10, null)).toBeNull()
30
+ })
31
+ it('clamps at one so being over a limit can still be drawn', () => {
32
+ expect(usageRatio(5, 10)).toBe(0.5)
33
+ expect(usageRatio(30, 10)).toBe(1)
34
+ })
35
+ })
36
+
37
+ describe('planBlockedReason', () => {
38
+ it('names the reason a smaller plan cannot be chosen, rather than returning false', () => {
39
+ expect(planBlockedReason({ limits: { seats: 3 } }, { seats: 5 })).toBe('seats')
40
+ expect(planBlockedReason({ limits: { seats: 10 } }, { seats: 5 })).toBeNull()
41
+ expect(planBlockedReason({ limits: { seats: null } }, { seats: 5000 })).toBeNull()
42
+ })
43
+ })
44
+
45
+ describe('isEntitled', () => {
46
+ it('keeps a past-due workspace working and stops a suspended one', () => {
47
+ expect(isEntitled('trialing')).toBe(true)
48
+ expect(isEntitled('active')).toBe(true)
49
+ expect(isEntitled('past_due')).toBe(true)
50
+ expect(isEntitled('suspended')).toBe(false)
51
+ expect(isEntitled('canceled')).toBe(false)
52
+ expect(isEntitled(null)).toBe(false)
53
+ })
54
+ })
55
+
56
+ describe('trialDaysLeft', () => {
57
+ const now = new Date('2026-08-23T12:00:00Z')
58
+ it('rounds up, floors at zero, and is null when no trial is running', () => {
59
+ expect(trialDaysLeft(null, now)).toBeNull()
60
+ expect(trialDaysLeft('2026-08-25T12:00:00Z', now)).toBe(2)
61
+ expect(trialDaysLeft('2026-08-25T13:00:00Z', now)).toBe(3)
62
+ expect(trialDaysLeft('2026-08-01T12:00:00Z', now)).toBe(0)
63
+ })
64
+ })
@@ -0,0 +1,81 @@
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.
5
+ */
6
+
7
+ export {
8
+ type AdminWorkspaceRow,
9
+ BILLING_PERMISSIONS,
10
+ type BillingInterval,
11
+ billingContract,
12
+ type Invoice,
13
+ MODULE_ID,
14
+ type Plan,
15
+ PlanLimits,
16
+ type PublicPlan,
17
+ type Subscription,
18
+ type SubscriptionStatus,
19
+ type Usage,
20
+ type WorkspaceBilling,
21
+ } from '../contract.js'
22
+ 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
+ }
@@ -0,0 +1,311 @@
1
+ import {
2
+ baseContract,
3
+ defineEvent,
4
+ definePermissions,
5
+ Id,
6
+ PageInput,
7
+ page,
8
+ WorkspaceId,
9
+ } from '@kernhq/contracts'
10
+ import { z } from 'zod'
11
+
12
+ export const MODULE_ID = 'billing'
13
+
14
+ /**
15
+ * The limits a plan may set.
16
+ *
17
+ * These keys mirror `Entitlement` in `@kernhq/kernel` exactly, and that is the point: the kernel
18
+ * declares what can be limited, this module decides the values, and every key has one place in core
19
+ * that enforces it. A plan can therefore be edited freely by an instance admin without ever being
20
+ * able to promise something nothing checks.
21
+ *
22
+ * `null` means unlimited, everywhere.
23
+ */
24
+ export const PlanLimits = z.object({
25
+ /** billable seats; members with the `guest` role never consume one */
26
+ seats: z.number().int().positive().nullable().default(null),
27
+ storageBytes: z.number().int().nonnegative().nullable().default(null),
28
+ /** module ids this plan allows to be switched on; `null` = every module the instance ships */
29
+ modules: z.array(z.string()).nullable().default(null),
30
+ sso: z.boolean().default(true),
31
+ auditRetentionDays: z.number().int().positive().nullable().default(null),
32
+ apiRateLimit: z.number().int().positive().nullable().default(null),
33
+ })
34
+ export type PlanLimits = z.infer<typeof PlanLimits>
35
+
36
+ /**
37
+ * A patch over `PlanLimits`, for comping one limit without touching the rest.
38
+ *
39
+ * Written out rather than derived with `.partial()`, because `.partial()` keeps the field defaults:
40
+ * parsing `{ seats: 50 }` through it returns every other key at its default too, so spreading the
41
+ * result silently resets limits the admin never mentioned. Absent here means absent.
42
+ */
43
+ export const PlanLimitsPatch = z.object({
44
+ seats: z.number().int().positive().nullable().optional(),
45
+ storageBytes: z.number().int().nonnegative().nullable().optional(),
46
+ modules: z.array(z.string()).nullable().optional(),
47
+ sso: z.boolean().optional(),
48
+ auditRetentionDays: z.number().int().positive().nullable().optional(),
49
+ apiRateLimit: z.number().int().positive().nullable().optional(),
50
+ })
51
+ export type PlanLimitsPatch = z.infer<typeof PlanLimitsPatch>
52
+
53
+ export const BillingInterval = z.enum(['month', 'year'])
54
+ export type BillingInterval = z.infer<typeof BillingInterval>
55
+
56
+ export const Plan = z.object({
57
+ id: Id,
58
+ /** stable identifier used in URLs and by the marketing site; never reused */
59
+ slug: z
60
+ .string()
61
+ .min(2)
62
+ .max(48)
63
+ .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/),
64
+ name: z.string().min(1).max(80),
65
+ description: z.string().max(300).default(''),
66
+ /** in the currency's smallest unit, so 800 is $8.00 — never a float */
67
+ priceMinor: z.number().int().nonnegative(),
68
+ currency: z.string().length(3).default('usd'),
69
+ interval: BillingInterval.default('month'),
70
+ /** true when `priceMinor` is charged per seat rather than per workspace */
71
+ perSeat: z.boolean().default(true),
72
+ trialDays: z.number().int().nonnegative().default(0),
73
+ limits: PlanLimits,
74
+ /** the price object in Stripe; null until the plan is wired to one */
75
+ stripePriceId: z.string().nullable().default(null),
76
+ /** what the marketing site lists under the plan */
77
+ highlights: z.array(z.string()).default([]),
78
+ published: z.boolean().default(false),
79
+ order: z.number().int().default(100),
80
+ createdAt: z.string(),
81
+ updatedAt: z.string(),
82
+ })
83
+ export type Plan = z.infer<typeof Plan>
84
+
85
+ /**
86
+ * What a stranger may see. Served unauthenticated so the marketing site can render prices that come
87
+ * from the same row the instance actually charges against — no internal ids, no Stripe ids, and
88
+ * published plans only.
89
+ */
90
+ export const PublicPlan = Plan.pick({
91
+ slug: true,
92
+ name: true,
93
+ description: true,
94
+ priceMinor: true,
95
+ currency: true,
96
+ interval: true,
97
+ perSeat: true,
98
+ trialDays: true,
99
+ highlights: true,
100
+ order: true,
101
+ }).extend({ limits: PlanLimits })
102
+ export type PublicPlan = z.infer<typeof PublicPlan>
103
+
104
+ export const UpsertPlan = Plan.omit({ id: true, createdAt: true, updatedAt: true }).extend({
105
+ id: Id.optional(),
106
+ })
107
+ export type UpsertPlan = z.infer<typeof UpsertPlan>
108
+
109
+ /**
110
+ * `trialing` and `active` are the two states that entitle a workspace to its plan.
111
+ * `past_due` still does — there is a grace period — and `suspended` does not.
112
+ * `canceled` keeps the row so the history and the Stripe ids survive a resubscribe.
113
+ */
114
+ export const SubscriptionStatus = z.enum(['trialing', 'active', 'past_due', 'canceled', 'suspended'])
115
+ export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>
116
+
117
+ export const Subscription = z.object({
118
+ workspaceId: WorkspaceId,
119
+ planId: Id.nullable(),
120
+ planName: z.string().nullable(),
121
+ planSlug: z.string().nullable(),
122
+ status: SubscriptionStatus,
123
+ seatsPurchased: z.number().int().nonnegative(),
124
+ trialEndsAt: z.string().nullable(),
125
+ currentPeriodEnd: z.string().nullable(),
126
+ cancelAtPeriodEnd: z.boolean(),
127
+ /** set once the workspace has a Stripe customer; null on a comped or manually managed workspace */
128
+ stripeCustomerId: z.string().nullable(),
129
+ stripeSubscriptionId: z.string().nullable(),
130
+ })
131
+ export type Subscription = z.infer<typeof Subscription>
132
+
133
+ export const Usage = z.object({
134
+ seats: z.number().int().nonnegative(),
135
+ storageBytes: z.number().int().nonnegative(),
136
+ updatedAt: z.string(),
137
+ })
138
+ export type Usage = z.infer<typeof Usage>
139
+
140
+ /** Everything the workspace's own billing screen needs, in one round trip. */
141
+ export const WorkspaceBilling = z.object({
142
+ subscription: Subscription.nullable(),
143
+ usage: Usage,
144
+ limits: PlanLimits,
145
+ /** false while past due or suspended */
146
+ active: z.boolean(),
147
+ /** whether this instance can take a payment at all — false when no Stripe key is configured */
148
+ paymentsEnabled: z.boolean(),
149
+ })
150
+ export type WorkspaceBilling = z.infer<typeof WorkspaceBilling>
151
+
152
+ export const Invoice = z.object({
153
+ id: Id,
154
+ workspaceId: WorkspaceId,
155
+ number: z.string().nullable(),
156
+ status: z.string(),
157
+ totalMinor: z.number().int(),
158
+ currency: z.string(),
159
+ periodStart: z.string().nullable(),
160
+ periodEnd: z.string().nullable(),
161
+ hostedUrl: z.string().nullable(),
162
+ pdfUrl: z.string().nullable(),
163
+ createdAt: z.string(),
164
+ })
165
+ export type Invoice = z.infer<typeof Invoice>
166
+
167
+ /** One row of the instance console's list of every workspace on the instance. */
168
+ export const AdminWorkspaceRow = z.object({
169
+ workspaceId: WorkspaceId,
170
+ workspaceName: z.string(),
171
+ workspaceSlug: z.string(),
172
+ planName: z.string().nullable(),
173
+ planSlug: z.string().nullable(),
174
+ status: SubscriptionStatus.nullable(),
175
+ seatsUsed: z.number().int().nonnegative(),
176
+ seatsPurchased: z.number().int().nonnegative(),
177
+ storageBytes: z.number().int().nonnegative(),
178
+ trialEndsAt: z.string().nullable(),
179
+ currentPeriodEnd: z.string().nullable(),
180
+ /** what this workspace bills per month, in minor units; annual plans are divided by twelve */
181
+ monthlyMinor: z.number().int().nonnegative(),
182
+ currency: z.string(),
183
+ /** true when an admin has overridden any limit by hand */
184
+ overridden: z.boolean(),
185
+ stripeCustomerId: z.string().nullable(),
186
+ })
187
+ export type AdminWorkspaceRow = z.infer<typeof AdminWorkspaceRow>
188
+
189
+ const ws = z.object({ workspaceId: WorkspaceId })
190
+
191
+ export const billingContract = {
192
+ plans: {
193
+ list: baseContract
194
+ .route({ method: 'GET', path: '/plans', tags: ['billing'] })
195
+ .input(z.object({ includeUnpublished: z.boolean().default(false) }))
196
+ .output(z.array(Plan)),
197
+ /**
198
+ * Unauthenticated on purpose: this is what kernaio.com and any other instance's marketing page
199
+ * reads, so that a price is edited in one place and true in both.
200
+ */
201
+ public: baseContract
202
+ .route({ method: 'GET', path: '/plans/public', tags: ['billing'] })
203
+ .input(z.object({}))
204
+ .output(z.array(PublicPlan)),
205
+ upsert: baseContract
206
+ .route({ method: 'POST', path: '/plans', tags: ['billing'] })
207
+ .input(UpsertPlan)
208
+ .output(Plan),
209
+ setPublished: baseContract
210
+ .route({ method: 'POST', path: '/plans/{id}/published', tags: ['billing'] })
211
+ .input(z.object({ id: Id, published: z.boolean() }))
212
+ .output(Plan),
213
+ archive: baseContract
214
+ .route({ method: 'DELETE', path: '/plans/{id}', tags: ['billing'] })
215
+ .input(z.object({ id: Id }))
216
+ .output(z.object({ ok: z.literal(true) })),
217
+ },
218
+ subscription: {
219
+ get: baseContract
220
+ .route({ method: 'GET', path: '/subscription', tags: ['billing'] })
221
+ .input(ws)
222
+ .output(WorkspaceBilling),
223
+ invoices: baseContract
224
+ .route({ method: 'GET', path: '/subscription/invoices', tags: ['billing'] })
225
+ .input(ws.extend(PageInput.shape))
226
+ .output(page(Invoice)),
227
+ /** Stripe Checkout for a new subscription or a plan change; returns a URL to send the user to. */
228
+ checkout: baseContract
229
+ .route({ method: 'POST', path: '/subscription/checkout', tags: ['billing'] })
230
+ .input(ws.extend({ planSlug: z.string(), seats: z.number().int().positive().optional() }))
231
+ .output(z.object({ url: z.string() })),
232
+ /** Stripe's own billing portal: payment method, cancellation, invoice download. */
233
+ portal: baseContract
234
+ .route({ method: 'POST', path: '/subscription/portal', tags: ['billing'] })
235
+ .input(ws.extend({ returnPath: z.string().default('/') }))
236
+ .output(z.object({ url: z.string() })),
237
+ },
238
+ admin: {
239
+ workspaces: baseContract
240
+ .route({ method: 'GET', path: '/admin/workspaces', tags: ['billing'] })
241
+ .input(
242
+ z.object({ q: z.string().optional(), status: SubscriptionStatus.optional() }).extend(PageInput.shape),
243
+ )
244
+ .output(page(AdminWorkspaceRow)),
245
+ setPlan: baseContract
246
+ .route({ method: 'POST', path: '/admin/workspaces/{workspaceId}/plan', tags: ['billing'] })
247
+ .input(ws.extend({ planId: Id.nullable(), seatsPurchased: z.number().int().nonnegative().optional() }))
248
+ .output(Subscription),
249
+ /**
250
+ * Comp an account without inventing a plan for it. `null` clears the override and the plan's own
251
+ * limit applies again.
252
+ */
253
+ override: baseContract
254
+ .route({ method: 'POST', path: '/admin/workspaces/{workspaceId}/override', tags: ['billing'] })
255
+ .input(ws.extend({ limits: PlanLimitsPatch.nullable() }))
256
+ .output(Subscription),
257
+ extendTrial: baseContract
258
+ .route({ method: 'POST', path: '/admin/workspaces/{workspaceId}/trial', tags: ['billing'] })
259
+ .input(ws.extend({ days: z.number().int().positive().max(365) }))
260
+ .output(Subscription),
261
+ setStatus: baseContract
262
+ .route({ method: 'POST', path: '/admin/workspaces/{workspaceId}/status', tags: ['billing'] })
263
+ .input(ws.extend({ status: z.enum(['active', 'suspended']) }))
264
+ .output(Subscription),
265
+ },
266
+ }
267
+ export type BillingContract = typeof billingContract
268
+
269
+ export const billingEvents = {
270
+ /**
271
+ * Emitted whenever anything changes what a workspace is allowed to do. The kernel listens on
272
+ * `billing.subscription.*` and drops its entitlement cache, so a customer who has just paid does
273
+ * not wait out a TTL for the seat they bought.
274
+ */
275
+ subscriptionChanged: defineEvent(
276
+ 'billing.subscription.changed',
277
+ z.object({
278
+ workspaceId: WorkspaceId,
279
+ status: SubscriptionStatus,
280
+ planSlug: z.string().nullable(),
281
+ }),
282
+ ),
283
+ subscriptionSuspended: defineEvent(
284
+ 'billing.subscription.suspended',
285
+ z.object({ workspaceId: WorkspaceId, reason: z.string() }),
286
+ ),
287
+ /** A plan was published or unpublished — what the marketing site shows has changed. */
288
+ catalogueChanged: defineEvent('billing.catalogue.changed', z.object({ planSlug: z.string() })),
289
+ }
290
+
291
+ export const BILLING_PERMISSIONS = {
292
+ view: 'billing.subscription.view',
293
+ manage: 'billing.subscription.manage',
294
+ } as const
295
+
296
+ export const billingPermissions = definePermissions([
297
+ {
298
+ key: BILLING_PERMISSIONS.view,
299
+ label: 'View the plan and what it costs',
300
+ scope: 'workspace',
301
+ defaultRoles: ['owner', 'admin'],
302
+ dangerous: false,
303
+ },
304
+ {
305
+ key: BILLING_PERMISSIONS.manage,
306
+ label: 'Change the plan and the payment method',
307
+ scope: 'workspace',
308
+ defaultRoles: ['owner'],
309
+ dangerous: true,
310
+ },
311
+ ])