@delightstack/stripe 0.1.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 +21 -0
- package/README.md +30 -0
- package/dist/client/billing.client.svelte.d.ts +154 -0
- package/dist/client/billing.client.svelte.d.ts.map +1 -0
- package/dist/client/billing.client.svelte.js +279 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/server/billing.config.d.ts +189 -0
- package/dist/server/billing.config.d.ts.map +1 -0
- package/dist/server/billing.config.js +37 -0
- package/dist/server/billing.handler.d.ts +46 -0
- package/dist/server/billing.handler.d.ts.map +1 -0
- package/dist/server/billing.handler.js +73 -0
- package/dist/server/billing.meter.d.ts +41 -0
- package/dist/server/billing.meter.d.ts.map +1 -0
- package/dist/server/billing.meter.js +55 -0
- package/dist/server/billing.products.d.ts +24 -0
- package/dist/server/billing.products.d.ts.map +1 -0
- package/dist/server/billing.products.js +124 -0
- package/dist/server/billing.routes.d.ts +8 -0
- package/dist/server/billing.routes.d.ts.map +1 -0
- package/dist/server/billing.routes.js +441 -0
- package/dist/server/billing.stripe.d.ts +34 -0
- package/dist/server/billing.stripe.d.ts.map +1 -0
- package/dist/server/billing.stripe.js +135 -0
- package/dist/server/billing.sync.d.ts +29 -0
- package/dist/server/billing.sync.d.ts.map +1 -0
- package/dist/server/billing.sync.js +122 -0
- package/dist/server/billing.webhook.d.ts +10 -0
- package/dist/server/billing.webhook.d.ts.map +1 -0
- package/dist/server/billing.webhook.js +222 -0
- package/dist/server/billing.webhook.register.d.ts +12 -0
- package/dist/server/billing.webhook.register.d.ts.map +1 -0
- package/dist/server/billing.webhook.register.js +57 -0
- package/dist/server/index.d.ts +9 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +8 -0
- package/dist/sveltekit/guards.d.ts +29 -0
- package/dist/sveltekit/guards.d.ts.map +1 -0
- package/dist/sveltekit/guards.js +57 -0
- package/dist/sveltekit/index.d.ts +2 -0
- package/dist/sveltekit/index.d.ts.map +1 -0
- package/dist/sveltekit/index.js +1 -0
- package/dist/types/billing.type.d.ts +61 -0
- package/dist/types/billing.type.d.ts.map +1 -0
- package/dist/types/billing.type.js +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/webhook.type.d.ts +22 -0
- package/dist/types/webhook.type.d.ts.map +1 -0
- package/dist/types/webhook.type.js +2 -0
- package/package.json +91 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { DelightError } from '@delightstack/utilities';
|
|
2
|
+
import { getStripe, stripeCall, formatPaymentMethod, formatInvoice, parseBody, getAppUrl, resolveReturnUrl, } from './billing.stripe';
|
|
3
|
+
import { syncSubscription, fetchSubscriptionState, activePlanIds } from './billing.sync';
|
|
4
|
+
function jsonResponse(data, status = 200) {
|
|
5
|
+
return new Response(JSON.stringify(data), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/** Get org_id from locals when billing_scope is 'org' */
|
|
11
|
+
function getOrgId(event, config) {
|
|
12
|
+
if (config.billing_scope !== 'org')
|
|
13
|
+
return undefined;
|
|
14
|
+
const locals = event.locals;
|
|
15
|
+
const org_id = locals.org_id ?? undefined;
|
|
16
|
+
return org_id;
|
|
17
|
+
}
|
|
18
|
+
/** Get user_id from locals when billing_scope is 'user' */
|
|
19
|
+
function getUserId(event) {
|
|
20
|
+
const locals = event.locals;
|
|
21
|
+
const user = locals.user;
|
|
22
|
+
return user?.id ?? undefined;
|
|
23
|
+
}
|
|
24
|
+
/** Get the org_state cookie writer from locals (org scope only) */
|
|
25
|
+
function getSetOrgState(event, config) {
|
|
26
|
+
if (config.billing_scope !== 'org')
|
|
27
|
+
return undefined;
|
|
28
|
+
const locals = event.locals;
|
|
29
|
+
return locals.setOrgState;
|
|
30
|
+
}
|
|
31
|
+
/** Resolve customer_id from org_state or user metadata */
|
|
32
|
+
function resolveCustomerId(event, config) {
|
|
33
|
+
const locals = event.locals;
|
|
34
|
+
if (config.billing_scope === 'org') {
|
|
35
|
+
const org_state = locals.org_state;
|
|
36
|
+
if (org_state?.customer_id)
|
|
37
|
+
return org_state.customer_id;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
/** Resolve customer_id, checking org_state then Stripe (by metadata search) */
|
|
42
|
+
async function resolveCustomerIdAsync(event, config) {
|
|
43
|
+
// First check cached customer_id
|
|
44
|
+
const cached = resolveCustomerId(event, config);
|
|
45
|
+
if (cached)
|
|
46
|
+
return cached;
|
|
47
|
+
// Search Stripe by org_id or user_id metadata
|
|
48
|
+
const stripe = getStripe(config);
|
|
49
|
+
const org_id = getOrgId(event, config);
|
|
50
|
+
const user_id = getUserId(event);
|
|
51
|
+
const search_key = config.billing_scope === 'org' ? 'org_id' : 'user_id';
|
|
52
|
+
const search_value = config.billing_scope === 'org' ? org_id : user_id;
|
|
53
|
+
if (!search_value)
|
|
54
|
+
return null;
|
|
55
|
+
// Guard against Stripe search query injection/breakage — ids from the auth
|
|
56
|
+
// system should never contain quotes or backslashes
|
|
57
|
+
if (/['"\\]/.test(search_value)) {
|
|
58
|
+
throw DelightError.badRequest('Invalid billing identifier');
|
|
59
|
+
}
|
|
60
|
+
const customers = await stripeCall(() => stripe.customers.search({
|
|
61
|
+
query: `metadata['${search_key}']:'${search_value}'`,
|
|
62
|
+
limit: 1,
|
|
63
|
+
}));
|
|
64
|
+
const customer = customers.data[0];
|
|
65
|
+
if (!customer)
|
|
66
|
+
return null;
|
|
67
|
+
// Cache in org_state for future requests
|
|
68
|
+
cacheCustomerId(event, config, customer.id);
|
|
69
|
+
return customer.id;
|
|
70
|
+
}
|
|
71
|
+
/** Create a Stripe customer and cache the customer_id */
|
|
72
|
+
async function ensureCustomer(event, config, ctx) {
|
|
73
|
+
const existing = await resolveCustomerIdAsync(event, config);
|
|
74
|
+
if (existing)
|
|
75
|
+
return existing;
|
|
76
|
+
const stripe = getStripe(config);
|
|
77
|
+
const locals = event.locals;
|
|
78
|
+
const user = locals.user;
|
|
79
|
+
const org = locals.org;
|
|
80
|
+
const org_id = getOrgId(event, config);
|
|
81
|
+
const user_id = getUserId(event);
|
|
82
|
+
const metadata = {};
|
|
83
|
+
if (org_id)
|
|
84
|
+
metadata.org_id = org_id;
|
|
85
|
+
if (user_id)
|
|
86
|
+
metadata.user_id = user_id;
|
|
87
|
+
const customer = await stripeCall(() => stripe.customers.create({
|
|
88
|
+
email: user?.email,
|
|
89
|
+
name: config.billing_scope === 'org' ? org?.name : user?.name,
|
|
90
|
+
metadata,
|
|
91
|
+
}));
|
|
92
|
+
// Cache the customer_id
|
|
93
|
+
cacheCustomerId(event, config, customer.id);
|
|
94
|
+
// Store customer_id in auth org metadata (read-modify-write — never
|
|
95
|
+
// overwrite existing org JSON metadata from other features)
|
|
96
|
+
if (org_id && ctx.getAuthServer) {
|
|
97
|
+
const auth = ctx.getAuthServer(event);
|
|
98
|
+
if (auth) {
|
|
99
|
+
try {
|
|
100
|
+
const existing_record = auth.getOrg ? await auth.getOrg(org_id) : org;
|
|
101
|
+
let existing_json = {};
|
|
102
|
+
if (existing_record?.json) {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(existing_record.json);
|
|
105
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
106
|
+
existing_json = parsed;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Unparseable existing json — keep it from being silently
|
|
111
|
+
// destroyed by skipping the write entirely
|
|
112
|
+
throw new Error('Existing org json is not valid JSON');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await auth.updateOrg(org_id, {
|
|
116
|
+
json: JSON.stringify({ ...existing_json, customer_id: customer.id }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Non-critical — customer_id is also discoverable via Stripe search
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (config.hooks?.onCustomerCreated) {
|
|
125
|
+
await config.hooks.onCustomerCreated({
|
|
126
|
+
customer_id: customer.id,
|
|
127
|
+
org_id,
|
|
128
|
+
user_id,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return customer.id;
|
|
132
|
+
}
|
|
133
|
+
/** Cache customer_id in org_state cookie */
|
|
134
|
+
function cacheCustomerId(event, config, customer_id) {
|
|
135
|
+
if (config.billing_scope !== 'org')
|
|
136
|
+
return;
|
|
137
|
+
const locals = event.locals;
|
|
138
|
+
const setOrgState = locals.setOrgState;
|
|
139
|
+
if (setOrgState) {
|
|
140
|
+
setOrgState({ customer_id });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export async function handleBillingRoute(event, config, route_path, method, ctx) {
|
|
144
|
+
const stripe = getStripe(config);
|
|
145
|
+
// Handle parameterized routes (e.g., /payment-method/:id)
|
|
146
|
+
const payment_method_match = route_path.match(/^\/payment-method\/(.+)$/);
|
|
147
|
+
if (payment_method_match) {
|
|
148
|
+
const pm_id = payment_method_match[1];
|
|
149
|
+
if (method === 'DELETE' || method === 'PATCH') {
|
|
150
|
+
const customer_id = await resolveCustomerIdAsync(event, config);
|
|
151
|
+
if (!customer_id)
|
|
152
|
+
throw DelightError.badRequest('No customer found');
|
|
153
|
+
// Verify the payment method belongs to the caller's customer before
|
|
154
|
+
// acting on it — otherwise any authed user could detach others' cards
|
|
155
|
+
const pm = await stripeCall(() => stripe.paymentMethods.retrieve(pm_id));
|
|
156
|
+
const pm_customer = typeof pm.customer === 'string' ? pm.customer : pm.customer?.id;
|
|
157
|
+
if (pm_customer !== customer_id) {
|
|
158
|
+
throw DelightError.notFound('Payment method not found');
|
|
159
|
+
}
|
|
160
|
+
if (method === 'DELETE') {
|
|
161
|
+
await stripeCall(() => stripe.paymentMethods.detach(pm_id));
|
|
162
|
+
return new Response(null, { status: 204 });
|
|
163
|
+
}
|
|
164
|
+
await stripeCall(() => stripe.customers.update(customer_id, {
|
|
165
|
+
invoice_settings: { default_payment_method: pm_id },
|
|
166
|
+
}));
|
|
167
|
+
return jsonResponse({ ok: true });
|
|
168
|
+
}
|
|
169
|
+
throw DelightError.notFound('Route not found');
|
|
170
|
+
}
|
|
171
|
+
switch (`${method} ${route_path}`) {
|
|
172
|
+
// ── Subscription ───────────────────────────────────────────────
|
|
173
|
+
case 'GET /subscription': {
|
|
174
|
+
const customer_id = await resolveCustomerIdAsync(event, config);
|
|
175
|
+
if (!customer_id)
|
|
176
|
+
return jsonResponse({ subscription: null });
|
|
177
|
+
// Lightweight read — no entitlement writes or broadcasts here.
|
|
178
|
+
// Use POST /sync for a full sync.
|
|
179
|
+
const state = await fetchSubscriptionState(config, customer_id);
|
|
180
|
+
// Still cache active plan ids so requirePlan() guards stay fresh
|
|
181
|
+
getSetOrgState(event, config)?.({ billing_plan_ids: activePlanIds(state) });
|
|
182
|
+
return jsonResponse({ subscription: state });
|
|
183
|
+
}
|
|
184
|
+
case 'POST /subscription': {
|
|
185
|
+
const body = await parseBody(event.request);
|
|
186
|
+
const plan_id = body.plan_id;
|
|
187
|
+
const payment_method_id = body.payment_method_id;
|
|
188
|
+
const coupon = body.coupon;
|
|
189
|
+
if (!plan_id)
|
|
190
|
+
throw DelightError.badRequest('plan_id is required');
|
|
191
|
+
const plan = config.plans?.find((p) => p.id === plan_id);
|
|
192
|
+
if (!plan)
|
|
193
|
+
throw DelightError.badRequest(`Unknown plan: ${plan_id}`);
|
|
194
|
+
const customer_id = await ensureCustomer(event, config, ctx);
|
|
195
|
+
// Look up the price by lookup_key
|
|
196
|
+
const prices = await stripeCall(() => stripe.prices.list({
|
|
197
|
+
lookup_keys: [plan.lookup_key],
|
|
198
|
+
limit: 1,
|
|
199
|
+
}));
|
|
200
|
+
const price = prices.data[0];
|
|
201
|
+
if (!price)
|
|
202
|
+
throw DelightError.badRequest(`Price not found for plan: ${plan_id}`);
|
|
203
|
+
// Check for existing active subscription
|
|
204
|
+
const subs = await stripeCall(() => stripe.subscriptions.list({
|
|
205
|
+
customer: customer_id,
|
|
206
|
+
status: 'active',
|
|
207
|
+
limit: 1,
|
|
208
|
+
}));
|
|
209
|
+
if (subs.data.length > 0) {
|
|
210
|
+
// Update existing subscription
|
|
211
|
+
const existing = subs.data[0];
|
|
212
|
+
await stripeCall(() => stripe.subscriptions.update(existing.id, {
|
|
213
|
+
items: [
|
|
214
|
+
...existing.items.data.map((item) => ({
|
|
215
|
+
id: item.id,
|
|
216
|
+
deleted: true,
|
|
217
|
+
})),
|
|
218
|
+
{ price: price.id },
|
|
219
|
+
],
|
|
220
|
+
...(payment_method_id ? { default_payment_method: payment_method_id } : {}),
|
|
221
|
+
proration_behavior: 'create_prorations',
|
|
222
|
+
...(coupon ? { coupon } : {}),
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
// Create new subscription
|
|
227
|
+
await stripeCall(() => stripe.subscriptions.create({
|
|
228
|
+
customer: customer_id,
|
|
229
|
+
items: [{ price: price.id }],
|
|
230
|
+
...(payment_method_id ? { default_payment_method: payment_method_id } : {}),
|
|
231
|
+
...(plan.trial_days ? { trial_period_days: plan.trial_days } : {}),
|
|
232
|
+
...(coupon ? { coupon } : {}),
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
// Sync and return updated state
|
|
236
|
+
const state = await syncSubscription({
|
|
237
|
+
config,
|
|
238
|
+
customer_id,
|
|
239
|
+
org_id: getOrgId(event, config),
|
|
240
|
+
user_id: getUserId(event),
|
|
241
|
+
auth: ctx.getAuthServer?.(event),
|
|
242
|
+
ws: ctx.getWebsocket?.(event),
|
|
243
|
+
setOrgState: getSetOrgState(event, config),
|
|
244
|
+
});
|
|
245
|
+
if (config.hooks?.onSubscriptionChange && state) {
|
|
246
|
+
await config.hooks.onSubscriptionChange({
|
|
247
|
+
customer_id,
|
|
248
|
+
subscription_id: state.subscription_id,
|
|
249
|
+
status: state.status,
|
|
250
|
+
plan_id: state.plan_ids[0] ?? null,
|
|
251
|
+
entitlements: state.entitlements,
|
|
252
|
+
event,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return jsonResponse({ subscription: state });
|
|
256
|
+
}
|
|
257
|
+
case 'DELETE /subscription': {
|
|
258
|
+
const customer_id = await resolveCustomerIdAsync(event, config);
|
|
259
|
+
if (!customer_id)
|
|
260
|
+
throw DelightError.badRequest('No customer found');
|
|
261
|
+
// Optional body: { cancel_at_period_end?: boolean }
|
|
262
|
+
const body = await parseBody(event.request).catch(() => ({}));
|
|
263
|
+
const cancel_at_period_end = body.cancel_at_period_end === true;
|
|
264
|
+
// Default list excludes canceled — include trialing/past_due, not just active
|
|
265
|
+
const subs = await stripeCall(() => stripe.subscriptions.list({
|
|
266
|
+
customer: customer_id,
|
|
267
|
+
limit: 100,
|
|
268
|
+
}));
|
|
269
|
+
const cancellable = subs.data.find((s) => ['active', 'trialing', 'past_due', 'unpaid', 'incomplete'].includes(s.status));
|
|
270
|
+
if (cancellable) {
|
|
271
|
+
if (cancel_at_period_end) {
|
|
272
|
+
await stripeCall(() => stripe.subscriptions.update(cancellable.id, {
|
|
273
|
+
cancel_at_period_end: true,
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
await stripeCall(() => stripe.subscriptions.cancel(cancellable.id, {
|
|
278
|
+
invoice_now: true,
|
|
279
|
+
prorate: true,
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Sync to clear entitlements
|
|
284
|
+
const state = await syncSubscription({
|
|
285
|
+
config,
|
|
286
|
+
customer_id,
|
|
287
|
+
org_id: getOrgId(event, config),
|
|
288
|
+
user_id: getUserId(event),
|
|
289
|
+
auth: ctx.getAuthServer?.(event),
|
|
290
|
+
ws: ctx.getWebsocket?.(event),
|
|
291
|
+
setOrgState: getSetOrgState(event, config),
|
|
292
|
+
});
|
|
293
|
+
// Fire the lifecycle hook for cancellations too — when nothing remains,
|
|
294
|
+
// report the canceled subscription explicitly
|
|
295
|
+
if (config.hooks?.onSubscriptionChange && cancellable) {
|
|
296
|
+
await config.hooks.onSubscriptionChange({
|
|
297
|
+
customer_id,
|
|
298
|
+
subscription_id: state?.subscription_id ?? cancellable.id,
|
|
299
|
+
status: state?.status ?? 'canceled',
|
|
300
|
+
plan_id: state?.plan_ids[0] ?? null,
|
|
301
|
+
entitlements: state?.entitlements ?? [],
|
|
302
|
+
event,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return new Response(null, { status: 204 });
|
|
306
|
+
}
|
|
307
|
+
// ── Invoices ───────────────────────────────────────────────────
|
|
308
|
+
case 'GET /invoice': {
|
|
309
|
+
const customer_id = await resolveCustomerIdAsync(event, config);
|
|
310
|
+
if (!customer_id)
|
|
311
|
+
return jsonResponse({ invoices: [] });
|
|
312
|
+
const limit = parseInt(event.url.searchParams.get('limit') ?? '10', 10);
|
|
313
|
+
const invoices = await stripeCall(() => stripe.invoices.list({
|
|
314
|
+
customer: customer_id,
|
|
315
|
+
limit: Math.min(limit, 100),
|
|
316
|
+
}));
|
|
317
|
+
return jsonResponse({
|
|
318
|
+
invoices: invoices.data.map(formatInvoice),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// ── Payment Methods ────────────────────────────────────────────
|
|
322
|
+
case 'GET /payment-method': {
|
|
323
|
+
const customer_id = await resolveCustomerIdAsync(event, config);
|
|
324
|
+
if (!customer_id)
|
|
325
|
+
return jsonResponse({ payment_methods: [] });
|
|
326
|
+
const methods = await stripeCall(() => stripe.paymentMethods.list({ customer: customer_id }));
|
|
327
|
+
const customer = (await stripeCall(() => stripe.customers.retrieve(customer_id)));
|
|
328
|
+
const default_pm = customer.invoice_settings?.default_payment_method;
|
|
329
|
+
return jsonResponse({
|
|
330
|
+
payment_methods: methods.data.map((m) => formatPaymentMethod(m, default_pm)),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
case 'POST /payment-method': {
|
|
334
|
+
const customer_id = await ensureCustomer(event, config, ctx);
|
|
335
|
+
const session = await stripeCall(() => stripe.checkout.sessions.create({
|
|
336
|
+
mode: 'setup',
|
|
337
|
+
customer: customer_id,
|
|
338
|
+
currency: 'usd',
|
|
339
|
+
ui_mode: 'embedded',
|
|
340
|
+
return_url: `${getAppUrl(event, config)}/billing/complete?session_id={CHECKOUT_SESSION_ID}`,
|
|
341
|
+
}));
|
|
342
|
+
return jsonResponse({ client_secret: session.client_secret }, 201);
|
|
343
|
+
}
|
|
344
|
+
// ── Billing Portal ─────────────────────────────────────────────
|
|
345
|
+
case 'POST /portal': {
|
|
346
|
+
if (!config.portal.enabled) {
|
|
347
|
+
throw DelightError.badRequest('Billing Portal is not enabled');
|
|
348
|
+
}
|
|
349
|
+
const customer_id = await resolveCustomerIdAsync(event, config);
|
|
350
|
+
if (!customer_id)
|
|
351
|
+
throw DelightError.badRequest('No customer found');
|
|
352
|
+
const body = await parseBody(event.request).catch(() => ({}));
|
|
353
|
+
// Validate the user-provided return_url (open-redirect protection)
|
|
354
|
+
const return_url = resolveReturnUrl(event, config, body.return_url) ?? getAppUrl(event, config);
|
|
355
|
+
const session = await stripeCall(() => stripe.billingPortal.sessions.create({
|
|
356
|
+
customer: customer_id,
|
|
357
|
+
return_url,
|
|
358
|
+
}));
|
|
359
|
+
return jsonResponse({ url: session.url });
|
|
360
|
+
}
|
|
361
|
+
// ── Checkout ───────────────────────────────────────────────────
|
|
362
|
+
case 'POST /checkout': {
|
|
363
|
+
const body = await parseBody(event.request);
|
|
364
|
+
const plan_id = body.plan_id;
|
|
365
|
+
if (!plan_id)
|
|
366
|
+
throw DelightError.badRequest('plan_id is required');
|
|
367
|
+
const plan = config.plans?.find((p) => p.id === plan_id);
|
|
368
|
+
if (!plan)
|
|
369
|
+
throw DelightError.badRequest(`Unknown plan: ${plan_id}`);
|
|
370
|
+
// Validate the user-provided return_url (open-redirect protection)
|
|
371
|
+
// before making any Stripe calls
|
|
372
|
+
const return_url = resolveReturnUrl(event, config, body.return_url) ??
|
|
373
|
+
`${getAppUrl(event, config)}/billing/complete?session_id={CHECKOUT_SESSION_ID}`;
|
|
374
|
+
const customer_id = await ensureCustomer(event, config, ctx);
|
|
375
|
+
const prices = await stripeCall(() => stripe.prices.list({
|
|
376
|
+
lookup_keys: [plan.lookup_key],
|
|
377
|
+
limit: 1,
|
|
378
|
+
}));
|
|
379
|
+
const price = prices.data[0];
|
|
380
|
+
if (!price)
|
|
381
|
+
throw DelightError.badRequest(`Price not found for plan: ${plan_id}`);
|
|
382
|
+
const session = await stripeCall(() => stripe.checkout.sessions.create({
|
|
383
|
+
mode: 'subscription',
|
|
384
|
+
customer: customer_id,
|
|
385
|
+
line_items: [{ price: price.id, quantity: 1 }],
|
|
386
|
+
ui_mode: 'embedded',
|
|
387
|
+
return_url,
|
|
388
|
+
...(plan.trial_days
|
|
389
|
+
? {
|
|
390
|
+
subscription_data: {
|
|
391
|
+
trial_period_days: plan.trial_days,
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
: {}),
|
|
395
|
+
}));
|
|
396
|
+
return jsonResponse({ client_secret: session.client_secret });
|
|
397
|
+
}
|
|
398
|
+
// ── Plans (public info) ────────────────────────────────────────
|
|
399
|
+
case 'GET /plan': {
|
|
400
|
+
const plans = (config.plans ?? [])
|
|
401
|
+
.filter((p) => !p.archived)
|
|
402
|
+
.map((p) => ({
|
|
403
|
+
id: p.id,
|
|
404
|
+
name: p.name,
|
|
405
|
+
description: p.description,
|
|
406
|
+
amount: p.amount,
|
|
407
|
+
currency: p.currency ?? 'usd',
|
|
408
|
+
interval: p.interval,
|
|
409
|
+
interval_count: p.interval_count ?? 1,
|
|
410
|
+
entitlements: p.entitlements ?? [],
|
|
411
|
+
trial_days: p.trial_days,
|
|
412
|
+
}));
|
|
413
|
+
return jsonResponse({ plans });
|
|
414
|
+
}
|
|
415
|
+
// ── Config (client-safe) ───────────────────────────────────────
|
|
416
|
+
case 'GET /config': {
|
|
417
|
+
return jsonResponse({
|
|
418
|
+
publishable_key: config.publishable_key,
|
|
419
|
+
portal_enabled: config.portal.enabled,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
// ── Force sync ─────────────────────────────────────────────────
|
|
423
|
+
case 'POST /sync': {
|
|
424
|
+
const customer_id = await resolveCustomerIdAsync(event, config);
|
|
425
|
+
if (!customer_id)
|
|
426
|
+
return jsonResponse({ subscription: null });
|
|
427
|
+
const state = await syncSubscription({
|
|
428
|
+
config,
|
|
429
|
+
customer_id,
|
|
430
|
+
org_id: getOrgId(event, config),
|
|
431
|
+
user_id: getUserId(event),
|
|
432
|
+
auth: ctx.getAuthServer?.(event),
|
|
433
|
+
ws: ctx.getWebsocket?.(event),
|
|
434
|
+
setOrgState: getSetOrgState(event, config),
|
|
435
|
+
});
|
|
436
|
+
return jsonResponse({ subscription: state });
|
|
437
|
+
}
|
|
438
|
+
default:
|
|
439
|
+
throw DelightError.notFound('Route not found');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import type { ResolvedBillingConfig } from './billing.config';
|
|
3
|
+
import type { PaymentMethodInfo, InvoiceInfo } from '../types';
|
|
4
|
+
/** Get or create a Stripe instance */
|
|
5
|
+
export declare function getStripe(config: ResolvedBillingConfig): Stripe;
|
|
6
|
+
/** Wraps a Stripe API call with consistent error handling */
|
|
7
|
+
export declare function stripeCall<T>(fn: () => Promise<T>): Promise<T>;
|
|
8
|
+
/** Format a Stripe PaymentMethod into a client-safe shape */
|
|
9
|
+
export declare function formatPaymentMethod(method: Stripe.PaymentMethod, default_pm: string | Stripe.PaymentMethod | null | undefined): PaymentMethodInfo;
|
|
10
|
+
/**
|
|
11
|
+
* Format a Stripe Invoice into a client-safe shape.
|
|
12
|
+
* All amounts are integers in the smallest currency unit (cents) — never floats.
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatInvoice(invoice: Stripe.Invoice): InvoiceInfo;
|
|
15
|
+
/** Parse JSON body from a request, returning {} on failure */
|
|
16
|
+
export declare function parseBody(request: Request): Promise<Record<string, unknown>>;
|
|
17
|
+
/** Get the app URL from config or request origin */
|
|
18
|
+
export declare function getAppUrl(event: {
|
|
19
|
+
url: URL;
|
|
20
|
+
}, config: ResolvedBillingConfig): string;
|
|
21
|
+
/**
|
|
22
|
+
* Validates a client-provided return_url to prevent open-redirect/phishing.
|
|
23
|
+
*
|
|
24
|
+
* - `undefined`/non-string → returns `undefined` (caller falls back to a default)
|
|
25
|
+
* - Relative paths are resolved against the app URL
|
|
26
|
+
* - Absolute URLs must be http(s) and same-origin with the app URL, the
|
|
27
|
+
* request origin, or one of `config.allowed_return_origins`
|
|
28
|
+
*
|
|
29
|
+
* @throws DelightError 400 when the URL is malformed or not an allowed origin
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveReturnUrl(event: {
|
|
32
|
+
url: URL;
|
|
33
|
+
}, config: ResolvedBillingConfig, raw: unknown): string | undefined;
|
|
34
|
+
//# sourceMappingURL=billing.stripe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"billing.stripe.d.ts","sourceRoot":"","sources":["../../src/server/billing.stripe.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,QAAQ,CAAC;AAE5B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAK/D,sCAAsC;AACtC,wBAAgB,SAAS,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAa/D;AAED,6DAA6D;AAC7D,wBAAsB,UAAU,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAcpE;AAED,6DAA6D;AAC7D,wBAAgB,mBAAmB,CAClC,MAAM,EAAE,MAAM,CAAC,aAAa,EAC5B,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC,aAAa,GAAG,IAAI,GAAG,SAAS,GAC1D,iBAAiB,CAqBnB;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,CAAC,OAAO,GAAG,WAAW,CAyBlE;AAED,8DAA8D;AAC9D,wBAAsB,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAMlF;AAED,oDAAoD;AACpD,wBAAgB,SAAS,CAAC,KAAK,EAAE;IAAE,GAAG,EAAE,GAAG,CAAA;CAAE,EAAE,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAEpF;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC/B,KAAK,EAAE;IAAE,GAAG,EAAE,GAAG,CAAA;CAAE,EACnB,MAAM,EAAE,qBAAqB,EAC7B,GAAG,EAAE,OAAO,GACV,MAAM,GAAG,SAAS,CA+BpB"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import Stripe from 'stripe';
|
|
2
|
+
import { DelightError } from '@delightstack/utilities';
|
|
3
|
+
/** Cached Stripe instance per secret key */
|
|
4
|
+
const stripe_instances = new Map();
|
|
5
|
+
/** Get or create a Stripe instance */
|
|
6
|
+
export function getStripe(config) {
|
|
7
|
+
let instance = stripe_instances.get(config.secret_key);
|
|
8
|
+
if (!instance) {
|
|
9
|
+
instance = new Stripe(config.secret_key, {
|
|
10
|
+
typescript: true,
|
|
11
|
+
appInfo: {
|
|
12
|
+
name: 'delightstack',
|
|
13
|
+
version: '0.1.0',
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
stripe_instances.set(config.secret_key, instance);
|
|
17
|
+
}
|
|
18
|
+
return instance;
|
|
19
|
+
}
|
|
20
|
+
/** Wraps a Stripe API call with consistent error handling */
|
|
21
|
+
export async function stripeCall(fn) {
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof Stripe.errors.StripeError) {
|
|
27
|
+
throw new DelightError({
|
|
28
|
+
message: error.message,
|
|
29
|
+
status: error.statusCode ?? 500,
|
|
30
|
+
code: `stripe/${error.type}`,
|
|
31
|
+
detail: error.code ?? undefined,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
throw DelightError.from(error);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Format a Stripe PaymentMethod into a client-safe shape */
|
|
38
|
+
export function formatPaymentMethod(method, default_pm) {
|
|
39
|
+
const default_id = typeof default_pm === 'string' ? default_pm : default_pm?.id;
|
|
40
|
+
const card = method.card;
|
|
41
|
+
let display_name = method.type;
|
|
42
|
+
if (card) {
|
|
43
|
+
const brand = (card.brand || 'card').charAt(0).toUpperCase() + (card.brand || 'card').slice(1);
|
|
44
|
+
display_name = `${brand} •••• ${card.last4}`;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
id: method.id,
|
|
48
|
+
type: method.type,
|
|
49
|
+
brand: card?.brand ?? undefined,
|
|
50
|
+
last4: card?.last4 ?? undefined,
|
|
51
|
+
exp_month: card?.exp_month ?? undefined,
|
|
52
|
+
exp_year: card?.exp_year ?? undefined,
|
|
53
|
+
display_name,
|
|
54
|
+
is_default: method.id === default_id,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Format a Stripe Invoice into a client-safe shape.
|
|
59
|
+
* All amounts are integers in the smallest currency unit (cents) — never floats.
|
|
60
|
+
*/
|
|
61
|
+
export function formatInvoice(invoice) {
|
|
62
|
+
let period_start = null;
|
|
63
|
+
let period_end = null;
|
|
64
|
+
if (invoice.lines?.data?.[0]) {
|
|
65
|
+
const line = invoice.lines.data[0];
|
|
66
|
+
period_start = line.period.start * 1000;
|
|
67
|
+
period_end = line.period.end * 1000;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
id: invoice.id,
|
|
71
|
+
number: invoice.number,
|
|
72
|
+
status: invoice.status,
|
|
73
|
+
amount_paid: invoice.amount_paid ?? 0,
|
|
74
|
+
amount_due: invoice.amount_due ?? 0,
|
|
75
|
+
total: invoice.total ?? 0,
|
|
76
|
+
currency: invoice.currency,
|
|
77
|
+
created: invoice.created * 1000,
|
|
78
|
+
due_date: invoice.due_date ? invoice.due_date * 1000 : null,
|
|
79
|
+
period_start,
|
|
80
|
+
period_end,
|
|
81
|
+
pdf: invoice.invoice_pdf ?? null,
|
|
82
|
+
hosted_invoice_url: invoice.hosted_invoice_url ?? null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Parse JSON body from a request, returning {} on failure */
|
|
86
|
+
export async function parseBody(request) {
|
|
87
|
+
try {
|
|
88
|
+
return (await request.json());
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw DelightError.badRequest('Invalid JSON body');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Get the app URL from config or request origin */
|
|
95
|
+
export function getAppUrl(event, config) {
|
|
96
|
+
return config.app_url ?? event.url.origin;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validates a client-provided return_url to prevent open-redirect/phishing.
|
|
100
|
+
*
|
|
101
|
+
* - `undefined`/non-string → returns `undefined` (caller falls back to a default)
|
|
102
|
+
* - Relative paths are resolved against the app URL
|
|
103
|
+
* - Absolute URLs must be http(s) and same-origin with the app URL, the
|
|
104
|
+
* request origin, or one of `config.allowed_return_origins`
|
|
105
|
+
*
|
|
106
|
+
* @throws DelightError 400 when the URL is malformed or not an allowed origin
|
|
107
|
+
*/
|
|
108
|
+
export function resolveReturnUrl(event, config, raw) {
|
|
109
|
+
if (typeof raw !== 'string' || !raw)
|
|
110
|
+
return undefined;
|
|
111
|
+
const app_url = getAppUrl(event, config);
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = new URL(raw, app_url);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw DelightError.badRequest('Invalid return_url');
|
|
118
|
+
}
|
|
119
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
120
|
+
throw DelightError.badRequest('return_url must be an http(s) URL');
|
|
121
|
+
}
|
|
122
|
+
const allowed = new Set([new URL(app_url).origin, event.url.origin]);
|
|
123
|
+
for (const origin of config.allowed_return_origins ?? []) {
|
|
124
|
+
try {
|
|
125
|
+
allowed.add(new URL(origin).origin);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Ignore malformed allowlist entries
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!allowed.has(parsed.origin)) {
|
|
132
|
+
throw DelightError.badRequest('return_url must be same-origin or one of allowed_return_origins');
|
|
133
|
+
}
|
|
134
|
+
return parsed.toString();
|
|
135
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ResolvedBillingConfig, AuthServerRpc, WebsocketRpc } from './billing.config';
|
|
2
|
+
import type { SubscriptionState } from '../types';
|
|
3
|
+
export interface SyncContext {
|
|
4
|
+
config: ResolvedBillingConfig;
|
|
5
|
+
customer_id: string;
|
|
6
|
+
org_id?: string;
|
|
7
|
+
user_id?: string;
|
|
8
|
+
auth?: AuthServerRpc;
|
|
9
|
+
ws?: WebsocketRpc;
|
|
10
|
+
/**
|
|
11
|
+
* Writes updates into the org_state cookie (from `event.locals.setOrgState`).
|
|
12
|
+
* Used to cache `billing_plan_ids` so `requirePlan()` guards work without
|
|
13
|
+
* hitting Stripe. Unavailable in webhook contexts (no user cookie).
|
|
14
|
+
*/
|
|
15
|
+
setOrgState?: (updates: Record<string, unknown>) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Lightweight read of the current subscription state from Stripe.
|
|
19
|
+
* Does NOT update auth entitlements or broadcast — use for GET endpoints.
|
|
20
|
+
*/
|
|
21
|
+
export declare function fetchSubscriptionState(config: ResolvedBillingConfig, customer_id: string): Promise<SubscriptionState | null>;
|
|
22
|
+
/** The plan ids that should be cached in org_state for `requirePlan()` */
|
|
23
|
+
export declare function activePlanIds(state: SubscriptionState | null): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Fetches the latest subscription state from Stripe and syncs entitlements
|
|
26
|
+
* to the auth package. Returns the current subscription state.
|
|
27
|
+
*/
|
|
28
|
+
export declare function syncSubscription(ctx: SyncContext): Promise<SubscriptionState | null>;
|
|
29
|
+
//# sourceMappingURL=billing.sync.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"billing.sync.d.ts","sourceRoot":"","sources":["../../src/server/billing.sync.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACX,qBAAqB,EACrB,aAAa,EACb,YAAY,EACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAGlD,MAAM,WAAW,WAAW;IAC3B,MAAM,EAAE,qBAAqB,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,EAAE,CAAC,EAAE,YAAY,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;CACzD;AA0FD;;;GAGG;AACH,wBAAsB,sBAAsB,CAC3C,MAAM,EAAE,qBAAqB,EAC7B,WAAW,EAAE,MAAM,GACjB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAGnC;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,GAAG,MAAM,EAAE,CAEvE;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACrC,GAAG,EAAE,WAAW,GACd,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAYnC"}
|