@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.
- package/LICENSE +662 -0
- package/dist/contract.d.ts +965 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +265 -0
- package/dist/contract.js.map +1 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +165 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/router.d.ts +697 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +126 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +1030 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +137 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/services/entitlements.d.ts +3 -0
- package/dist/server/services/entitlements.d.ts.map +1 -0
- package/dist/server/services/entitlements.js +68 -0
- package/dist/server/services/entitlements.js.map +1 -0
- package/dist/server/services/plans.d.ts +20 -0
- package/dist/server/services/plans.d.ts.map +1 -0
- package/dist/server/services/plans.js +122 -0
- package/dist/server/services/plans.js.map +1 -0
- package/dist/server/services/stripe.d.ts +42 -0
- package/dist/server/services/stripe.d.ts.map +1 -0
- package/dist/server/services/stripe.js +267 -0
- package/dist/server/services/stripe.js.map +1 -0
- package/dist/server/services/subscriptions.d.ts +35 -0
- package/dist/server/services/subscriptions.d.ts.map +1 -0
- package/dist/server/services/subscriptions.js +193 -0
- package/dist/server/services/subscriptions.js.map +1 -0
- package/dist/server/services/usage.d.ts +40 -0
- package/dist/server/services/usage.d.ts.map +1 -0
- package/dist/server/services/usage.js +98 -0
- package/dist/server/services/usage.js.map +1 -0
- package/migrations/0000_init.sql +82 -0
- package/migrations/0001_rls.sql +18 -0
- package/migrations/meta/0000_snapshot.json +580 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +73 -0
- package/src/client/api.ts +15 -0
- package/src/client/format.test.ts +64 -0
- package/src/client/index.ts +81 -0
- 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
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -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
|
+
])
|