@farm.js/stripe 0.1.0-beta.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 +22 -0
- package/README.md +11 -0
- package/dist/client.d.ts +369 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +100 -0
- package/dist/index.d.ts +285 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3396 -0
- package/dist/storage.d.ts +231 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +645 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3396 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
import { createIntegrationOrm, defineIntegration, integrationRoute, } from "@farm.js/core";
|
|
3
|
+
import { integrationConfig, normalizeWebhookConfig, resolveAppPath, toAbsoluteUrl, withSearchParams, } from "@farm.js/integration-utils";
|
|
4
|
+
import { createStripeClientApi, } from "./client.js";
|
|
5
|
+
import { ormStorageAdapter, } from "./storage.js";
|
|
6
|
+
export { drizzleStorageAdapter, ormStorageAdapter, prismaStorageAdapter, sqliteStorageAdapter, } from "./storage.js";
|
|
7
|
+
function normalizeCheckoutTrialBehavior(value) {
|
|
8
|
+
switch (value) {
|
|
9
|
+
case "none":
|
|
10
|
+
case "require":
|
|
11
|
+
return value;
|
|
12
|
+
default:
|
|
13
|
+
return "if_eligible";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function normalizeProrationBehavior(value) {
|
|
17
|
+
switch (value) {
|
|
18
|
+
case "always_invoice":
|
|
19
|
+
case "none":
|
|
20
|
+
return value;
|
|
21
|
+
default:
|
|
22
|
+
return "create_prorations";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function normalizeUsageProperties(value) {
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const properties = {};
|
|
30
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
31
|
+
if (typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean") {
|
|
32
|
+
properties[key] = entry;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(properties).length > 0 ? properties : undefined;
|
|
36
|
+
}
|
|
37
|
+
function resolveOccurredAt(value) {
|
|
38
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
39
|
+
return new Date().toISOString();
|
|
40
|
+
}
|
|
41
|
+
const date = new Date(value);
|
|
42
|
+
if (Number.isNaN(date.getTime())) {
|
|
43
|
+
throw new Error("Stripe billing usage reporting requires a valid occurredAt timestamp.");
|
|
44
|
+
}
|
|
45
|
+
return date.toISOString();
|
|
46
|
+
}
|
|
47
|
+
function resolveStripeWebhooks(input, env, defaultPath) {
|
|
48
|
+
const configured = normalizeWebhookConfig({
|
|
49
|
+
webhooks: input.webhooks,
|
|
50
|
+
defaultName: "default",
|
|
51
|
+
defaultPath,
|
|
52
|
+
defaultSecret: env.webhookSecret,
|
|
53
|
+
});
|
|
54
|
+
if (configured.length > 0) {
|
|
55
|
+
return configured;
|
|
56
|
+
}
|
|
57
|
+
return normalizeWebhookConfig({
|
|
58
|
+
webhooks: {
|
|
59
|
+
path: input.webhookPath ?? defaultPath,
|
|
60
|
+
secret: input.webhookSecret ?? env.webhookSecret,
|
|
61
|
+
onEvent: input.onWebhook
|
|
62
|
+
? async (event, context) => {
|
|
63
|
+
await input.onWebhook?.(event, context.route);
|
|
64
|
+
}
|
|
65
|
+
: undefined,
|
|
66
|
+
},
|
|
67
|
+
defaultName: "default",
|
|
68
|
+
defaultPath,
|
|
69
|
+
defaultSecret: env.webhookSecret,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function toStripeCurrentChargeLineKind(kind) {
|
|
73
|
+
switch (kind) {
|
|
74
|
+
case "metered":
|
|
75
|
+
return "metered_usage";
|
|
76
|
+
default:
|
|
77
|
+
return kind;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function toStripeCurrentChargesResult(input) {
|
|
81
|
+
const currency = input.preview.currency ?? "usd";
|
|
82
|
+
const baseSubscriptionAmount = input.preview.lines
|
|
83
|
+
.filter((line) => line.kind === "base_subscription")
|
|
84
|
+
.reduce((sum, line) => sum + (line.amount ?? 0), 0);
|
|
85
|
+
return {
|
|
86
|
+
owner: {
|
|
87
|
+
kind: input.owner.kind,
|
|
88
|
+
id: input.owner.id,
|
|
89
|
+
email: input.owner.email,
|
|
90
|
+
},
|
|
91
|
+
planId: input.snapshot.planId,
|
|
92
|
+
productId: input.snapshot.productId,
|
|
93
|
+
customerId: input.snapshot.stripeCustomerId ?? "",
|
|
94
|
+
subscriptionId: input.snapshot.stripeSubscriptionId,
|
|
95
|
+
subscriptionStatus: input.subscriptionStatus,
|
|
96
|
+
currency,
|
|
97
|
+
currentPeriodStart: input.currentPeriodStart,
|
|
98
|
+
currentPeriodEnd: input.currentPeriodEnd,
|
|
99
|
+
baseSubscriptionAmount,
|
|
100
|
+
pendingMeterChargeAmount: input.preview.totals.metered,
|
|
101
|
+
estimatedTotalAmount: input.preview.totals.total,
|
|
102
|
+
lineItems: input.preview.lines.map((line) => ({
|
|
103
|
+
key: line.meterKey,
|
|
104
|
+
kind: toStripeCurrentChargeLineKind(line.kind),
|
|
105
|
+
label: line.description ?? line.kind,
|
|
106
|
+
amount: line.amount,
|
|
107
|
+
currency: line.currency ?? currency,
|
|
108
|
+
quantity: line.quantity,
|
|
109
|
+
includedUnits: line.meterKey
|
|
110
|
+
? getBillingLimitForKey(input.billing, input.snapshot.planId, input.snapshot, line.meterKey)
|
|
111
|
+
: null,
|
|
112
|
+
overageUnits: null,
|
|
113
|
+
billedBuckets: null,
|
|
114
|
+
billingUnits: null,
|
|
115
|
+
unitAmountDecimal: null,
|
|
116
|
+
periodStart: line.periodStart,
|
|
117
|
+
periodEnd: line.periodEnd,
|
|
118
|
+
meterKey: line.meterKey,
|
|
119
|
+
})),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const pendingStripeMeterProjectionByPeriod = new Map();
|
|
123
|
+
const PENDING_STRIPE_METER_PROJECTION_TTL_MS = 2 * 60 * 1000;
|
|
124
|
+
function createPendingStripeMeterProjectionKey(input) {
|
|
125
|
+
return [
|
|
126
|
+
input.customerId,
|
|
127
|
+
input.key,
|
|
128
|
+
input.currentPeriodStart ?? "none",
|
|
129
|
+
input.currentPeriodEnd ?? "none",
|
|
130
|
+
].join(":");
|
|
131
|
+
}
|
|
132
|
+
function prunePendingStripeMeterProjectionStore(periodKey, now = Date.now()) {
|
|
133
|
+
const store = pendingStripeMeterProjectionByPeriod.get(periodKey);
|
|
134
|
+
if (!store) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
for (const [identifier, event] of store.entries()) {
|
|
138
|
+
if (event.expiresAt <= now) {
|
|
139
|
+
store.delete(identifier);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (store.size === 0) {
|
|
143
|
+
pendingStripeMeterProjectionByPeriod.delete(periodKey);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return store;
|
|
147
|
+
}
|
|
148
|
+
function getPendingStripeMeterProjectionEvent(input) {
|
|
149
|
+
const periodKey = createPendingStripeMeterProjectionKey(input);
|
|
150
|
+
const store = prunePendingStripeMeterProjectionStore(periodKey);
|
|
151
|
+
return store?.get(input.identifier) ?? null;
|
|
152
|
+
}
|
|
153
|
+
function getPendingStripeMeterProjectedUsage(input) {
|
|
154
|
+
const periodKey = createPendingStripeMeterProjectionKey(input);
|
|
155
|
+
const store = prunePendingStripeMeterProjectionStore(periodKey);
|
|
156
|
+
if (!store) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
let projectedCurrentPeriodUsed = null;
|
|
160
|
+
for (const event of store.values()) {
|
|
161
|
+
projectedCurrentPeriodUsed =
|
|
162
|
+
projectedCurrentPeriodUsed == null
|
|
163
|
+
? event.projectedCurrentPeriodUsed
|
|
164
|
+
: Math.max(projectedCurrentPeriodUsed, event.projectedCurrentPeriodUsed);
|
|
165
|
+
}
|
|
166
|
+
return projectedCurrentPeriodUsed;
|
|
167
|
+
}
|
|
168
|
+
function rememberPendingStripeMeterProjection(input) {
|
|
169
|
+
const periodKey = createPendingStripeMeterProjectionKey(input);
|
|
170
|
+
const store = prunePendingStripeMeterProjectionStore(periodKey) ??
|
|
171
|
+
new Map();
|
|
172
|
+
store.set(input.identifier, {
|
|
173
|
+
quantity: input.quantity,
|
|
174
|
+
occurredAt: input.occurredAt,
|
|
175
|
+
projectedCurrentPeriodUsed: input.projectedCurrentPeriodUsed,
|
|
176
|
+
expiresAt: Date.now() + PENDING_STRIPE_METER_PROJECTION_TTL_MS,
|
|
177
|
+
});
|
|
178
|
+
pendingStripeMeterProjectionByPeriod.set(periodKey, store);
|
|
179
|
+
}
|
|
180
|
+
function resolveEffectiveStripeMeterCurrentPeriodUsed(input) {
|
|
181
|
+
const periodKey = createPendingStripeMeterProjectionKey(input);
|
|
182
|
+
const pendingProjectedUsage = getPendingStripeMeterProjectedUsage(input);
|
|
183
|
+
if (pendingProjectedUsage == null) {
|
|
184
|
+
return input.currentPeriodUsed;
|
|
185
|
+
}
|
|
186
|
+
if (input.currentPeriodUsed >= pendingProjectedUsage) {
|
|
187
|
+
pendingStripeMeterProjectionByPeriod.delete(periodKey);
|
|
188
|
+
return input.currentPeriodUsed;
|
|
189
|
+
}
|
|
190
|
+
return Math.max(input.currentPeriodUsed, pendingProjectedUsage);
|
|
191
|
+
}
|
|
192
|
+
export const stripeSchema = {
|
|
193
|
+
models: {
|
|
194
|
+
billingAccount: {
|
|
195
|
+
name: "billing_account",
|
|
196
|
+
description: "Semantic billing snapshot for linking app owners to Stripe customers and subscriptions.",
|
|
197
|
+
fields: {
|
|
198
|
+
id: {
|
|
199
|
+
type: "id",
|
|
200
|
+
name: "id",
|
|
201
|
+
primaryKey: true,
|
|
202
|
+
},
|
|
203
|
+
ownerId: {
|
|
204
|
+
type: "string",
|
|
205
|
+
name: "owner_id",
|
|
206
|
+
required: true,
|
|
207
|
+
index: true,
|
|
208
|
+
},
|
|
209
|
+
ownerKind: {
|
|
210
|
+
type: "enum",
|
|
211
|
+
name: "owner_kind",
|
|
212
|
+
required: true,
|
|
213
|
+
default: "user",
|
|
214
|
+
values: ["user", "organization"],
|
|
215
|
+
index: true,
|
|
216
|
+
},
|
|
217
|
+
stripeCustomerId: {
|
|
218
|
+
type: "string",
|
|
219
|
+
name: "stripe_customer_id",
|
|
220
|
+
unique: true,
|
|
221
|
+
nullable: true,
|
|
222
|
+
},
|
|
223
|
+
stripeSubscriptionId: {
|
|
224
|
+
type: "string",
|
|
225
|
+
name: "stripe_subscription_id",
|
|
226
|
+
unique: true,
|
|
227
|
+
nullable: true,
|
|
228
|
+
},
|
|
229
|
+
planId: {
|
|
230
|
+
type: "string",
|
|
231
|
+
name: "plan_id",
|
|
232
|
+
required: true,
|
|
233
|
+
default: "free",
|
|
234
|
+
},
|
|
235
|
+
productId: {
|
|
236
|
+
type: "string",
|
|
237
|
+
name: "product_id",
|
|
238
|
+
nullable: true,
|
|
239
|
+
},
|
|
240
|
+
status: {
|
|
241
|
+
type: "string",
|
|
242
|
+
name: "status",
|
|
243
|
+
required: true,
|
|
244
|
+
default: "free",
|
|
245
|
+
},
|
|
246
|
+
currentPeriodEnd: {
|
|
247
|
+
type: "datetime",
|
|
248
|
+
name: "current_period_end",
|
|
249
|
+
nullable: true,
|
|
250
|
+
},
|
|
251
|
+
cancelAtPeriodEnd: {
|
|
252
|
+
type: "boolean",
|
|
253
|
+
name: "cancel_at_period_end",
|
|
254
|
+
required: true,
|
|
255
|
+
default: false,
|
|
256
|
+
},
|
|
257
|
+
trialEndsAt: {
|
|
258
|
+
type: "datetime",
|
|
259
|
+
name: "trial_ends_at",
|
|
260
|
+
nullable: true,
|
|
261
|
+
},
|
|
262
|
+
trialUsedAt: {
|
|
263
|
+
type: "datetime",
|
|
264
|
+
name: "trial_used_at",
|
|
265
|
+
nullable: true,
|
|
266
|
+
},
|
|
267
|
+
seatQuantity: {
|
|
268
|
+
type: "integer",
|
|
269
|
+
name: "seat_quantity",
|
|
270
|
+
nullable: true,
|
|
271
|
+
},
|
|
272
|
+
seatAllowanceOverride: {
|
|
273
|
+
type: "integer",
|
|
274
|
+
name: "seat_allowance_override",
|
|
275
|
+
nullable: true,
|
|
276
|
+
},
|
|
277
|
+
createdAt: {
|
|
278
|
+
type: "datetime",
|
|
279
|
+
name: "created_at",
|
|
280
|
+
required: true,
|
|
281
|
+
default: "now",
|
|
282
|
+
},
|
|
283
|
+
updatedAt: {
|
|
284
|
+
type: "datetime",
|
|
285
|
+
name: "updated_at",
|
|
286
|
+
required: true,
|
|
287
|
+
default: "now",
|
|
288
|
+
meta: {
|
|
289
|
+
autoUpdate: true,
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
constraints: [
|
|
294
|
+
{
|
|
295
|
+
type: "unique",
|
|
296
|
+
fields: ["ownerKind", "ownerId"],
|
|
297
|
+
name: "billing_account_owner_unique",
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
meta: {
|
|
303
|
+
category: "payment",
|
|
304
|
+
integration: "stripe",
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
function createStripeApi(input = {}) {
|
|
308
|
+
return createStripeClientApi(input);
|
|
309
|
+
}
|
|
310
|
+
function resolveEnv(input) {
|
|
311
|
+
return {
|
|
312
|
+
secretKey: input.secretKey ?? process.env.STRIPE_SECRET_KEY ?? undefined,
|
|
313
|
+
webhookSecret: input.webhookSecret ?? process.env.STRIPE_WEBHOOK_SECRET ?? undefined,
|
|
314
|
+
appBaseUrl: input.appBaseUrl ?? process.env.APP_BASE_URL ?? undefined,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function normalizeProducts(products) {
|
|
318
|
+
const seen = new Set();
|
|
319
|
+
return (products || []).map((product) => {
|
|
320
|
+
if (!product.id) {
|
|
321
|
+
throw new Error("Stripe integration products require an id.");
|
|
322
|
+
}
|
|
323
|
+
if (seen.has(product.id)) {
|
|
324
|
+
throw new Error(`Stripe integration product "${product.id}" is duplicated.`);
|
|
325
|
+
}
|
|
326
|
+
seen.add(product.id);
|
|
327
|
+
if (!product.name && !product.priceId && !product.lookupKey) {
|
|
328
|
+
throw new Error(`Stripe integration product "${product.id}" requires a name.`);
|
|
329
|
+
}
|
|
330
|
+
if (!product.priceId && !product.lookupKey) {
|
|
331
|
+
if (!product.currency) {
|
|
332
|
+
throw new Error(`Stripe integration product "${product.id}" requires a currency when priceId and lookupKey are not provided.`);
|
|
333
|
+
}
|
|
334
|
+
const unitAmount = product.unitAmount;
|
|
335
|
+
if (typeof unitAmount !== "number" || !Number.isInteger(unitAmount) || unitAmount <= 0) {
|
|
336
|
+
throw new Error(`Stripe integration product "${product.id}" requires a positive integer unitAmount when priceId and lookupKey are not provided.`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
else if (product.unitAmount != null &&
|
|
340
|
+
(!Number.isInteger(product.unitAmount) || product.unitAmount <= 0)) {
|
|
341
|
+
throw new Error(`Stripe integration product "${product.id}" has an invalid unitAmount.`);
|
|
342
|
+
}
|
|
343
|
+
if (product.intervalCount != null &&
|
|
344
|
+
(!Number.isInteger(product.intervalCount) || product.intervalCount <= 0)) {
|
|
345
|
+
throw new Error(`Stripe integration product "${product.id}" has an invalid intervalCount.`);
|
|
346
|
+
}
|
|
347
|
+
const mode = product.mode ?? "payment";
|
|
348
|
+
return {
|
|
349
|
+
...product,
|
|
350
|
+
kind: mode === "subscription" ? "subscription" : "one_time",
|
|
351
|
+
public: true,
|
|
352
|
+
planId: product.id,
|
|
353
|
+
seatBilling: product.seatBilling ?? "line_item_quantity",
|
|
354
|
+
mode,
|
|
355
|
+
quantity: product.quantity ?? 1,
|
|
356
|
+
interval: product.interval ?? (product.mode === "subscription" ? "month" : undefined),
|
|
357
|
+
intervalCount: mode === "subscription" ? (product.intervalCount ?? 1) : product.intervalCount,
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function normalizeBillingProducts(products) {
|
|
362
|
+
if (!products) {
|
|
363
|
+
return [];
|
|
364
|
+
}
|
|
365
|
+
const seen = new Set();
|
|
366
|
+
return Object.entries(products).map(([id, product]) => {
|
|
367
|
+
const priceId = product.stripe?.priceId ?? product.priceId;
|
|
368
|
+
const seatPriceId = product.stripe?.seatPriceId ?? product.seatPriceId;
|
|
369
|
+
const meterPriceIds = product.stripe?.meterPriceIds ?? product.meterPriceIds;
|
|
370
|
+
const lookupKey = product.stripe?.lookupKey ?? product.lookupKey;
|
|
371
|
+
if (seen.has(id)) {
|
|
372
|
+
throw new Error(`Stripe billing product "${id}" is duplicated.`);
|
|
373
|
+
}
|
|
374
|
+
seen.add(id);
|
|
375
|
+
if (!product.name && !priceId && !lookupKey) {
|
|
376
|
+
throw new Error(`Stripe billing product "${id}" requires a name.`);
|
|
377
|
+
}
|
|
378
|
+
if (!priceId && !lookupKey) {
|
|
379
|
+
if (!product.currency) {
|
|
380
|
+
throw new Error(`Stripe billing product "${id}" requires a currency when priceId and lookupKey are not provided.`);
|
|
381
|
+
}
|
|
382
|
+
if (typeof product.unitAmount !== "number" ||
|
|
383
|
+
!Number.isInteger(product.unitAmount) ||
|
|
384
|
+
product.unitAmount <= 0) {
|
|
385
|
+
throw new Error(`Stripe billing product "${id}" requires a positive integer unitAmount when priceId and lookupKey are not provided.`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (product.intervalCount != null &&
|
|
389
|
+
(!Number.isInteger(product.intervalCount) || product.intervalCount <= 0)) {
|
|
390
|
+
throw new Error(`Stripe billing product "${id}" has an invalid intervalCount.`);
|
|
391
|
+
}
|
|
392
|
+
const mode = product.kind === "subscription" ? "subscription" : "payment";
|
|
393
|
+
return {
|
|
394
|
+
id,
|
|
395
|
+
name: product.name,
|
|
396
|
+
description: product.description,
|
|
397
|
+
priceId,
|
|
398
|
+
seatPriceId,
|
|
399
|
+
meterPriceIds,
|
|
400
|
+
lookupKey,
|
|
401
|
+
currency: product.currency,
|
|
402
|
+
unitAmount: product.unitAmount,
|
|
403
|
+
kind: product.kind,
|
|
404
|
+
public: product.public ?? true,
|
|
405
|
+
planId: product.planId ?? null,
|
|
406
|
+
seatBilling: product.seatBilling ?? "line_item_quantity",
|
|
407
|
+
mode,
|
|
408
|
+
interval: product.kind === "subscription" ? (product.interval ?? "month") : product.interval,
|
|
409
|
+
intervalCount: product.kind === "subscription" ? (product.intervalCount ?? 1) : product.intervalCount,
|
|
410
|
+
quantity: product.quantity ?? 1,
|
|
411
|
+
imageUrl: product.imageUrl,
|
|
412
|
+
metadata: product.metadata,
|
|
413
|
+
};
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
async function resolveStripePriceReference(stripe, product) {
|
|
417
|
+
const resolved = await resolveStripePriceWithProduct(stripe, product);
|
|
418
|
+
if (!resolved) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
const price = resolved.price;
|
|
422
|
+
const resolvedMode = price.type === "recurring" || price.recurring ? "subscription" : "payment";
|
|
423
|
+
if (product.mode && product.mode !== resolvedMode) {
|
|
424
|
+
throw new Error(`Stripe integration product "${product.id}" declares mode "${product.mode}" but price "${price.id}" is "${resolvedMode}".`);
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
priceId: price.id,
|
|
428
|
+
mode: product.mode ?? resolvedMode,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function resolveCatalogProducts(stripe, products, billing) {
|
|
432
|
+
return await Promise.all(products.map(async (product) => {
|
|
433
|
+
const trialDays = product.planId && billing?.plans?.[product.planId]?.trial
|
|
434
|
+
? billing.plans[product.planId].trial.days
|
|
435
|
+
: null;
|
|
436
|
+
const seatPrice = stripe && product.seatPriceId ? await stripe.prices.retrieve(product.seatPriceId) : null;
|
|
437
|
+
const resolvedSeatPrice = seatPrice && !("deleted" in seatPrice && seatPrice.deleted) ? seatPrice : null;
|
|
438
|
+
const meterPrices = stripe && product.meterPriceIds
|
|
439
|
+
? (await Promise.all(Object.entries(product.meterPriceIds).map(async ([key, priceId]) => {
|
|
440
|
+
const result = await stripe.prices.retrieve(priceId, {
|
|
441
|
+
expand: ["product"],
|
|
442
|
+
});
|
|
443
|
+
if ("deleted" in result && result.deleted) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const meter = billing?.meters?.[key] ?? null;
|
|
447
|
+
const tiers = result.billing_scheme === "tiered" && Array.isArray(result.tiers)
|
|
448
|
+
? result.tiers
|
|
449
|
+
: [];
|
|
450
|
+
const includedTier = tiers[0] ?? null;
|
|
451
|
+
const overageTier = tiers[1] ?? null;
|
|
452
|
+
const includedUnits = includedTier && typeof includedTier.up_to === "number"
|
|
453
|
+
? includedTier.up_to
|
|
454
|
+
: null;
|
|
455
|
+
const unitAmountDecimal = overageTier?.unit_amount_decimal ??
|
|
456
|
+
result.unit_amount_decimal ??
|
|
457
|
+
(typeof result.unit_amount === "number" ? String(result.unit_amount) : null);
|
|
458
|
+
const generatedSummary = includedUnits !== null && unitAmountDecimal
|
|
459
|
+
? `Includes first ${includedUnits.toLocaleString()} ${meter?.unit ?? "units"}, then ${formatCurrencyFromMinorDecimal(unitAmountDecimal, result.currency)} per ${meter?.unit ?? "unit"}.`
|
|
460
|
+
: unitAmountDecimal
|
|
461
|
+
? `${formatCurrencyFromMinorDecimal(unitAmountDecimal, result.currency)} per ${meter?.unit ?? "unit"}.`
|
|
462
|
+
: null;
|
|
463
|
+
const summary = result.metadata.rateSummary || result.nickname || generatedSummary;
|
|
464
|
+
const meterPrice = {
|
|
465
|
+
key,
|
|
466
|
+
eventName: meter?.eventName ?? key,
|
|
467
|
+
unit: meter?.unit ?? null,
|
|
468
|
+
priceId: result.id,
|
|
469
|
+
currency: result.currency,
|
|
470
|
+
billingScheme: result.billing_scheme ?? null,
|
|
471
|
+
tiersMode: result.tiers_mode ?? null,
|
|
472
|
+
unitAmount: result.unit_amount ?? null,
|
|
473
|
+
unitAmountDecimal: result.unit_amount_decimal ?? null,
|
|
474
|
+
summary,
|
|
475
|
+
};
|
|
476
|
+
return meterPrice;
|
|
477
|
+
}))).filter((item) => item !== null)
|
|
478
|
+
: [];
|
|
479
|
+
if (!stripe) {
|
|
480
|
+
return {
|
|
481
|
+
id: product.id,
|
|
482
|
+
name: product.name ?? product.id,
|
|
483
|
+
description: product.description ?? null,
|
|
484
|
+
kind: product.kind,
|
|
485
|
+
planId: product.planId,
|
|
486
|
+
trialDays,
|
|
487
|
+
public: product.public,
|
|
488
|
+
currency: product.currency ?? null,
|
|
489
|
+
unitAmount: product.unitAmount ?? null,
|
|
490
|
+
mode: product.mode ?? "payment",
|
|
491
|
+
interval: product.interval ?? null,
|
|
492
|
+
intervalCount: product.intervalCount ?? null,
|
|
493
|
+
quantity: product.quantity ?? 1,
|
|
494
|
+
seatBilling: product.seatBilling ?? "line_item_quantity",
|
|
495
|
+
hasSeatPrice: Boolean(product.seatPriceId),
|
|
496
|
+
seatUnitAmount: null,
|
|
497
|
+
seatCurrency: null,
|
|
498
|
+
meterPrices: [],
|
|
499
|
+
priceId: product.priceId ?? null,
|
|
500
|
+
productId: null,
|
|
501
|
+
lookupKey: product.lookupKey ?? null,
|
|
502
|
+
metadata: product.metadata ?? {},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const resolved = await resolveStripePriceWithProduct(stripe, product);
|
|
506
|
+
const price = resolved?.price ?? null;
|
|
507
|
+
const stripeProduct = resolved?.product ?? null;
|
|
508
|
+
return {
|
|
509
|
+
id: product.id,
|
|
510
|
+
name: product.name ?? stripeProduct?.name ?? product.id,
|
|
511
|
+
description: product.description ?? stripeProduct?.description ?? null,
|
|
512
|
+
kind: product.kind,
|
|
513
|
+
planId: product.planId,
|
|
514
|
+
trialDays,
|
|
515
|
+
public: product.public,
|
|
516
|
+
currency: price?.currency ?? product.currency ?? null,
|
|
517
|
+
unitAmount: price?.unit_amount ?? product.unitAmount ?? null,
|
|
518
|
+
mode: product.mode ??
|
|
519
|
+
(price?.type === "recurring" || price?.recurring ? "subscription" : "payment"),
|
|
520
|
+
interval: product.interval ??
|
|
521
|
+
price?.recurring?.interval ??
|
|
522
|
+
null,
|
|
523
|
+
intervalCount: product.intervalCount ?? price?.recurring?.interval_count ?? null,
|
|
524
|
+
quantity: product.quantity ?? 1,
|
|
525
|
+
seatBilling: product.seatBilling ?? "line_item_quantity",
|
|
526
|
+
hasSeatPrice: Boolean(product.seatPriceId),
|
|
527
|
+
seatUnitAmount: resolvedSeatPrice?.unit_amount ?? null,
|
|
528
|
+
seatCurrency: resolvedSeatPrice?.currency ?? null,
|
|
529
|
+
meterPrices,
|
|
530
|
+
priceId: price?.id ?? product.priceId ?? null,
|
|
531
|
+
productId: stripeProduct?.id ?? null,
|
|
532
|
+
lookupKey: price?.lookup_key ?? product.lookupKey ?? null,
|
|
533
|
+
metadata: {
|
|
534
|
+
...stripeProduct?.metadata,
|
|
535
|
+
...price?.metadata,
|
|
536
|
+
...product.metadata,
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}));
|
|
540
|
+
}
|
|
541
|
+
function resolvePath(path, label, fallback) {
|
|
542
|
+
return resolveAppPath(path, label) ?? fallback;
|
|
543
|
+
}
|
|
544
|
+
function resolveQuantity(rawQuantity, fallback) {
|
|
545
|
+
const quantity = rawQuantity ?? fallback;
|
|
546
|
+
if (!Number.isInteger(quantity) || quantity <= 0) {
|
|
547
|
+
throw new Error("Stripe checkout quantity must be a positive integer.");
|
|
548
|
+
}
|
|
549
|
+
return quantity;
|
|
550
|
+
}
|
|
551
|
+
function resolveMeterLineItems(product, billing) {
|
|
552
|
+
if (!product.meterPriceIds) {
|
|
553
|
+
return [];
|
|
554
|
+
}
|
|
555
|
+
return Object.entries(product.meterPriceIds).map(([key, priceId]) => {
|
|
556
|
+
const meter = getBillingMeter(billing, key);
|
|
557
|
+
return {
|
|
558
|
+
product: {
|
|
559
|
+
id: `${product.id}:meter:${key}`,
|
|
560
|
+
priceId,
|
|
561
|
+
mode: "subscription",
|
|
562
|
+
metadata: {
|
|
563
|
+
meterKey: key,
|
|
564
|
+
meterEventName: meter?.eventName ?? key,
|
|
565
|
+
meterForProductId: product.id,
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
function resolveCheckoutLineItems(product, quantity, billing, planId) {
|
|
572
|
+
const meterLineItems = resolveMeterLineItems(product, billing);
|
|
573
|
+
if (product.seatBilling !== "included_plus_add_on") {
|
|
574
|
+
return [
|
|
575
|
+
{
|
|
576
|
+
product,
|
|
577
|
+
quantity,
|
|
578
|
+
},
|
|
579
|
+
...meterLineItems,
|
|
580
|
+
];
|
|
581
|
+
}
|
|
582
|
+
const includedSeats = getConfiguredBillingLimits(billing, planId).seats;
|
|
583
|
+
if (typeof includedSeats !== "number" || !Number.isInteger(includedSeats) || includedSeats <= 0) {
|
|
584
|
+
throw new Error(`Stripe billing product "${product.id}" requires a positive integer plan seat limit when seatBilling="included_plus_add_on".`);
|
|
585
|
+
}
|
|
586
|
+
if (quantity < includedSeats) {
|
|
587
|
+
throw new Error(`Stripe checkout quantity for "${product.id}" must be at least ${includedSeats}.`);
|
|
588
|
+
}
|
|
589
|
+
const lineItems = [
|
|
590
|
+
{
|
|
591
|
+
product,
|
|
592
|
+
quantity: 1,
|
|
593
|
+
},
|
|
594
|
+
];
|
|
595
|
+
const extraSeats = quantity - includedSeats;
|
|
596
|
+
if (extraSeats <= 0) {
|
|
597
|
+
return [...lineItems, ...meterLineItems];
|
|
598
|
+
}
|
|
599
|
+
if (!product.seatPriceId) {
|
|
600
|
+
throw new Error(`Stripe billing product "${product.id}" requires seatPriceId before checkout can add seats above the included ${includedSeats}.`);
|
|
601
|
+
}
|
|
602
|
+
lineItems.push({
|
|
603
|
+
product: {
|
|
604
|
+
id: `${product.id}:seat-addon`,
|
|
605
|
+
priceId: product.seatPriceId,
|
|
606
|
+
mode: "subscription",
|
|
607
|
+
metadata: {
|
|
608
|
+
seatAddOnForProductId: product.id,
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
quantity: extraSeats,
|
|
612
|
+
});
|
|
613
|
+
return [...lineItems, ...meterLineItems];
|
|
614
|
+
}
|
|
615
|
+
function resolveAbsoluteDestination(pathOrUrl, request, configuredBaseUrl, params) {
|
|
616
|
+
const targetUrl = toAbsoluteUrl(pathOrUrl, request, configuredBaseUrl);
|
|
617
|
+
return params ? withSearchParams(targetUrl, params).toString() : targetUrl.toString();
|
|
618
|
+
}
|
|
619
|
+
function resolveCheckoutSuccessUrl(pathOrUrl, request, configuredBaseUrl) {
|
|
620
|
+
const targetUrl = resolveAbsoluteDestination(pathOrUrl, request, configuredBaseUrl, {
|
|
621
|
+
session_id: "{CHECKOUT_SESSION_ID}",
|
|
622
|
+
});
|
|
623
|
+
return targetUrl.replace(encodeURIComponent("{CHECKOUT_SESSION_ID}"), "{CHECKOUT_SESSION_ID}");
|
|
624
|
+
}
|
|
625
|
+
function normalizeStripeSessionResult(input) {
|
|
626
|
+
return {
|
|
627
|
+
id: input.id,
|
|
628
|
+
status: input.status ?? null,
|
|
629
|
+
paymentStatus: input.paymentStatus ?? null,
|
|
630
|
+
mode: input.mode === "payment" || input.mode === "subscription" ? input.mode : null,
|
|
631
|
+
customerId: input.customerId ?? null,
|
|
632
|
+
customerEmail: input.customerEmail ?? null,
|
|
633
|
+
subscriptionId: input.subscriptionId ?? null,
|
|
634
|
+
subscriptionStatus: input.subscriptionStatus ?? null,
|
|
635
|
+
currentPeriodEnd: input.currentPeriodEnd ?? null,
|
|
636
|
+
trialEndsAt: input.trialEndsAt ?? null,
|
|
637
|
+
cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? false,
|
|
638
|
+
amountSubtotal: input.amountSubtotal ?? null,
|
|
639
|
+
amountTotal: input.amountTotal ?? null,
|
|
640
|
+
currency: input.currency ?? null,
|
|
641
|
+
metadata: input.metadata ?? {},
|
|
642
|
+
lineItems: input.lineItems ?? [],
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
function normalizeBillingStatus(value, fallback = "active") {
|
|
646
|
+
switch (value) {
|
|
647
|
+
case "trialing":
|
|
648
|
+
case "active":
|
|
649
|
+
case "past_due":
|
|
650
|
+
case "canceled":
|
|
651
|
+
case "unpaid":
|
|
652
|
+
case "incomplete":
|
|
653
|
+
return value;
|
|
654
|
+
case "paid":
|
|
655
|
+
return "active";
|
|
656
|
+
default:
|
|
657
|
+
return fallback;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function formatCurrencyFromMinorDecimal(value, currency) {
|
|
661
|
+
const parsed = Number(value);
|
|
662
|
+
if (!Number.isFinite(parsed)) {
|
|
663
|
+
return value;
|
|
664
|
+
}
|
|
665
|
+
return new Intl.NumberFormat("en-US", {
|
|
666
|
+
style: "currency",
|
|
667
|
+
currency: (currency ?? "usd").toUpperCase(),
|
|
668
|
+
maximumFractionDigits: parsed % 100 === 0 ? 0 : 6,
|
|
669
|
+
}).format(parsed / 100);
|
|
670
|
+
}
|
|
671
|
+
function serializeBillingSnapshot(snapshot, billing, features = {}, limits = {}, entitlements = {}) {
|
|
672
|
+
const planId = snapshot?.planId ?? "free";
|
|
673
|
+
const seatLimit = getBillingSeatLimit(billing, planId, snapshot);
|
|
674
|
+
return {
|
|
675
|
+
owner: snapshot?.owner ?? null,
|
|
676
|
+
planId,
|
|
677
|
+
productId: snapshot?.productId ?? null,
|
|
678
|
+
status: snapshot?.status ?? "free",
|
|
679
|
+
stripeCustomerId: snapshot?.stripeCustomerId ?? null,
|
|
680
|
+
stripeSubscriptionId: snapshot?.stripeSubscriptionId ?? null,
|
|
681
|
+
currentPeriodEnd: snapshot?.currentPeriodEnd?.toISOString() ?? null,
|
|
682
|
+
cancelAtPeriodEnd: snapshot?.cancelAtPeriodEnd ?? false,
|
|
683
|
+
trialEndsAt: snapshot?.trialEndsAt?.toISOString() ?? null,
|
|
684
|
+
trialUsedAt: snapshot?.trialUsedAt?.toISOString() ?? null,
|
|
685
|
+
seatMode: getBillingSeatsMode(billing),
|
|
686
|
+
seatQuantity: snapshot?.seatQuantity ?? null,
|
|
687
|
+
seatAllowanceOverride: snapshot?.seatAllowanceOverride ?? null,
|
|
688
|
+
seatLimitSource: seatLimit.source,
|
|
689
|
+
features,
|
|
690
|
+
limits,
|
|
691
|
+
entitlements,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
async function resolveStripePriceWithProduct(stripe, product) {
|
|
695
|
+
let price = null;
|
|
696
|
+
if (product.lookupKey) {
|
|
697
|
+
const result = await stripe.prices.list({
|
|
698
|
+
lookup_keys: [product.lookupKey],
|
|
699
|
+
active: true,
|
|
700
|
+
limit: 1,
|
|
701
|
+
});
|
|
702
|
+
price = result.data[0] ?? null;
|
|
703
|
+
if (!price) {
|
|
704
|
+
throw new Error(`Stripe integration product "${product.id}" could not resolve lookupKey "${product.lookupKey}".`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
else if (product.priceId) {
|
|
708
|
+
const result = await stripe.prices.retrieve(product.priceId);
|
|
709
|
+
if ("deleted" in result && result.deleted) {
|
|
710
|
+
throw new Error(`Stripe integration product "${product.id}" references deleted price "${product.priceId}".`);
|
|
711
|
+
}
|
|
712
|
+
price = result;
|
|
713
|
+
}
|
|
714
|
+
if (!price) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
if (typeof price.product !== "string") {
|
|
718
|
+
return {
|
|
719
|
+
price,
|
|
720
|
+
product: "deleted" in price.product && price.product.deleted ? null : price.product,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
const stripeProduct = await stripe.products.retrieve(price.product);
|
|
724
|
+
return {
|
|
725
|
+
price,
|
|
726
|
+
product: "deleted" in stripeProduct && stripeProduct.deleted ? null : stripeProduct,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function isStripeSdkInstance(value) {
|
|
730
|
+
return (!!value &&
|
|
731
|
+
typeof value === "object" &&
|
|
732
|
+
"checkout" in value &&
|
|
733
|
+
"billingPortal" in value &&
|
|
734
|
+
"webhooks" in value);
|
|
735
|
+
}
|
|
736
|
+
function getStripePriceProductId(price) {
|
|
737
|
+
if (!price || typeof price.product === "string") {
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
return "deleted" in price.product && price.product.deleted
|
|
741
|
+
? null
|
|
742
|
+
: (price.product.metadata?.productId ?? null);
|
|
743
|
+
}
|
|
744
|
+
function findSubscriptionItemForProduct(subscription, product, resolvedPriceId) {
|
|
745
|
+
for (const item of subscription.items.data) {
|
|
746
|
+
if (resolvedPriceId && item.price.id === resolvedPriceId) {
|
|
747
|
+
return item;
|
|
748
|
+
}
|
|
749
|
+
if (getStripePriceProductId(item.price) === product.id) {
|
|
750
|
+
return item;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
function normalizeStripeSubscriptionResult(subscription) {
|
|
756
|
+
return {
|
|
757
|
+
customerId: typeof subscription.customer === "string"
|
|
758
|
+
? subscription.customer
|
|
759
|
+
: (subscription.customer?.id ?? null),
|
|
760
|
+
subscriptionId: subscription.id,
|
|
761
|
+
subscriptionStatus: subscription.status,
|
|
762
|
+
currentPeriodEnd: subscription.items.data[0]?.current_period_end
|
|
763
|
+
? new Date(subscription.items.data[0].current_period_end * 1000).toISOString()
|
|
764
|
+
: null,
|
|
765
|
+
trialEndsAt: typeof subscription.trial_end === "number"
|
|
766
|
+
? new Date(subscription.trial_end * 1000).toISOString()
|
|
767
|
+
: null,
|
|
768
|
+
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
|
769
|
+
lineItems: subscription.items.data.map((item) => ({
|
|
770
|
+
description: typeof item.price.product === "string"
|
|
771
|
+
? null
|
|
772
|
+
: "deleted" in item.price.product && item.price.product.deleted
|
|
773
|
+
? null
|
|
774
|
+
: (item.price.product.name ?? null),
|
|
775
|
+
quantity: item.quantity ?? null,
|
|
776
|
+
amountSubtotal: null,
|
|
777
|
+
amountTotal: null,
|
|
778
|
+
currency: item.price.currency ?? null,
|
|
779
|
+
priceId: item.price.id ?? null,
|
|
780
|
+
productId: getStripePriceProductId(item.price),
|
|
781
|
+
})),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function getStripeInvoiceLinePriceId(line) {
|
|
785
|
+
const pricing = line.pricing;
|
|
786
|
+
if (pricing?.type === "price_details" && typeof pricing.price_details?.price === "string") {
|
|
787
|
+
return pricing.price_details.price;
|
|
788
|
+
}
|
|
789
|
+
const legacyPrice = line.price;
|
|
790
|
+
return typeof legacyPrice?.id === "string" ? legacyPrice.id : null;
|
|
791
|
+
}
|
|
792
|
+
function getStripeInvoiceLineStripeProductId(line) {
|
|
793
|
+
const pricing = line.pricing;
|
|
794
|
+
if (pricing?.type === "price_details" && typeof pricing.price_details?.product === "string") {
|
|
795
|
+
return pricing.price_details.product;
|
|
796
|
+
}
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
function isStripeInvoiceLineProration(line) {
|
|
800
|
+
const parent = line.parent;
|
|
801
|
+
return parent?.subscription_item_details?.proration === true;
|
|
802
|
+
}
|
|
803
|
+
function resolveStripeUpcomingInvoiceLineKind(input) {
|
|
804
|
+
const { line, basePriceId, seatPriceId, meterKeyByPriceId } = input;
|
|
805
|
+
const priceId = getStripeInvoiceLinePriceId(line);
|
|
806
|
+
const meterKey = priceId ? (meterKeyByPriceId.get(priceId) ?? null) : null;
|
|
807
|
+
if (isStripeInvoiceLineProration(line)) {
|
|
808
|
+
return {
|
|
809
|
+
kind: "proration",
|
|
810
|
+
meterKey,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
if (meterKey) {
|
|
814
|
+
return {
|
|
815
|
+
kind: "metered",
|
|
816
|
+
meterKey,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
if (priceId && seatPriceId && priceId === seatPriceId) {
|
|
820
|
+
return {
|
|
821
|
+
kind: "seat_add_on",
|
|
822
|
+
meterKey: null,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
if (priceId && basePriceId && priceId === basePriceId) {
|
|
826
|
+
return {
|
|
827
|
+
kind: "base_subscription",
|
|
828
|
+
meterKey: null,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
kind: "other",
|
|
833
|
+
meterKey: null,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
async function createStripeUpcomingInvoicePreviewFromSdk(input) {
|
|
837
|
+
const basePriceId = input.product
|
|
838
|
+
? ((await resolveStripePriceReference(input.stripe, input.product))?.priceId ?? null)
|
|
839
|
+
: null;
|
|
840
|
+
const meterKeyByPriceId = new Map(Object.entries(input.product?.meterPriceIds ?? {}).flatMap(([key, priceId]) => typeof priceId === "string" && priceId ? [[priceId, key]] : []));
|
|
841
|
+
const preview = await input.stripe.invoices.createPreview({
|
|
842
|
+
customer: input.customerId,
|
|
843
|
+
subscription: input.subscriptionId,
|
|
844
|
+
});
|
|
845
|
+
const totals = {
|
|
846
|
+
recurring: 0,
|
|
847
|
+
prorations: 0,
|
|
848
|
+
metered: 0,
|
|
849
|
+
other: 0,
|
|
850
|
+
total: typeof preview.total === "number" ? preview.total : 0,
|
|
851
|
+
};
|
|
852
|
+
const lines = preview.lines.data.map((line) => {
|
|
853
|
+
const { kind, meterKey } = resolveStripeUpcomingInvoiceLineKind({
|
|
854
|
+
line,
|
|
855
|
+
basePriceId,
|
|
856
|
+
seatPriceId: input.product?.seatPriceId,
|
|
857
|
+
meterKeyByPriceId,
|
|
858
|
+
});
|
|
859
|
+
switch (kind) {
|
|
860
|
+
case "base_subscription":
|
|
861
|
+
case "seat_add_on":
|
|
862
|
+
totals.recurring += line.amount;
|
|
863
|
+
break;
|
|
864
|
+
case "proration":
|
|
865
|
+
totals.prorations += line.amount;
|
|
866
|
+
break;
|
|
867
|
+
case "metered":
|
|
868
|
+
totals.metered += line.amount;
|
|
869
|
+
break;
|
|
870
|
+
default:
|
|
871
|
+
totals.other += line.amount;
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
description: line.description ?? null,
|
|
876
|
+
kind,
|
|
877
|
+
quantity: line.quantity ?? null,
|
|
878
|
+
amount: line.amount ?? null,
|
|
879
|
+
currency: line.currency ?? preview.currency ?? null,
|
|
880
|
+
periodStart: typeof line.period?.start === "number"
|
|
881
|
+
? new Date(line.period.start * 1000).toISOString()
|
|
882
|
+
: null,
|
|
883
|
+
periodEnd: typeof line.period?.end === "number"
|
|
884
|
+
? new Date(line.period.end * 1000).toISOString()
|
|
885
|
+
: null,
|
|
886
|
+
priceId: getStripeInvoiceLinePriceId(line),
|
|
887
|
+
stripeProductId: getStripeInvoiceLineStripeProductId(line),
|
|
888
|
+
meterKey,
|
|
889
|
+
};
|
|
890
|
+
});
|
|
891
|
+
return {
|
|
892
|
+
currency: preview.currency ?? null,
|
|
893
|
+
totals,
|
|
894
|
+
lines,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
function createStripeAdapterFromSdk(stripe) {
|
|
898
|
+
return {
|
|
899
|
+
async createCheckoutSession(input) {
|
|
900
|
+
const checkoutLineItems = input.lineItems?.length
|
|
901
|
+
? input.lineItems
|
|
902
|
+
: [
|
|
903
|
+
{
|
|
904
|
+
product: input.product,
|
|
905
|
+
quantity: input.quantity,
|
|
906
|
+
},
|
|
907
|
+
];
|
|
908
|
+
const resolvedLineItems = await Promise.all(checkoutLineItems.map(async (lineItem) => {
|
|
909
|
+
const resolvedPrice = await resolveStripePriceReference(stripe, lineItem.product);
|
|
910
|
+
const mode = resolvedPrice?.mode ?? lineItem.product.mode ?? "payment";
|
|
911
|
+
return {
|
|
912
|
+
mode,
|
|
913
|
+
lineItem: resolvedPrice
|
|
914
|
+
? {
|
|
915
|
+
price: resolvedPrice.priceId,
|
|
916
|
+
...(typeof lineItem.quantity === "number"
|
|
917
|
+
? {
|
|
918
|
+
quantity: lineItem.quantity,
|
|
919
|
+
}
|
|
920
|
+
: {}),
|
|
921
|
+
}
|
|
922
|
+
: {
|
|
923
|
+
price_data: {
|
|
924
|
+
currency: lineItem.product.currency,
|
|
925
|
+
unit_amount: lineItem.product.unitAmount,
|
|
926
|
+
recurring: mode === "subscription"
|
|
927
|
+
? {
|
|
928
|
+
interval: lineItem.product.interval ?? "month",
|
|
929
|
+
interval_count: lineItem.product.intervalCount ?? 1,
|
|
930
|
+
}
|
|
931
|
+
: undefined,
|
|
932
|
+
product_data: {
|
|
933
|
+
name: lineItem.product.name ?? lineItem.product.id,
|
|
934
|
+
description: lineItem.product.description,
|
|
935
|
+
images: lineItem.product.imageUrl ? [lineItem.product.imageUrl] : undefined,
|
|
936
|
+
metadata: {
|
|
937
|
+
productId: lineItem.product.id,
|
|
938
|
+
...lineItem.product.metadata,
|
|
939
|
+
},
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
...(typeof lineItem.quantity === "number"
|
|
943
|
+
? {
|
|
944
|
+
quantity: lineItem.quantity,
|
|
945
|
+
}
|
|
946
|
+
: {}),
|
|
947
|
+
},
|
|
948
|
+
};
|
|
949
|
+
}));
|
|
950
|
+
const mode = resolvedLineItems[0]?.mode ?? input.product.mode ?? "payment";
|
|
951
|
+
const session = await stripe.checkout.sessions.create({
|
|
952
|
+
mode,
|
|
953
|
+
customer: input.customerId,
|
|
954
|
+
customer_email: input.customerId ? undefined : input.customerEmail,
|
|
955
|
+
success_url: input.successUrl,
|
|
956
|
+
cancel_url: input.cancelUrl,
|
|
957
|
+
allow_promotion_codes: input.allowPromotionCodes,
|
|
958
|
+
automatic_tax: input.automaticTax ? { enabled: true } : undefined,
|
|
959
|
+
customer_creation: mode === "payment" && !input.customerId ? "always" : undefined,
|
|
960
|
+
client_reference_id: input.product.id,
|
|
961
|
+
metadata: {
|
|
962
|
+
productId: input.product.id,
|
|
963
|
+
...input.product.metadata,
|
|
964
|
+
...input.metadata,
|
|
965
|
+
},
|
|
966
|
+
subscription_data: mode === "subscription" && input.trialDays && input.trialDays > 0
|
|
967
|
+
? {
|
|
968
|
+
trial_period_days: input.trialDays,
|
|
969
|
+
}
|
|
970
|
+
: undefined,
|
|
971
|
+
line_items: resolvedLineItems.map((entry) => entry.lineItem),
|
|
972
|
+
});
|
|
973
|
+
if (!session.url) {
|
|
974
|
+
throw new Error("Stripe did not return a Checkout redirect URL.");
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
id: session.id,
|
|
978
|
+
url: session.url,
|
|
979
|
+
};
|
|
980
|
+
},
|
|
981
|
+
async createPortalSession(input) {
|
|
982
|
+
const session = await stripe.billingPortal.sessions.create({
|
|
983
|
+
customer: input.customerId,
|
|
984
|
+
return_url: input.returnUrl,
|
|
985
|
+
});
|
|
986
|
+
return {
|
|
987
|
+
url: session.url,
|
|
988
|
+
};
|
|
989
|
+
},
|
|
990
|
+
async updateSubscription(input) {
|
|
991
|
+
const subscription = await stripe.subscriptions.retrieve(input.subscriptionId, {
|
|
992
|
+
expand: ["items.data.price.product"],
|
|
993
|
+
});
|
|
994
|
+
if ("deleted" in subscription && subscription.deleted) {
|
|
995
|
+
throw new Error(`Stripe subscription "${input.subscriptionId}" no longer exists.`);
|
|
996
|
+
}
|
|
997
|
+
const itemUpdates = [];
|
|
998
|
+
const matchedItemIds = new Set();
|
|
999
|
+
const relevantProductPriceIds = new Set([input.product.seatPriceId, ...Object.values(input.product.meterPriceIds ?? {})].flatMap((value) => (typeof value === "string" && value ? [value] : [])));
|
|
1000
|
+
for (const lineItem of input.lineItems) {
|
|
1001
|
+
const resolvedPrice = await resolveStripePriceReference(stripe, lineItem.product);
|
|
1002
|
+
const existingItem = findSubscriptionItemForProduct(subscription, lineItem.product, resolvedPrice?.priceId ?? null);
|
|
1003
|
+
if (existingItem) {
|
|
1004
|
+
matchedItemIds.add(existingItem.id);
|
|
1005
|
+
if (typeof lineItem.quantity === "number") {
|
|
1006
|
+
itemUpdates.push({
|
|
1007
|
+
id: existingItem.id,
|
|
1008
|
+
quantity: lineItem.quantity,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (!resolvedPrice?.priceId) {
|
|
1014
|
+
throw new Error(`Stripe billing product "${lineItem.product.id}" requires a saved price before it can be added to an existing subscription.`);
|
|
1015
|
+
}
|
|
1016
|
+
itemUpdates.push({
|
|
1017
|
+
price: resolvedPrice.priceId,
|
|
1018
|
+
...(typeof lineItem.quantity === "number"
|
|
1019
|
+
? {
|
|
1020
|
+
quantity: lineItem.quantity,
|
|
1021
|
+
}
|
|
1022
|
+
: {}),
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
for (const item of subscription.items.data) {
|
|
1026
|
+
if (matchedItemIds.has(item.id)) {
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
const itemProductId = getStripePriceProductId(item.price);
|
|
1030
|
+
if (itemProductId !== input.product.id && !relevantProductPriceIds.has(item.price.id)) {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
itemUpdates.push({
|
|
1034
|
+
id: item.id,
|
|
1035
|
+
deleted: true,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
const updated = await stripe.subscriptions.update(input.subscriptionId, {
|
|
1039
|
+
items: itemUpdates,
|
|
1040
|
+
proration_behavior: input.prorationBehavior ?? "create_prorations",
|
|
1041
|
+
expand: ["items.data.price.product"],
|
|
1042
|
+
});
|
|
1043
|
+
return normalizeStripeSubscriptionResult(updated);
|
|
1044
|
+
},
|
|
1045
|
+
async reportUsage(input) {
|
|
1046
|
+
const occurredAtDate = new Date(input.occurredAt);
|
|
1047
|
+
const timestamp = Math.floor(occurredAtDate.getTime() / 1000);
|
|
1048
|
+
const payload = {
|
|
1049
|
+
stripe_customer_id: input.customerId,
|
|
1050
|
+
value: String(input.quantity),
|
|
1051
|
+
};
|
|
1052
|
+
for (const [key, value] of Object.entries(input.properties ?? {})) {
|
|
1053
|
+
payload[key] = String(value);
|
|
1054
|
+
}
|
|
1055
|
+
const event = await stripe.billing.meterEvents.create({
|
|
1056
|
+
event_name: input.meter.eventName,
|
|
1057
|
+
identifier: input.idempotencyKey,
|
|
1058
|
+
timestamp,
|
|
1059
|
+
payload,
|
|
1060
|
+
});
|
|
1061
|
+
return {
|
|
1062
|
+
customerId: input.customerId,
|
|
1063
|
+
eventName: event.event_name,
|
|
1064
|
+
identifier: event.identifier,
|
|
1065
|
+
occurredAt: new Date(event.timestamp * 1000).toISOString(),
|
|
1066
|
+
};
|
|
1067
|
+
},
|
|
1068
|
+
async previewUpcomingInvoice(input) {
|
|
1069
|
+
return createStripeUpcomingInvoicePreviewFromSdk({
|
|
1070
|
+
stripe,
|
|
1071
|
+
customerId: input.customerId,
|
|
1072
|
+
subscriptionId: input.subscriptionId,
|
|
1073
|
+
product: input.product,
|
|
1074
|
+
});
|
|
1075
|
+
},
|
|
1076
|
+
async retrieveCheckoutSession(sessionId) {
|
|
1077
|
+
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
|
1078
|
+
const lineItems = await stripe.checkout.sessions.listLineItems(sessionId, {
|
|
1079
|
+
limit: 20,
|
|
1080
|
+
});
|
|
1081
|
+
const subscriptionId = typeof session.subscription === "string"
|
|
1082
|
+
? session.subscription
|
|
1083
|
+
: (session.subscription?.id ?? null);
|
|
1084
|
+
const subscription = subscriptionId
|
|
1085
|
+
? await stripe.subscriptions.retrieve(subscriptionId)
|
|
1086
|
+
: null;
|
|
1087
|
+
const normalizedSubscription = subscription && !("deleted" in subscription && subscription.deleted)
|
|
1088
|
+
? normalizeStripeSubscriptionResult(subscription)
|
|
1089
|
+
: null;
|
|
1090
|
+
return normalizeStripeSessionResult({
|
|
1091
|
+
id: session.id,
|
|
1092
|
+
status: session.status,
|
|
1093
|
+
paymentStatus: session.payment_status,
|
|
1094
|
+
mode: session.mode,
|
|
1095
|
+
customerId: typeof session.customer === "string" ? session.customer : (session.customer?.id ?? null),
|
|
1096
|
+
customerEmail: session.customer_details?.email ?? session.customer_email ?? null,
|
|
1097
|
+
subscriptionId,
|
|
1098
|
+
subscriptionStatus: normalizedSubscription?.subscriptionStatus ?? null,
|
|
1099
|
+
currentPeriodEnd: normalizedSubscription?.currentPeriodEnd ?? null,
|
|
1100
|
+
trialEndsAt: normalizedSubscription?.trialEndsAt ?? null,
|
|
1101
|
+
cancelAtPeriodEnd: normalizedSubscription?.cancelAtPeriodEnd ?? false,
|
|
1102
|
+
amountSubtotal: session.amount_subtotal,
|
|
1103
|
+
amountTotal: session.amount_total,
|
|
1104
|
+
currency: session.currency,
|
|
1105
|
+
metadata: session.metadata || {},
|
|
1106
|
+
lineItems: lineItems.data.map((item) => ({
|
|
1107
|
+
description: item.description ?? null,
|
|
1108
|
+
quantity: item.quantity,
|
|
1109
|
+
amountSubtotal: item.amount_subtotal,
|
|
1110
|
+
amountTotal: item.amount_total,
|
|
1111
|
+
currency: item.currency,
|
|
1112
|
+
priceId: item.price?.id ?? null,
|
|
1113
|
+
productId: item.price?.product && typeof item.price.product === "object"
|
|
1114
|
+
? "deleted" in item.price.product && item.price.product.deleted
|
|
1115
|
+
? null
|
|
1116
|
+
: (item.price.product.metadata?.productId ?? null)
|
|
1117
|
+
: null,
|
|
1118
|
+
})),
|
|
1119
|
+
});
|
|
1120
|
+
},
|
|
1121
|
+
async constructWebhookEvent(input) {
|
|
1122
|
+
if (!input.secret) {
|
|
1123
|
+
throw new Error("Stripe webhook secret is required to verify webhook events.");
|
|
1124
|
+
}
|
|
1125
|
+
const event = stripe.webhooks.constructEvent(input.payload, input.signature || "", input.secret);
|
|
1126
|
+
return {
|
|
1127
|
+
id: event.id,
|
|
1128
|
+
type: event.type,
|
|
1129
|
+
data: event.data.object,
|
|
1130
|
+
raw: event,
|
|
1131
|
+
};
|
|
1132
|
+
},
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
function resolveStripeInstance(input, env) {
|
|
1136
|
+
const candidate = input.instance || (env.secretKey ? new Stripe(env.secretKey) : undefined);
|
|
1137
|
+
if (!candidate) {
|
|
1138
|
+
throw new Error("Stripe integration requires a Stripe SDK instance or STRIPE_SECRET_KEY.");
|
|
1139
|
+
}
|
|
1140
|
+
return isStripeSdkInstance(candidate) ? createStripeAdapterFromSdk(candidate) : candidate;
|
|
1141
|
+
}
|
|
1142
|
+
function getProduct(products, productId) {
|
|
1143
|
+
const product = products.find((item) => item.id === productId);
|
|
1144
|
+
if (!product) {
|
|
1145
|
+
throw new Error(`Unknown Stripe product "${productId}".`);
|
|
1146
|
+
}
|
|
1147
|
+
return product;
|
|
1148
|
+
}
|
|
1149
|
+
async function readJsonObject(request) {
|
|
1150
|
+
try {
|
|
1151
|
+
const value = await request.json();
|
|
1152
|
+
if (value && typeof value === "object") {
|
|
1153
|
+
return value;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
catch {
|
|
1157
|
+
// Keep request parsing errors as empty input and validate below.
|
|
1158
|
+
}
|
|
1159
|
+
return {};
|
|
1160
|
+
}
|
|
1161
|
+
function getBillingPlan(billing, planId) {
|
|
1162
|
+
return billing?.plans?.[planId];
|
|
1163
|
+
}
|
|
1164
|
+
function getBillingTrial(billing, planId) {
|
|
1165
|
+
return getBillingPlan(billing, planId)?.trial;
|
|
1166
|
+
}
|
|
1167
|
+
function getBillingSeatsMode(billing) {
|
|
1168
|
+
return billing?.seats?.mode === "subscription_quantity" ? "subscription_quantity" : "plan_limit";
|
|
1169
|
+
}
|
|
1170
|
+
function normalizeBillingFeatures(value) {
|
|
1171
|
+
if (!value || typeof value !== "object") {
|
|
1172
|
+
return {};
|
|
1173
|
+
}
|
|
1174
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "boolean"));
|
|
1175
|
+
}
|
|
1176
|
+
function normalizeBillingLimits(value) {
|
|
1177
|
+
if (!value || typeof value !== "object") {
|
|
1178
|
+
return {};
|
|
1179
|
+
}
|
|
1180
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "number"));
|
|
1181
|
+
}
|
|
1182
|
+
function getBillingFeatures(billing, planId) {
|
|
1183
|
+
const plan = getBillingPlan(billing, planId);
|
|
1184
|
+
if (plan?.features) {
|
|
1185
|
+
return plan.features;
|
|
1186
|
+
}
|
|
1187
|
+
const legacyFeatures = plan?.entitlements && typeof plan.entitlements === "object"
|
|
1188
|
+
? plan.entitlements.features
|
|
1189
|
+
: undefined;
|
|
1190
|
+
return normalizeBillingFeatures(legacyFeatures);
|
|
1191
|
+
}
|
|
1192
|
+
function getConfiguredBillingLimits(billing, planId) {
|
|
1193
|
+
const plan = getBillingPlan(billing, planId);
|
|
1194
|
+
if (plan?.limits) {
|
|
1195
|
+
return plan.limits;
|
|
1196
|
+
}
|
|
1197
|
+
return normalizeBillingLimits(plan?.entitlements);
|
|
1198
|
+
}
|
|
1199
|
+
function getBillingSeatLimit(billing, planId, snapshot) {
|
|
1200
|
+
if (typeof snapshot?.seatAllowanceOverride === "number") {
|
|
1201
|
+
return {
|
|
1202
|
+
limit: snapshot.seatAllowanceOverride,
|
|
1203
|
+
source: "override",
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
if (getBillingSeatsMode(billing) === "subscription_quantity" &&
|
|
1207
|
+
typeof snapshot?.seatQuantity === "number") {
|
|
1208
|
+
return {
|
|
1209
|
+
limit: snapshot.seatQuantity,
|
|
1210
|
+
source: "subscription_quantity",
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
const configuredSeatLimit = getConfiguredBillingLimits(billing, planId).seats;
|
|
1214
|
+
if (typeof configuredSeatLimit === "number") {
|
|
1215
|
+
return {
|
|
1216
|
+
limit: configuredSeatLimit,
|
|
1217
|
+
source: "plan_limit",
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
return {
|
|
1221
|
+
limit: null,
|
|
1222
|
+
source: "none",
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
function getBillingLimits(billing, planId, snapshot) {
|
|
1226
|
+
const limits = {
|
|
1227
|
+
...getConfiguredBillingLimits(billing, planId),
|
|
1228
|
+
};
|
|
1229
|
+
const seatLimit = getBillingSeatLimit(billing, planId, snapshot);
|
|
1230
|
+
if (seatLimit.limit !== null) {
|
|
1231
|
+
limits.seats = seatLimit.limit;
|
|
1232
|
+
}
|
|
1233
|
+
return limits;
|
|
1234
|
+
}
|
|
1235
|
+
function getBillingEntitlements(billing, planId, snapshot) {
|
|
1236
|
+
const plan = getBillingPlan(billing, planId);
|
|
1237
|
+
if (plan?.entitlements) {
|
|
1238
|
+
const nextEntitlements = {
|
|
1239
|
+
...plan.entitlements,
|
|
1240
|
+
};
|
|
1241
|
+
const existingLimits = nextEntitlements.limits && typeof nextEntitlements.limits === "object"
|
|
1242
|
+
? normalizeBillingLimits(nextEntitlements.limits)
|
|
1243
|
+
: {};
|
|
1244
|
+
const limits = getBillingLimits(billing, planId, snapshot);
|
|
1245
|
+
if (Object.keys(limits).length > 0) {
|
|
1246
|
+
nextEntitlements.limits = {
|
|
1247
|
+
...existingLimits,
|
|
1248
|
+
...limits,
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
return nextEntitlements;
|
|
1252
|
+
}
|
|
1253
|
+
const features = getBillingFeatures(billing, planId);
|
|
1254
|
+
const limits = getBillingLimits(billing, planId, snapshot);
|
|
1255
|
+
if (Object.keys(features).length === 0 && Object.keys(limits).length === 0) {
|
|
1256
|
+
return {};
|
|
1257
|
+
}
|
|
1258
|
+
return {
|
|
1259
|
+
features,
|
|
1260
|
+
limits,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
function getBillingLimitForKey(billing, planId, snapshot, key) {
|
|
1264
|
+
const value = getBillingLimits(billing, planId, snapshot)[key];
|
|
1265
|
+
return typeof value === "number" ? value : null;
|
|
1266
|
+
}
|
|
1267
|
+
function resolvePlanLimitReference(value, defaultKey) {
|
|
1268
|
+
if (!value || value === "plan_limit" || typeof value === "number") {
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
if (typeof value === "string") {
|
|
1272
|
+
const match = /^plans\.([^.]+)\.limits?\.([^.]+)$/.exec(value);
|
|
1273
|
+
if (!match) {
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
return {
|
|
1277
|
+
planId: match[1],
|
|
1278
|
+
key: match[2],
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
if (typeof value.planId === "string" && value.planId.trim()) {
|
|
1282
|
+
return {
|
|
1283
|
+
planId: value.planId,
|
|
1284
|
+
key: typeof value.key === "string" && value.key.trim() ? value.key : defaultKey,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
function isPastDueBillingStatus(status) {
|
|
1290
|
+
return status === "past_due" || status === "unpaid";
|
|
1291
|
+
}
|
|
1292
|
+
function resolveMeterSoftLimit(billing, meter, planId, snapshot, key, includedLimit) {
|
|
1293
|
+
const configuredSoftLimit = meter.guard?.softLimit;
|
|
1294
|
+
if (configuredSoftLimit === undefined || configuredSoftLimit === "plan_limit") {
|
|
1295
|
+
return typeof includedLimit === "number" && includedLimit >= 0 ? includedLimit : null;
|
|
1296
|
+
}
|
|
1297
|
+
const reference = resolvePlanLimitReference(configuredSoftLimit, key);
|
|
1298
|
+
if (reference) {
|
|
1299
|
+
const referencedLimit = getBillingLimitForKey(billing, reference.planId, reference.planId === planId ? snapshot : null, reference.key ?? key);
|
|
1300
|
+
return typeof referencedLimit === "number" && referencedLimit >= 0 ? referencedLimit : null;
|
|
1301
|
+
}
|
|
1302
|
+
return typeof configuredSoftLimit === "number" &&
|
|
1303
|
+
Number.isInteger(configuredSoftLimit) &&
|
|
1304
|
+
configuredSoftLimit >= 0
|
|
1305
|
+
? configuredSoftLimit
|
|
1306
|
+
: null;
|
|
1307
|
+
}
|
|
1308
|
+
function resolveMeterHardLimit(meter, planId, includedLimit) {
|
|
1309
|
+
const hardLimitByPlan = meter.guard?.hardLimitByPlan?.[planId];
|
|
1310
|
+
if (typeof hardLimitByPlan === "number" &&
|
|
1311
|
+
Number.isInteger(hardLimitByPlan) &&
|
|
1312
|
+
hardLimitByPlan >= 0) {
|
|
1313
|
+
return hardLimitByPlan;
|
|
1314
|
+
}
|
|
1315
|
+
const hardOverageByPlan = meter.guard?.hardOverageByPlan?.[planId];
|
|
1316
|
+
if (typeof hardOverageByPlan === "number" &&
|
|
1317
|
+
Number.isInteger(hardOverageByPlan) &&
|
|
1318
|
+
hardOverageByPlan >= 0 &&
|
|
1319
|
+
typeof includedLimit === "number" &&
|
|
1320
|
+
includedLimit >= 0) {
|
|
1321
|
+
return includedLimit + hardOverageByPlan;
|
|
1322
|
+
}
|
|
1323
|
+
if (Number.isInteger(meter.guard?.hardLimit) && meter.guard.hardLimit >= 0) {
|
|
1324
|
+
return meter.guard.hardLimit;
|
|
1325
|
+
}
|
|
1326
|
+
if (Number.isInteger(meter.guard?.hardOverage) &&
|
|
1327
|
+
meter.guard.hardOverage >= 0 &&
|
|
1328
|
+
typeof includedLimit === "number" &&
|
|
1329
|
+
includedLimit >= 0) {
|
|
1330
|
+
return includedLimit + meter.guard.hardOverage;
|
|
1331
|
+
}
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
function evaluateStripeMeterUsage(input) {
|
|
1335
|
+
const { meter, billingStatus, attached, currentPeriodUsed, includedLimit, softLimit, hardLimit } = input;
|
|
1336
|
+
const remainingIncluded = typeof includedLimit === "number" && includedLimit >= 0
|
|
1337
|
+
? Math.max(0, includedLimit - currentPeriodUsed)
|
|
1338
|
+
: null;
|
|
1339
|
+
const remainingHard = typeof hardLimit === "number" && hardLimit >= 0
|
|
1340
|
+
? Math.max(0, hardLimit - currentPeriodUsed)
|
|
1341
|
+
: null;
|
|
1342
|
+
if ((meter.guard?.blockOnPastDue ?? true) && isPastDueBillingStatus(billingStatus)) {
|
|
1343
|
+
return {
|
|
1344
|
+
currentPeriodUsed,
|
|
1345
|
+
includedLimit,
|
|
1346
|
+
softLimit,
|
|
1347
|
+
hardLimit,
|
|
1348
|
+
remainingIncluded,
|
|
1349
|
+
remainingHard,
|
|
1350
|
+
state: "blocked_past_due",
|
|
1351
|
+
warning: "This subscription is past due, so additional metered usage is blocked until billing is brought current.",
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
if (!attached) {
|
|
1355
|
+
return {
|
|
1356
|
+
currentPeriodUsed,
|
|
1357
|
+
includedLimit,
|
|
1358
|
+
softLimit,
|
|
1359
|
+
hardLimit,
|
|
1360
|
+
remainingIncluded,
|
|
1361
|
+
remainingHard,
|
|
1362
|
+
state: "subscription_missing_meter_price",
|
|
1363
|
+
warning: "This subscription is missing the metered price item for this usage key, so Stripe cannot invoice the reported usage yet.",
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
if (typeof hardLimit === "number" && hardLimit >= 0 && currentPeriodUsed >= hardLimit) {
|
|
1367
|
+
return {
|
|
1368
|
+
currentPeriodUsed,
|
|
1369
|
+
includedLimit,
|
|
1370
|
+
softLimit,
|
|
1371
|
+
hardLimit,
|
|
1372
|
+
remainingIncluded,
|
|
1373
|
+
remainingHard,
|
|
1374
|
+
state: "hard_limit_reached",
|
|
1375
|
+
warning: "The configured metered hard cap has been reached for the current billing period. Reported usage is blocked until the next cycle or a plan change.",
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
if (typeof softLimit === "number" && softLimit >= 0 && currentPeriodUsed >= softLimit) {
|
|
1379
|
+
return {
|
|
1380
|
+
currentPeriodUsed,
|
|
1381
|
+
includedLimit,
|
|
1382
|
+
softLimit,
|
|
1383
|
+
hardLimit,
|
|
1384
|
+
remainingIncluded,
|
|
1385
|
+
remainingHard,
|
|
1386
|
+
state: "soft_limit_reached",
|
|
1387
|
+
warning: "The included monthly allowance has been reached. Additional metered usage is now in overage territory.",
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
currentPeriodUsed,
|
|
1392
|
+
includedLimit,
|
|
1393
|
+
softLimit,
|
|
1394
|
+
hardLimit,
|
|
1395
|
+
remainingIncluded,
|
|
1396
|
+
remainingHard,
|
|
1397
|
+
state: "ok",
|
|
1398
|
+
warning: null,
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
function resolveDesiredSubscriptionQuantity(snapshot, billing, product) {
|
|
1402
|
+
if (product.seatBilling === "included_plus_add_on") {
|
|
1403
|
+
const includedSeats = getConfiguredSeatBaseLimit(billing, product) ?? product.quantity ?? 1;
|
|
1404
|
+
const currentQuantity = typeof snapshot.seatQuantity === "number" && snapshot.seatQuantity > 0
|
|
1405
|
+
? snapshot.seatQuantity
|
|
1406
|
+
: includedSeats;
|
|
1407
|
+
return Math.max(includedSeats, currentQuantity, 1);
|
|
1408
|
+
}
|
|
1409
|
+
if (typeof snapshot.seatQuantity === "number" && snapshot.seatQuantity > 0) {
|
|
1410
|
+
return snapshot.seatQuantity;
|
|
1411
|
+
}
|
|
1412
|
+
return Math.max(product.quantity ?? 1, 1);
|
|
1413
|
+
}
|
|
1414
|
+
function getSubscriptionCurrentPeriodStart(subscription) {
|
|
1415
|
+
const startTime = subscription.items.data[0]?.current_period_start;
|
|
1416
|
+
return typeof startTime === "number" ? new Date(startTime * 1000).toISOString() : null;
|
|
1417
|
+
}
|
|
1418
|
+
function getSubscriptionCurrentPeriodEnd(subscription) {
|
|
1419
|
+
const endTime = subscription.items.data[0]?.current_period_end;
|
|
1420
|
+
return typeof endTime === "number" ? new Date(endTime * 1000).toISOString() : null;
|
|
1421
|
+
}
|
|
1422
|
+
function deriveFallbackCurrentPeriodStart(currentPeriodEnd, product) {
|
|
1423
|
+
if (!currentPeriodEnd || !product?.interval) {
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
const date = new Date(currentPeriodEnd);
|
|
1427
|
+
if (Number.isNaN(date.getTime())) {
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
const intervalCount = typeof product.intervalCount === "number" && product.intervalCount > 0
|
|
1431
|
+
? product.intervalCount
|
|
1432
|
+
: 1;
|
|
1433
|
+
switch (product.interval) {
|
|
1434
|
+
case "day":
|
|
1435
|
+
date.setDate(date.getDate() - intervalCount);
|
|
1436
|
+
break;
|
|
1437
|
+
case "week":
|
|
1438
|
+
date.setDate(date.getDate() - intervalCount * 7);
|
|
1439
|
+
break;
|
|
1440
|
+
case "month":
|
|
1441
|
+
date.setMonth(date.getMonth() - intervalCount);
|
|
1442
|
+
break;
|
|
1443
|
+
case "year":
|
|
1444
|
+
date.setFullYear(date.getFullYear() - intervalCount);
|
|
1445
|
+
break;
|
|
1446
|
+
}
|
|
1447
|
+
return date.toISOString();
|
|
1448
|
+
}
|
|
1449
|
+
function alignIsoToMinuteTimestamp(value, mode) {
|
|
1450
|
+
const date = new Date(value);
|
|
1451
|
+
const timestamp = date.getTime();
|
|
1452
|
+
if (Number.isNaN(timestamp)) {
|
|
1453
|
+
throw new Error("Stripe meter usage requires a valid period timestamp.");
|
|
1454
|
+
}
|
|
1455
|
+
const minute = 60_000;
|
|
1456
|
+
const aligned = mode === "floor"
|
|
1457
|
+
? Math.floor(timestamp / minute) * minute
|
|
1458
|
+
: Math.ceil(timestamp / minute) * minute;
|
|
1459
|
+
return Math.floor(aligned / 1000);
|
|
1460
|
+
}
|
|
1461
|
+
async function ensureConfiguredMeterItemsAttached(input) {
|
|
1462
|
+
const { owner, snapshot, product, billing, products, persistence, stripeSdk, instance } = input;
|
|
1463
|
+
let nextSnapshot = snapshot;
|
|
1464
|
+
let nextProduct = product;
|
|
1465
|
+
if (!stripeSdk ||
|
|
1466
|
+
!snapshot.stripeSubscriptionId ||
|
|
1467
|
+
typeof instance.updateSubscription !== "function") {
|
|
1468
|
+
return nextSnapshot;
|
|
1469
|
+
}
|
|
1470
|
+
const subscription = await stripeSdk.subscriptions.retrieve(snapshot.stripeSubscriptionId, {
|
|
1471
|
+
expand: ["items.data.price.product"],
|
|
1472
|
+
});
|
|
1473
|
+
if ("deleted" in subscription && subscription.deleted) {
|
|
1474
|
+
return nextSnapshot;
|
|
1475
|
+
}
|
|
1476
|
+
const inferredProduct = inferConfiguredProductForSnapshot(products, nextSnapshot, subscription);
|
|
1477
|
+
if (inferredProduct && nextSnapshot.productId !== inferredProduct.id) {
|
|
1478
|
+
nextSnapshot = {
|
|
1479
|
+
...nextSnapshot,
|
|
1480
|
+
productId: inferredProduct.id,
|
|
1481
|
+
};
|
|
1482
|
+
await persistBillingSnapshot(nextSnapshot, billing, persistence, snapshot);
|
|
1483
|
+
nextProduct = inferredProduct;
|
|
1484
|
+
}
|
|
1485
|
+
else if (!nextProduct) {
|
|
1486
|
+
nextProduct = inferredProduct;
|
|
1487
|
+
}
|
|
1488
|
+
if (!nextProduct ||
|
|
1489
|
+
nextProduct.kind !== "subscription" ||
|
|
1490
|
+
!nextProduct.meterPriceIds ||
|
|
1491
|
+
Object.keys(nextProduct.meterPriceIds).length === 0) {
|
|
1492
|
+
return nextSnapshot;
|
|
1493
|
+
}
|
|
1494
|
+
const missingMeterPriceIds = Object.values(nextProduct.meterPriceIds).filter((priceId) => !subscription.items.data.some((item) => item.price.id === priceId));
|
|
1495
|
+
if (missingMeterPriceIds.length === 0) {
|
|
1496
|
+
return nextSnapshot;
|
|
1497
|
+
}
|
|
1498
|
+
const desiredQuantity = resolveDesiredSubscriptionQuantity(nextSnapshot, billing, nextProduct);
|
|
1499
|
+
const lineItems = resolveCheckoutLineItems(nextProduct, desiredQuantity, billing, nextSnapshot.planId);
|
|
1500
|
+
const subscriptionId = nextSnapshot.stripeSubscriptionId;
|
|
1501
|
+
if (!subscriptionId) {
|
|
1502
|
+
return nextSnapshot;
|
|
1503
|
+
}
|
|
1504
|
+
try {
|
|
1505
|
+
const updated = await instance.updateSubscription({
|
|
1506
|
+
subscriptionId,
|
|
1507
|
+
product: nextProduct,
|
|
1508
|
+
lineItems,
|
|
1509
|
+
prorationBehavior: "none",
|
|
1510
|
+
});
|
|
1511
|
+
nextSnapshot = createBillingSnapshotFromSubscriptionChange(owner, updated, billing, products, nextSnapshot);
|
|
1512
|
+
await persistBillingSnapshot(nextSnapshot, billing, persistence, snapshot);
|
|
1513
|
+
}
|
|
1514
|
+
catch (error) {
|
|
1515
|
+
const message = error instanceof Error ? error.message : "";
|
|
1516
|
+
if (message.includes("already using that Price") ||
|
|
1517
|
+
message.includes("already has this price")) {
|
|
1518
|
+
return nextSnapshot;
|
|
1519
|
+
}
|
|
1520
|
+
throw error;
|
|
1521
|
+
}
|
|
1522
|
+
return nextSnapshot;
|
|
1523
|
+
}
|
|
1524
|
+
async function prepareStripeMeterContext(input) {
|
|
1525
|
+
const { owner, key, billing, products, persistence, stripeSdk, instance } = input;
|
|
1526
|
+
const nextSnapshot = await ensureConfiguredMeterItemsAttached({
|
|
1527
|
+
owner,
|
|
1528
|
+
snapshot: input.snapshot,
|
|
1529
|
+
product: findConfiguredProduct(products, input.snapshot.productId),
|
|
1530
|
+
billing,
|
|
1531
|
+
products,
|
|
1532
|
+
persistence,
|
|
1533
|
+
stripeSdk,
|
|
1534
|
+
instance,
|
|
1535
|
+
});
|
|
1536
|
+
const product = findConfiguredProduct(products, nextSnapshot.productId);
|
|
1537
|
+
const attachedPriceId = product?.meterPriceIds?.[key] ?? null;
|
|
1538
|
+
if (!stripeSdk || !nextSnapshot.stripeSubscriptionId) {
|
|
1539
|
+
return {
|
|
1540
|
+
snapshot: nextSnapshot,
|
|
1541
|
+
product,
|
|
1542
|
+
attachedPriceId,
|
|
1543
|
+
subscriptionStatus: nextSnapshot.status,
|
|
1544
|
+
currentPeriodStart: deriveFallbackCurrentPeriodStart(nextSnapshot.currentPeriodEnd?.toISOString() ?? null, product) ?? null,
|
|
1545
|
+
currentPeriodEnd: nextSnapshot.currentPeriodEnd?.toISOString() ?? null,
|
|
1546
|
+
meterId: null,
|
|
1547
|
+
attached: false,
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
const subscription = await stripeSdk.subscriptions.retrieve(nextSnapshot.stripeSubscriptionId, {
|
|
1551
|
+
expand: ["items.data.price.product"],
|
|
1552
|
+
});
|
|
1553
|
+
if ("deleted" in subscription && subscription.deleted) {
|
|
1554
|
+
return {
|
|
1555
|
+
snapshot: nextSnapshot,
|
|
1556
|
+
product,
|
|
1557
|
+
attachedPriceId,
|
|
1558
|
+
subscriptionStatus: nextSnapshot.status,
|
|
1559
|
+
currentPeriodStart: null,
|
|
1560
|
+
currentPeriodEnd: nextSnapshot.currentPeriodEnd?.toISOString() ?? null,
|
|
1561
|
+
meterId: null,
|
|
1562
|
+
attached: false,
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
let meterId = null;
|
|
1566
|
+
if (attachedPriceId) {
|
|
1567
|
+
const price = await stripeSdk.prices.retrieve(attachedPriceId);
|
|
1568
|
+
if (!("deleted" in price && price.deleted)) {
|
|
1569
|
+
meterId = price.recurring?.meter ?? null;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return {
|
|
1573
|
+
snapshot: nextSnapshot,
|
|
1574
|
+
product,
|
|
1575
|
+
attachedPriceId,
|
|
1576
|
+
subscriptionStatus: normalizeBillingStatus(subscription.status, nextSnapshot.status),
|
|
1577
|
+
currentPeriodStart: getSubscriptionCurrentPeriodStart(subscription) ??
|
|
1578
|
+
deriveFallbackCurrentPeriodStart(getSubscriptionCurrentPeriodEnd(subscription) ??
|
|
1579
|
+
nextSnapshot.currentPeriodEnd?.toISOString() ??
|
|
1580
|
+
null, product),
|
|
1581
|
+
currentPeriodEnd: getSubscriptionCurrentPeriodEnd(subscription) ??
|
|
1582
|
+
nextSnapshot.currentPeriodEnd?.toISOString() ??
|
|
1583
|
+
null,
|
|
1584
|
+
meterId,
|
|
1585
|
+
attached: attachedPriceId
|
|
1586
|
+
? subscription.items.data.some((item) => item.price.id === attachedPriceId)
|
|
1587
|
+
: false,
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
async function loadStripeMeterCurrentPeriodUsage(input) {
|
|
1591
|
+
const { stripeSdk, meterId, customerId, currentPeriodStart, currentPeriodEnd } = input;
|
|
1592
|
+
if (!stripeSdk || !meterId || !currentPeriodStart || !currentPeriodEnd) {
|
|
1593
|
+
return 0;
|
|
1594
|
+
}
|
|
1595
|
+
let startTime = alignIsoToMinuteTimestamp(currentPeriodStart, "floor");
|
|
1596
|
+
let endTime = alignIsoToMinuteTimestamp(currentPeriodEnd, "ceil");
|
|
1597
|
+
if (endTime <= startTime) {
|
|
1598
|
+
endTime = startTime + 60;
|
|
1599
|
+
}
|
|
1600
|
+
const summaries = await stripeSdk.billing.meters.listEventSummaries(meterId, {
|
|
1601
|
+
customer: customerId,
|
|
1602
|
+
start_time: startTime,
|
|
1603
|
+
end_time: endTime,
|
|
1604
|
+
limit: 1,
|
|
1605
|
+
});
|
|
1606
|
+
return summaries.data[0]?.aggregated_value ?? 0;
|
|
1607
|
+
}
|
|
1608
|
+
function findConfiguredProduct(products, productId) {
|
|
1609
|
+
if (!productId) {
|
|
1610
|
+
return null;
|
|
1611
|
+
}
|
|
1612
|
+
return products.find((product) => product.id === productId) ?? null;
|
|
1613
|
+
}
|
|
1614
|
+
function getSubscriptionInterval(subscription) {
|
|
1615
|
+
for (const item of subscription.items.data) {
|
|
1616
|
+
const interval = item.price.recurring?.interval;
|
|
1617
|
+
if (interval) {
|
|
1618
|
+
return interval;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
return null;
|
|
1622
|
+
}
|
|
1623
|
+
function inferConfiguredProductForSnapshot(products, snapshot, subscription) {
|
|
1624
|
+
const existing = findConfiguredProduct(products, snapshot.productId);
|
|
1625
|
+
if (existing) {
|
|
1626
|
+
return existing;
|
|
1627
|
+
}
|
|
1628
|
+
const candidates = products.filter((product) => product.kind === "subscription" && product.planId === snapshot.planId);
|
|
1629
|
+
if (candidates.length === 0) {
|
|
1630
|
+
return null;
|
|
1631
|
+
}
|
|
1632
|
+
if (candidates.length === 1) {
|
|
1633
|
+
return candidates[0] ?? null;
|
|
1634
|
+
}
|
|
1635
|
+
const interval = subscription ? getSubscriptionInterval(subscription) : null;
|
|
1636
|
+
if (!interval) {
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
const intervalMatches = candidates.filter((product) => product.interval === interval);
|
|
1640
|
+
return intervalMatches.length === 1 ? (intervalMatches[0] ?? null) : null;
|
|
1641
|
+
}
|
|
1642
|
+
function getConfiguredSeatBaseLimit(billing, product) {
|
|
1643
|
+
if (!product?.planId) {
|
|
1644
|
+
return null;
|
|
1645
|
+
}
|
|
1646
|
+
const limit = getConfiguredBillingLimits(billing, product.planId).seats;
|
|
1647
|
+
return typeof limit === "number" && Number.isInteger(limit) && limit >= 0 ? limit : null;
|
|
1648
|
+
}
|
|
1649
|
+
function getSeatQuantityFromLineItems(session) {
|
|
1650
|
+
const quantity = session.lineItems[0]?.quantity;
|
|
1651
|
+
return typeof quantity === "number" && Number.isInteger(quantity) && quantity > 0
|
|
1652
|
+
? quantity
|
|
1653
|
+
: null;
|
|
1654
|
+
}
|
|
1655
|
+
function getSeatAddOnQuantityFromLineItems(session, seatPriceId) {
|
|
1656
|
+
for (const lineItem of session.lineItems) {
|
|
1657
|
+
if (lineItem.priceId !== seatPriceId) {
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
const quantity = lineItem.quantity;
|
|
1661
|
+
if (typeof quantity === "number" && Number.isInteger(quantity) && quantity > 0) {
|
|
1662
|
+
return quantity;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return 0;
|
|
1666
|
+
}
|
|
1667
|
+
function getSeatQuantityFromSubscriptionEvent(eventData) {
|
|
1668
|
+
const items = eventData.items;
|
|
1669
|
+
if (!items || typeof items !== "object" || !("data" in items) || !Array.isArray(items.data)) {
|
|
1670
|
+
return null;
|
|
1671
|
+
}
|
|
1672
|
+
const firstItem = items.data[0];
|
|
1673
|
+
if (!firstItem || typeof firstItem !== "object" || !("quantity" in firstItem)) {
|
|
1674
|
+
return null;
|
|
1675
|
+
}
|
|
1676
|
+
const quantity = firstItem.quantity;
|
|
1677
|
+
return typeof quantity === "number" && Number.isInteger(quantity) && quantity > 0
|
|
1678
|
+
? quantity
|
|
1679
|
+
: null;
|
|
1680
|
+
}
|
|
1681
|
+
function getSeatAddOnQuantityFromSubscriptionEvent(eventData, seatPriceId) {
|
|
1682
|
+
const items = eventData.items;
|
|
1683
|
+
if (!items || typeof items !== "object" || !("data" in items) || !Array.isArray(items.data)) {
|
|
1684
|
+
return 0;
|
|
1685
|
+
}
|
|
1686
|
+
for (const item of items.data) {
|
|
1687
|
+
if (!item || typeof item !== "object") {
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
const price = "price" in item && item.price && typeof item.price === "object" ? item.price : null;
|
|
1691
|
+
if (!price || !("id" in price) || price.id !== seatPriceId) {
|
|
1692
|
+
continue;
|
|
1693
|
+
}
|
|
1694
|
+
const quantity = "quantity" in item ? item.quantity : null;
|
|
1695
|
+
if (typeof quantity === "number" && Number.isInteger(quantity) && quantity > 0) {
|
|
1696
|
+
return quantity;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
return 0;
|
|
1700
|
+
}
|
|
1701
|
+
function resolveSeatQuantityFromSession(session, billing, products, previousSnapshot) {
|
|
1702
|
+
const configuredProduct = findConfiguredProduct(products, resolveProductIdFromSession(session));
|
|
1703
|
+
if (configuredProduct?.seatBilling === "included_plus_add_on") {
|
|
1704
|
+
const includedSeats = getConfiguredSeatBaseLimit(billing, configuredProduct);
|
|
1705
|
+
if (includedSeats !== null) {
|
|
1706
|
+
return (includedSeats +
|
|
1707
|
+
(configuredProduct.seatPriceId
|
|
1708
|
+
? getSeatAddOnQuantityFromLineItems(session, configuredProduct.seatPriceId)
|
|
1709
|
+
: 0));
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
return getSeatQuantityFromLineItems(session) ?? previousSnapshot?.seatQuantity ?? null;
|
|
1713
|
+
}
|
|
1714
|
+
function resolveSeatQuantityFromSubscriptionData(eventData, billing, products, existingSnapshot) {
|
|
1715
|
+
const configuredProduct = findConfiguredProduct(products, existingSnapshot.productId);
|
|
1716
|
+
if (configuredProduct?.seatBilling === "included_plus_add_on") {
|
|
1717
|
+
const includedSeats = getConfiguredSeatBaseLimit(billing, configuredProduct);
|
|
1718
|
+
if (includedSeats !== null) {
|
|
1719
|
+
return (includedSeats +
|
|
1720
|
+
(configuredProduct.seatPriceId
|
|
1721
|
+
? getSeatAddOnQuantityFromSubscriptionEvent(eventData, configuredProduct.seatPriceId)
|
|
1722
|
+
: 0));
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return getSeatQuantityFromSubscriptionEvent(eventData) ?? existingSnapshot.seatQuantity;
|
|
1726
|
+
}
|
|
1727
|
+
async function resolveBillingUsage(billing, owner, key, tools) {
|
|
1728
|
+
if (!billing?.usage) {
|
|
1729
|
+
return null;
|
|
1730
|
+
}
|
|
1731
|
+
const value = await billing.usage.resolve(owner, key, tools);
|
|
1732
|
+
return typeof value === "number" ? value : null;
|
|
1733
|
+
}
|
|
1734
|
+
function getBillingMeter(billing, key) {
|
|
1735
|
+
const meter = billing?.meters?.[key];
|
|
1736
|
+
return meter ?? null;
|
|
1737
|
+
}
|
|
1738
|
+
function hasConfiguredStorageRuntimeClient(context) {
|
|
1739
|
+
const storage = context.config.storage;
|
|
1740
|
+
return (!!storage &&
|
|
1741
|
+
typeof storage === "object" &&
|
|
1742
|
+
"client" in storage &&
|
|
1743
|
+
storage.client != null);
|
|
1744
|
+
}
|
|
1745
|
+
async function resolveConfiguredStorageRuntimeClient(context) {
|
|
1746
|
+
if (!hasConfiguredStorageRuntimeClient(context)) {
|
|
1747
|
+
return undefined;
|
|
1748
|
+
}
|
|
1749
|
+
const storage = context.config.storage;
|
|
1750
|
+
const client = storage?.client;
|
|
1751
|
+
return typeof client === "function"
|
|
1752
|
+
? await client()
|
|
1753
|
+
: client;
|
|
1754
|
+
}
|
|
1755
|
+
function createBillingHookTools(context, stripe, schema) {
|
|
1756
|
+
let clientPromise;
|
|
1757
|
+
let ormPromise;
|
|
1758
|
+
return {
|
|
1759
|
+
ctx: context,
|
|
1760
|
+
stripe,
|
|
1761
|
+
storage: {
|
|
1762
|
+
getClient() {
|
|
1763
|
+
clientPromise ??= resolveConfiguredStorageRuntimeClient(context);
|
|
1764
|
+
return clientPromise;
|
|
1765
|
+
},
|
|
1766
|
+
getOrm() {
|
|
1767
|
+
ormPromise ??= createIntegrationOrm({
|
|
1768
|
+
schema,
|
|
1769
|
+
config: context.config,
|
|
1770
|
+
});
|
|
1771
|
+
return ormPromise;
|
|
1772
|
+
},
|
|
1773
|
+
},
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
function resolveConfiguredBillingStorage(context, tools) {
|
|
1777
|
+
if (!hasConfiguredStorageRuntimeClient(context)) {
|
|
1778
|
+
return undefined;
|
|
1779
|
+
}
|
|
1780
|
+
return ormStorageAdapter({
|
|
1781
|
+
orm: () => tools.storage.getOrm(),
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
function resolveBillingPersistence(billing, context, stripe, schema = stripeSchema, tools = createBillingHookTools(context, stripe, schema)) {
|
|
1785
|
+
const storage = billing?.storage ?? resolveConfiguredBillingStorage(context, tools);
|
|
1786
|
+
return {
|
|
1787
|
+
tools,
|
|
1788
|
+
getBillingAccount: billing?.hooks?.getBillingAccount
|
|
1789
|
+
? (owner) => billing.hooks.getBillingAccount(owner, tools)
|
|
1790
|
+
: storage?.getBillingAccount
|
|
1791
|
+
? (owner) => storage.getBillingAccount(owner)
|
|
1792
|
+
: undefined,
|
|
1793
|
+
getBillingAccountByStripeCustomerId: billing?.hooks?.getBillingAccountByStripeCustomerId
|
|
1794
|
+
? (customerId) => billing.hooks.getBillingAccountByStripeCustomerId(customerId, tools)
|
|
1795
|
+
: storage?.getBillingAccountByStripeCustomerId
|
|
1796
|
+
? (customerId) => storage.getBillingAccountByStripeCustomerId(customerId)
|
|
1797
|
+
: undefined,
|
|
1798
|
+
ensureCustomer: billing?.hooks?.ensureCustomer
|
|
1799
|
+
? (owner) => billing.hooks.ensureCustomer(owner, tools)
|
|
1800
|
+
: storage?.ensureCustomer
|
|
1801
|
+
? stripe
|
|
1802
|
+
? (owner) => storage.ensureCustomer({
|
|
1803
|
+
owner,
|
|
1804
|
+
stripe,
|
|
1805
|
+
})
|
|
1806
|
+
: undefined
|
|
1807
|
+
: undefined,
|
|
1808
|
+
saveBillingSnapshot: billing?.hooks?.saveBillingSnapshot
|
|
1809
|
+
? (snapshot) => billing.hooks.saveBillingSnapshot(snapshot, tools)
|
|
1810
|
+
: storage?.saveBillingSnapshot
|
|
1811
|
+
? (snapshot) => storage.saveBillingSnapshot(snapshot)
|
|
1812
|
+
: undefined,
|
|
1813
|
+
clearBillingSnapshot: billing?.hooks?.clearBillingSnapshot
|
|
1814
|
+
? (owner) => billing.hooks.clearBillingSnapshot(owner, tools)
|
|
1815
|
+
: storage?.clearBillingSnapshot
|
|
1816
|
+
? (owner) => storage.clearBillingSnapshot(owner)
|
|
1817
|
+
: undefined,
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
function requireBillingMethod(value, label) {
|
|
1821
|
+
if (!value) {
|
|
1822
|
+
throw new Error(`Stripe billing requires ${label}.`);
|
|
1823
|
+
}
|
|
1824
|
+
return value;
|
|
1825
|
+
}
|
|
1826
|
+
async function resolveBillingOwner(billing, context, tools) {
|
|
1827
|
+
return await billing.resolveOwner(context, tools);
|
|
1828
|
+
}
|
|
1829
|
+
function resolveProductIdFromSession(session) {
|
|
1830
|
+
return session.metadata.productId || session.lineItems[0]?.productId || null;
|
|
1831
|
+
}
|
|
1832
|
+
function resolvePlanIdFromSession(session, fallbackPlanId = "free") {
|
|
1833
|
+
return session.metadata.planId || fallbackPlanId;
|
|
1834
|
+
}
|
|
1835
|
+
function resolvePlanIdForProduct(product, existingSnapshot) {
|
|
1836
|
+
return product.planId ?? existingSnapshot?.planId ?? "free";
|
|
1837
|
+
}
|
|
1838
|
+
function hasActiveBillingSnapshot(snapshot) {
|
|
1839
|
+
if (!snapshot) {
|
|
1840
|
+
return false;
|
|
1841
|
+
}
|
|
1842
|
+
return (snapshot.status === "trialing" ||
|
|
1843
|
+
snapshot.status === "active" ||
|
|
1844
|
+
snapshot.status === "past_due" ||
|
|
1845
|
+
snapshot.status === "unpaid" ||
|
|
1846
|
+
snapshot.status === "incomplete");
|
|
1847
|
+
}
|
|
1848
|
+
async function resolveCheckoutTrial(billing, owner, product, existingSnapshot, tools) {
|
|
1849
|
+
if (!billing || !owner || product.kind !== "subscription") {
|
|
1850
|
+
return null;
|
|
1851
|
+
}
|
|
1852
|
+
const planId = resolvePlanIdForProduct(product, existingSnapshot);
|
|
1853
|
+
const trial = getBillingTrial(billing, planId);
|
|
1854
|
+
if (!trial) {
|
|
1855
|
+
return null;
|
|
1856
|
+
}
|
|
1857
|
+
if (!Number.isInteger(trial.days) || trial.days <= 0) {
|
|
1858
|
+
throw new Error(`Stripe billing plan "${planId}" has an invalid trial.days value.`);
|
|
1859
|
+
}
|
|
1860
|
+
if (hasActiveBillingSnapshot(existingSnapshot)) {
|
|
1861
|
+
return null;
|
|
1862
|
+
}
|
|
1863
|
+
const hasUsedTrial = Boolean(existingSnapshot?.trialUsedAt);
|
|
1864
|
+
if ((trial.oncePerOwner ?? true) && hasUsedTrial) {
|
|
1865
|
+
return null;
|
|
1866
|
+
}
|
|
1867
|
+
if (trial.eligible) {
|
|
1868
|
+
const eligible = await trial.eligible({
|
|
1869
|
+
owner,
|
|
1870
|
+
planId,
|
|
1871
|
+
productId: product.id,
|
|
1872
|
+
existingSnapshot,
|
|
1873
|
+
hasUsedTrial,
|
|
1874
|
+
}, tools);
|
|
1875
|
+
if (!eligible) {
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
return trial;
|
|
1880
|
+
}
|
|
1881
|
+
function validateBillingCatalog(billing, products) {
|
|
1882
|
+
if (!billing) {
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
for (const [planId, plan] of Object.entries(billing.plans ?? {})) {
|
|
1886
|
+
if (!plan.trial) {
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
if (!Number.isInteger(plan.trial.days) || plan.trial.days <= 0) {
|
|
1890
|
+
throw new Error(`Stripe billing plan "${planId}" has an invalid trial.days value.`);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
for (const [key, meter] of Object.entries(billing.meters ?? {})) {
|
|
1894
|
+
if (!meter.eventName || !meter.eventName.trim()) {
|
|
1895
|
+
throw new Error(`Stripe billing meter "${key}" requires a non-empty eventName.`);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
for (const product of products) {
|
|
1899
|
+
if (product.kind === "subscription" && !product.planId) {
|
|
1900
|
+
throw new Error(`Stripe billing product "${product.id}" must set planId for subscription products.`);
|
|
1901
|
+
}
|
|
1902
|
+
if (product.planId && !billing.plans?.[product.planId]) {
|
|
1903
|
+
throw new Error(`Stripe billing product "${product.id}" references unknown plan "${product.planId}".`);
|
|
1904
|
+
}
|
|
1905
|
+
if (product.seatBilling === "included_plus_add_on" && product.kind !== "subscription") {
|
|
1906
|
+
throw new Error(`Stripe billing product "${product.id}" can only use seatBilling="included_plus_add_on" on subscription products.`);
|
|
1907
|
+
}
|
|
1908
|
+
if (product.seatPriceId && product.kind !== "subscription") {
|
|
1909
|
+
throw new Error(`Stripe billing product "${product.id}" can only set seatPriceId on subscription products.`);
|
|
1910
|
+
}
|
|
1911
|
+
if (product.meterPriceIds && product.kind !== "subscription") {
|
|
1912
|
+
throw new Error(`Stripe billing product "${product.id}" can only set meterPriceIds on subscription products.`);
|
|
1913
|
+
}
|
|
1914
|
+
for (const [meterKey, priceId] of Object.entries(product.meterPriceIds ?? {})) {
|
|
1915
|
+
if (!billing.meters?.[meterKey]) {
|
|
1916
|
+
throw new Error(`Stripe billing product "${product.id}" references unknown meter "${meterKey}" in meterPriceIds.`);
|
|
1917
|
+
}
|
|
1918
|
+
if (typeof priceId !== "string" || !priceId.trim()) {
|
|
1919
|
+
throw new Error(`Stripe billing product "${product.id}" requires a non-empty price id for meter "${meterKey}".`);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
if (product.seatBilling === "included_plus_add_on") {
|
|
1923
|
+
const configuredSeatLimit = product.planId != null ? getConfiguredBillingLimits(billing, product.planId).seats : null;
|
|
1924
|
+
if (typeof configuredSeatLimit !== "number" ||
|
|
1925
|
+
!Number.isInteger(configuredSeatLimit) ||
|
|
1926
|
+
configuredSeatLimit <= 0) {
|
|
1927
|
+
throw new Error(`Stripe billing product "${product.id}" requires a positive integer plans["${product.planId}"].limits.seats when seatBilling="included_plus_add_on".`);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
function createBillingSnapshot(owner, session, billing, products, fallbackPlanId = "free", previousSnapshot = null) {
|
|
1933
|
+
const status = session.mode === "subscription"
|
|
1934
|
+
? normalizeBillingStatus(session.subscriptionStatus, "active")
|
|
1935
|
+
: normalizeBillingStatus(session.paymentStatus, "active");
|
|
1936
|
+
const trialEndsAt = session.trialEndsAt
|
|
1937
|
+
? new Date(session.trialEndsAt)
|
|
1938
|
+
: (previousSnapshot?.trialEndsAt ?? null);
|
|
1939
|
+
const trialUsedAt = status === "trialing"
|
|
1940
|
+
? (previousSnapshot?.trialUsedAt ?? new Date())
|
|
1941
|
+
: (previousSnapshot?.trialUsedAt ?? null);
|
|
1942
|
+
const seatQuantity = getBillingSeatsMode(billing) === "subscription_quantity" && session.mode === "subscription"
|
|
1943
|
+
? resolveSeatQuantityFromSession(session, billing, products, previousSnapshot)
|
|
1944
|
+
: (previousSnapshot?.seatQuantity ?? null);
|
|
1945
|
+
return {
|
|
1946
|
+
owner,
|
|
1947
|
+
planId: resolvePlanIdFromSession(session, fallbackPlanId),
|
|
1948
|
+
productId: resolveProductIdFromSession(session),
|
|
1949
|
+
status,
|
|
1950
|
+
stripeCustomerId: session.customerId ?? null,
|
|
1951
|
+
stripeSubscriptionId: session.subscriptionId ?? null,
|
|
1952
|
+
currentPeriodEnd: session.currentPeriodEnd ? new Date(session.currentPeriodEnd) : null,
|
|
1953
|
+
cancelAtPeriodEnd: session.cancelAtPeriodEnd ?? false,
|
|
1954
|
+
trialEndsAt,
|
|
1955
|
+
trialUsedAt,
|
|
1956
|
+
seatQuantity,
|
|
1957
|
+
seatAllowanceOverride: previousSnapshot?.seatAllowanceOverride ?? null,
|
|
1958
|
+
metadata: session.metadata,
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
function createBillingSnapshotFromSubscriptionChange(owner, result, billing, products, previousSnapshot) {
|
|
1962
|
+
const nextSession = normalizeStripeSessionResult({
|
|
1963
|
+
id: result.subscriptionId,
|
|
1964
|
+
mode: "subscription",
|
|
1965
|
+
customerId: result.customerId,
|
|
1966
|
+
subscriptionId: result.subscriptionId,
|
|
1967
|
+
subscriptionStatus: result.subscriptionStatus,
|
|
1968
|
+
currentPeriodEnd: result.currentPeriodEnd,
|
|
1969
|
+
trialEndsAt: result.trialEndsAt,
|
|
1970
|
+
cancelAtPeriodEnd: result.cancelAtPeriodEnd,
|
|
1971
|
+
metadata: {
|
|
1972
|
+
...previousSnapshot.metadata,
|
|
1973
|
+
planId: previousSnapshot.planId,
|
|
1974
|
+
...(previousSnapshot.productId ? { productId: previousSnapshot.productId } : {}),
|
|
1975
|
+
},
|
|
1976
|
+
lineItems: result.lineItems,
|
|
1977
|
+
});
|
|
1978
|
+
const nextStatus = normalizeBillingStatus(result.subscriptionStatus, previousSnapshot.status);
|
|
1979
|
+
return {
|
|
1980
|
+
owner,
|
|
1981
|
+
planId: previousSnapshot.planId,
|
|
1982
|
+
productId: previousSnapshot.productId,
|
|
1983
|
+
status: nextStatus,
|
|
1984
|
+
stripeCustomerId: result.customerId ?? previousSnapshot.stripeCustomerId,
|
|
1985
|
+
stripeSubscriptionId: result.subscriptionId,
|
|
1986
|
+
currentPeriodEnd: result.currentPeriodEnd
|
|
1987
|
+
? new Date(result.currentPeriodEnd)
|
|
1988
|
+
: previousSnapshot.currentPeriodEnd,
|
|
1989
|
+
cancelAtPeriodEnd: result.cancelAtPeriodEnd,
|
|
1990
|
+
trialEndsAt: result.trialEndsAt ? new Date(result.trialEndsAt) : null,
|
|
1991
|
+
trialUsedAt: nextStatus === "trialing"
|
|
1992
|
+
? (previousSnapshot.trialUsedAt ?? new Date())
|
|
1993
|
+
: previousSnapshot.trialUsedAt,
|
|
1994
|
+
seatQuantity: getBillingSeatsMode(billing) === "subscription_quantity"
|
|
1995
|
+
? resolveSeatQuantityFromSession(nextSession, billing, products, previousSnapshot)
|
|
1996
|
+
: previousSnapshot.seatQuantity,
|
|
1997
|
+
seatAllowanceOverride: previousSnapshot.seatAllowanceOverride,
|
|
1998
|
+
metadata: {
|
|
1999
|
+
...previousSnapshot.metadata,
|
|
2000
|
+
...(previousSnapshot.productId ? { productId: previousSnapshot.productId } : {}),
|
|
2001
|
+
planId: previousSnapshot.planId,
|
|
2002
|
+
},
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
async function resolveBillingSnapshotForSession(session, products, billing, persistence, context) {
|
|
2006
|
+
if (!billing) {
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
let owner = await resolveBillingOwner(billing, context, persistence.tools);
|
|
2010
|
+
let existingSnapshot = null;
|
|
2011
|
+
if (session.customerId && persistence.getBillingAccountByStripeCustomerId) {
|
|
2012
|
+
const getByCustomerId = requireBillingMethod(persistence.getBillingAccountByStripeCustomerId, "getBillingAccountByStripeCustomerId");
|
|
2013
|
+
existingSnapshot = await getByCustomerId(session.customerId);
|
|
2014
|
+
if (!owner) {
|
|
2015
|
+
owner = existingSnapshot?.owner ?? null;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
if (!owner) {
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
return createBillingSnapshot(owner, session, billing, products, existingSnapshot?.planId ?? "free", existingSnapshot);
|
|
2022
|
+
}
|
|
2023
|
+
async function persistBillingSnapshot(snapshot, billing, persistence, previousSnapshot = null) {
|
|
2024
|
+
if (!billing) {
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
const saveBillingSnapshot = requireBillingMethod(persistence.saveBillingSnapshot, "saveBillingSnapshot");
|
|
2028
|
+
await saveBillingSnapshot(snapshot);
|
|
2029
|
+
await billing.hooks?.onBillingSync?.(snapshot, persistence.tools);
|
|
2030
|
+
const trialDays = getBillingTrial(billing, snapshot.planId)?.days ?? null;
|
|
2031
|
+
if (snapshot.status === "trialing" && previousSnapshot?.status !== "trialing") {
|
|
2032
|
+
await billing.hooks?.onTrialStarted?.({
|
|
2033
|
+
...snapshot,
|
|
2034
|
+
trialDays: trialDays ?? 0,
|
|
2035
|
+
}, persistence.tools);
|
|
2036
|
+
}
|
|
2037
|
+
else if (previousSnapshot?.status === "trialing" && snapshot.status === "active") {
|
|
2038
|
+
await billing.hooks?.onTrialEnded?.(snapshot, persistence.tools);
|
|
2039
|
+
}
|
|
2040
|
+
else if (previousSnapshot?.status === "trialing" && snapshot.status !== "trialing") {
|
|
2041
|
+
await billing.hooks?.onTrialExpired?.(snapshot, persistence.tools);
|
|
2042
|
+
}
|
|
2043
|
+
if (snapshot.status === "active" || snapshot.status === "trialing") {
|
|
2044
|
+
await billing.hooks?.onPaymentSucceeded?.(snapshot, persistence.tools);
|
|
2045
|
+
}
|
|
2046
|
+
else if (snapshot.status === "past_due" || snapshot.status === "unpaid") {
|
|
2047
|
+
await billing.hooks?.onPaymentFailed?.(snapshot, persistence.tools);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
export function stripe(input = {}) {
|
|
2051
|
+
const env = resolveEnv(input);
|
|
2052
|
+
const rawInstance = input.instance || (env.secretKey ? new Stripe(env.secretKey) : undefined);
|
|
2053
|
+
const instance = resolveStripeInstance(input, env);
|
|
2054
|
+
const stripeSdk = rawInstance && isStripeSdkInstance(rawInstance) ? rawInstance : null;
|
|
2055
|
+
const integrationSchema = input.schema ?? stripeSchema;
|
|
2056
|
+
const configuredProducts = input.billing?.products
|
|
2057
|
+
? normalizeBillingProducts(input.billing.products)
|
|
2058
|
+
: normalizeProducts(input.products);
|
|
2059
|
+
validateBillingCatalog(input.billing, configuredProducts);
|
|
2060
|
+
const publicProducts = configuredProducts.filter((product) => product.public !== false);
|
|
2061
|
+
const productsPath = (input.productsPath ??
|
|
2062
|
+
input.checkoutPath?.replace(/checkout$/, "products") ??
|
|
2063
|
+
"/billing/products");
|
|
2064
|
+
const statusPath = (input.statusPath ?? "/billing/status");
|
|
2065
|
+
const currentChargesPath = (input.currentChargesPath ??
|
|
2066
|
+
"/billing/current-charges");
|
|
2067
|
+
const featuresPath = (input.featuresPath ?? "/billing/features");
|
|
2068
|
+
const limitsPath = (input.limitsPath ?? "/billing/limits");
|
|
2069
|
+
const usagePath = (input.usagePath ?? "/billing/usage");
|
|
2070
|
+
const meterUsagePath = (input.meterUsagePath ??
|
|
2071
|
+
"/billing/meter-usage");
|
|
2072
|
+
const upcomingInvoicePath = (input.upcomingInvoicePath ??
|
|
2073
|
+
"/billing/upcoming-invoice");
|
|
2074
|
+
const reportUsagePath = (input.reportUsagePath ??
|
|
2075
|
+
"/billing/report-usage");
|
|
2076
|
+
const checkPath = (input.checkPath ?? "/billing/check");
|
|
2077
|
+
const checkoutPath = (input.checkoutPath ?? "/billing/checkout");
|
|
2078
|
+
const upgradePath = (input.upgradePath ?? "/billing/upgrade");
|
|
2079
|
+
const portalPath = (input.portalPath ?? "/billing/portal");
|
|
2080
|
+
const sessionPath = (input.sessionPath ?? "/billing/session");
|
|
2081
|
+
const webhookPath = input.webhookPath ?? "/billing/webhook";
|
|
2082
|
+
const webhookDefinitions = resolveStripeWebhooks(input, env, webhookPath);
|
|
2083
|
+
const successPath = resolvePath(input.successPath, "Stripe integration successPath", "/success");
|
|
2084
|
+
const cancelPath = resolvePath(input.cancelPath, "Stripe integration cancelPath", "/cancel");
|
|
2085
|
+
const webhookRoutes = webhookDefinitions.map((definition) => integrationRoute.post(definition.path, {
|
|
2086
|
+
responseFormat: "json",
|
|
2087
|
+
rawBody: true,
|
|
2088
|
+
async handler(request, context) {
|
|
2089
|
+
const payload = await request.text();
|
|
2090
|
+
const webhookContext = {
|
|
2091
|
+
request,
|
|
2092
|
+
route: context,
|
|
2093
|
+
rawBody: payload,
|
|
2094
|
+
headers: request.headers,
|
|
2095
|
+
webhook: {
|
|
2096
|
+
name: definition.name,
|
|
2097
|
+
path: definition.path,
|
|
2098
|
+
},
|
|
2099
|
+
};
|
|
2100
|
+
try {
|
|
2101
|
+
const verifiedEvent = await instance.constructWebhookEvent({
|
|
2102
|
+
payload,
|
|
2103
|
+
signature: request.headers.get("stripe-signature"),
|
|
2104
|
+
secret: definition.secret,
|
|
2105
|
+
});
|
|
2106
|
+
const event = {
|
|
2107
|
+
provider: "stripe",
|
|
2108
|
+
id: verifiedEvent.id,
|
|
2109
|
+
type: verifiedEvent.type,
|
|
2110
|
+
data: verifiedEvent.data,
|
|
2111
|
+
raw: verifiedEvent.raw ?? verifiedEvent,
|
|
2112
|
+
};
|
|
2113
|
+
if (input.billing) {
|
|
2114
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema);
|
|
2115
|
+
if (event.type === "checkout.session.completed" &&
|
|
2116
|
+
event.data &&
|
|
2117
|
+
typeof event.data === "object" &&
|
|
2118
|
+
"id" in event.data &&
|
|
2119
|
+
typeof event.data.id === "string") {
|
|
2120
|
+
const session = await instance.retrieveCheckoutSession(event.data.id);
|
|
2121
|
+
const snapshot = await resolveBillingSnapshotForSession(session, configuredProducts, input.billing, persistence, context);
|
|
2122
|
+
if (snapshot) {
|
|
2123
|
+
const previousSnapshot = session.customerId && persistence.getBillingAccountByStripeCustomerId
|
|
2124
|
+
? await persistence.getBillingAccountByStripeCustomerId(session.customerId)
|
|
2125
|
+
: null;
|
|
2126
|
+
await persistBillingSnapshot(snapshot, input.billing, persistence, previousSnapshot);
|
|
2127
|
+
await input.billing.hooks?.onCheckoutCompleted?.({
|
|
2128
|
+
...snapshot,
|
|
2129
|
+
sessionId: session.id,
|
|
2130
|
+
}, persistence.tools);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
else if (event.data &&
|
|
2134
|
+
typeof event.data === "object" &&
|
|
2135
|
+
"customer" in event.data &&
|
|
2136
|
+
typeof event.data.customer === "string" &&
|
|
2137
|
+
persistence.getBillingAccountByStripeCustomerId) {
|
|
2138
|
+
const existing = await persistence.getBillingAccountByStripeCustomerId(event.data.customer);
|
|
2139
|
+
if (existing) {
|
|
2140
|
+
let nextSnapshot = null;
|
|
2141
|
+
if (event.type === "invoice.payment_failed") {
|
|
2142
|
+
nextSnapshot = {
|
|
2143
|
+
...existing,
|
|
2144
|
+
status: "past_due",
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
else if (event.type === "invoice.paid") {
|
|
2148
|
+
nextSnapshot = {
|
|
2149
|
+
...existing,
|
|
2150
|
+
status: "active",
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
else if (event.type === "customer.subscription.trial_will_end") {
|
|
2154
|
+
const trialWillEndSnapshot = {
|
|
2155
|
+
...existing,
|
|
2156
|
+
trialEndsAt: "trial_end" in event.data && typeof event.data.trial_end === "number"
|
|
2157
|
+
? new Date(event.data.trial_end * 1000)
|
|
2158
|
+
: existing.trialEndsAt,
|
|
2159
|
+
};
|
|
2160
|
+
await input.billing.hooks?.onTrialWillEnd?.(trialWillEndSnapshot, persistence.tools);
|
|
2161
|
+
}
|
|
2162
|
+
else if (event.type === "customer.subscription.updated" &&
|
|
2163
|
+
"status" in event.data &&
|
|
2164
|
+
typeof event.data.status === "string") {
|
|
2165
|
+
const nextStatus = normalizeBillingStatus(event.data.status, existing.status);
|
|
2166
|
+
nextSnapshot = {
|
|
2167
|
+
...existing,
|
|
2168
|
+
status: nextStatus,
|
|
2169
|
+
stripeSubscriptionId: "id" in event.data && typeof event.data.id === "string"
|
|
2170
|
+
? event.data.id
|
|
2171
|
+
: existing.stripeSubscriptionId,
|
|
2172
|
+
currentPeriodEnd: "current_period_end" in event.data &&
|
|
2173
|
+
typeof event.data.current_period_end === "number"
|
|
2174
|
+
? new Date(event.data.current_period_end * 1000)
|
|
2175
|
+
: existing.currentPeriodEnd,
|
|
2176
|
+
trialEndsAt: "trial_end" in event.data && typeof event.data.trial_end === "number"
|
|
2177
|
+
? new Date(event.data.trial_end * 1000)
|
|
2178
|
+
: existing.trialEndsAt,
|
|
2179
|
+
trialUsedAt: nextStatus === "trialing"
|
|
2180
|
+
? (existing.trialUsedAt ?? new Date())
|
|
2181
|
+
: existing.trialUsedAt,
|
|
2182
|
+
seatQuantity: getBillingSeatsMode(input.billing) === "subscription_quantity"
|
|
2183
|
+
? resolveSeatQuantityFromSubscriptionData(event.data, input.billing, configuredProducts, existing)
|
|
2184
|
+
: existing.seatQuantity,
|
|
2185
|
+
seatAllowanceOverride: existing.seatAllowanceOverride,
|
|
2186
|
+
cancelAtPeriodEnd: "cancel_at_period_end" in event.data &&
|
|
2187
|
+
typeof event.data.cancel_at_period_end === "boolean"
|
|
2188
|
+
? event.data.cancel_at_period_end
|
|
2189
|
+
: existing.cancelAtPeriodEnd,
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
if (nextSnapshot) {
|
|
2193
|
+
await persistBillingSnapshot(nextSnapshot, input.billing, persistence, existing);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
await definition.onEvent?.(event, webhookContext);
|
|
2199
|
+
return Response.json({
|
|
2200
|
+
received: true,
|
|
2201
|
+
provider: "stripe",
|
|
2202
|
+
webhook: definition.name,
|
|
2203
|
+
eventId: event.id,
|
|
2204
|
+
type: event.type,
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
catch (error) {
|
|
2208
|
+
const override = await definition.onError?.(error, webhookContext);
|
|
2209
|
+
if (override) {
|
|
2210
|
+
return override;
|
|
2211
|
+
}
|
|
2212
|
+
return Response.json({
|
|
2213
|
+
error: error instanceof Error ? error.message : "Stripe webhook verification failed.",
|
|
2214
|
+
}, {
|
|
2215
|
+
status: 400,
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
},
|
|
2219
|
+
}));
|
|
2220
|
+
return defineIntegration({
|
|
2221
|
+
category: "payment",
|
|
2222
|
+
type: "stripe",
|
|
2223
|
+
instance: {
|
|
2224
|
+
products: configuredProducts,
|
|
2225
|
+
successPath,
|
|
2226
|
+
cancelPath,
|
|
2227
|
+
liveMode: !!env.secretKey,
|
|
2228
|
+
},
|
|
2229
|
+
api: createStripeApi({
|
|
2230
|
+
productsPath,
|
|
2231
|
+
statusPath,
|
|
2232
|
+
currentChargesPath,
|
|
2233
|
+
featuresPath,
|
|
2234
|
+
limitsPath,
|
|
2235
|
+
usagePath,
|
|
2236
|
+
meterUsagePath,
|
|
2237
|
+
upcomingInvoicePath,
|
|
2238
|
+
reportUsagePath,
|
|
2239
|
+
checkPath,
|
|
2240
|
+
checkoutPath,
|
|
2241
|
+
upgradePath,
|
|
2242
|
+
portalPath,
|
|
2243
|
+
sessionPath,
|
|
2244
|
+
}),
|
|
2245
|
+
schema: integrationSchema,
|
|
2246
|
+
config: integrationConfig({
|
|
2247
|
+
label: "Stripe integration",
|
|
2248
|
+
env: {
|
|
2249
|
+
secretKey: "STRIPE_SECRET_KEY",
|
|
2250
|
+
webhookSecret: "STRIPE_WEBHOOK_SECRET",
|
|
2251
|
+
appBaseUrl: "APP_BASE_URL",
|
|
2252
|
+
},
|
|
2253
|
+
input: env,
|
|
2254
|
+
required: input.instance ? [] : ["secretKey"],
|
|
2255
|
+
}),
|
|
2256
|
+
log: input.log,
|
|
2257
|
+
routes: [
|
|
2258
|
+
integrationRoute.get(productsPath, {
|
|
2259
|
+
responseFormat: "json",
|
|
2260
|
+
async handler() {
|
|
2261
|
+
try {
|
|
2262
|
+
return Response.json(await resolveCatalogProducts(stripeSdk, publicProducts, input.billing));
|
|
2263
|
+
}
|
|
2264
|
+
catch (error) {
|
|
2265
|
+
return Response.json({
|
|
2266
|
+
error: error instanceof Error ? error.message : "Stripe product listing failed.",
|
|
2267
|
+
}, {
|
|
2268
|
+
status: 400,
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
},
|
|
2272
|
+
}),
|
|
2273
|
+
integrationRoute.get(statusPath, {
|
|
2274
|
+
responseFormat: "json",
|
|
2275
|
+
async handler(_request, context) {
|
|
2276
|
+
if (!input.billing) {
|
|
2277
|
+
return Response.json({
|
|
2278
|
+
error: "Stripe billing is not configured for this integration.",
|
|
2279
|
+
}, {
|
|
2280
|
+
status: 400,
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
2284
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
2285
|
+
if (!owner) {
|
|
2286
|
+
return Response.json({
|
|
2287
|
+
error: "You need to sign in before loading billing status.",
|
|
2288
|
+
}, {
|
|
2289
|
+
status: 401,
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
2293
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
2294
|
+
let snapshot = await getBillingAccount(owner);
|
|
2295
|
+
if (snapshot) {
|
|
2296
|
+
try {
|
|
2297
|
+
snapshot = await ensureConfiguredMeterItemsAttached({
|
|
2298
|
+
owner,
|
|
2299
|
+
snapshot,
|
|
2300
|
+
product: findConfiguredProduct(configuredProducts, snapshot.productId),
|
|
2301
|
+
billing: input.billing,
|
|
2302
|
+
products: configuredProducts,
|
|
2303
|
+
persistence,
|
|
2304
|
+
stripeSdk,
|
|
2305
|
+
instance,
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
catch {
|
|
2309
|
+
// Keep status reads resilient even if Stripe add-on self-healing fails.
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
return Response.json(serializeBillingSnapshot(snapshot ?? {
|
|
2313
|
+
owner,
|
|
2314
|
+
planId: "free",
|
|
2315
|
+
productId: null,
|
|
2316
|
+
status: "free",
|
|
2317
|
+
stripeCustomerId: null,
|
|
2318
|
+
stripeSubscriptionId: null,
|
|
2319
|
+
currentPeriodEnd: null,
|
|
2320
|
+
cancelAtPeriodEnd: false,
|
|
2321
|
+
trialEndsAt: null,
|
|
2322
|
+
trialUsedAt: null,
|
|
2323
|
+
seatQuantity: null,
|
|
2324
|
+
seatAllowanceOverride: null,
|
|
2325
|
+
}, input.billing, getBillingFeatures(input.billing, snapshot?.planId ?? "free"), getBillingLimits(input.billing, snapshot?.planId ?? "free", snapshot), getBillingEntitlements(input.billing, snapshot?.planId ?? "free", snapshot)));
|
|
2326
|
+
},
|
|
2327
|
+
}),
|
|
2328
|
+
integrationRoute.get(featuresPath, {
|
|
2329
|
+
responseFormat: "json",
|
|
2330
|
+
async handler(_request, context) {
|
|
2331
|
+
if (!input.billing) {
|
|
2332
|
+
return Response.json({
|
|
2333
|
+
error: "Stripe billing is not configured for this integration.",
|
|
2334
|
+
}, {
|
|
2335
|
+
status: 400,
|
|
2336
|
+
});
|
|
2337
|
+
}
|
|
2338
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
2339
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
2340
|
+
if (!owner) {
|
|
2341
|
+
return Response.json({
|
|
2342
|
+
error: "You need to sign in before loading billing features.",
|
|
2343
|
+
}, {
|
|
2344
|
+
status: 401,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
2348
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
2349
|
+
const snapshot = await getBillingAccount(owner);
|
|
2350
|
+
const planId = snapshot?.planId ?? "free";
|
|
2351
|
+
return Response.json({
|
|
2352
|
+
planId,
|
|
2353
|
+
features: getBillingFeatures(input.billing, planId),
|
|
2354
|
+
});
|
|
2355
|
+
},
|
|
2356
|
+
}),
|
|
2357
|
+
integrationRoute.get(limitsPath, {
|
|
2358
|
+
responseFormat: "json",
|
|
2359
|
+
async handler(_request, context) {
|
|
2360
|
+
if (!input.billing) {
|
|
2361
|
+
return Response.json({
|
|
2362
|
+
error: "Stripe billing is not configured for this integration.",
|
|
2363
|
+
}, {
|
|
2364
|
+
status: 400,
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
2368
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
2369
|
+
if (!owner) {
|
|
2370
|
+
return Response.json({
|
|
2371
|
+
error: "You need to sign in before loading billing limits.",
|
|
2372
|
+
}, {
|
|
2373
|
+
status: 401,
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
2377
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
2378
|
+
const snapshot = await getBillingAccount(owner);
|
|
2379
|
+
const planId = snapshot?.planId ?? "free";
|
|
2380
|
+
return Response.json({
|
|
2381
|
+
planId,
|
|
2382
|
+
limits: getBillingLimits(input.billing, planId, snapshot),
|
|
2383
|
+
});
|
|
2384
|
+
},
|
|
2385
|
+
}),
|
|
2386
|
+
integrationRoute.post(usagePath, {
|
|
2387
|
+
responseFormat: "json",
|
|
2388
|
+
async handler(request, context) {
|
|
2389
|
+
if (!input.billing) {
|
|
2390
|
+
return Response.json({
|
|
2391
|
+
error: "Stripe billing is not configured for this integration.",
|
|
2392
|
+
}, {
|
|
2393
|
+
status: 400,
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
2397
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
2398
|
+
if (!owner) {
|
|
2399
|
+
return Response.json({
|
|
2400
|
+
error: "You need to sign in before loading billing usage.",
|
|
2401
|
+
}, {
|
|
2402
|
+
status: 401,
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
const body = await readJsonObject(request);
|
|
2406
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
2407
|
+
if (!key) {
|
|
2408
|
+
return Response.json({
|
|
2409
|
+
error: "Stripe billing usage requires a key.",
|
|
2410
|
+
}, {
|
|
2411
|
+
status: 400,
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
2415
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
2416
|
+
const snapshot = await getBillingAccount(owner);
|
|
2417
|
+
const planId = snapshot?.planId ?? "free";
|
|
2418
|
+
const limit = getBillingLimitForKey(input.billing, planId, snapshot, key);
|
|
2419
|
+
if (limit === null) {
|
|
2420
|
+
return Response.json({
|
|
2421
|
+
error: `Stripe billing limit "${key}" is not defined on plan "${planId}".`,
|
|
2422
|
+
}, {
|
|
2423
|
+
status: 404,
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
const used = await resolveBillingUsage(input.billing, owner, key, persistence.tools);
|
|
2427
|
+
return Response.json({
|
|
2428
|
+
planId,
|
|
2429
|
+
key,
|
|
2430
|
+
used,
|
|
2431
|
+
limit,
|
|
2432
|
+
remaining: typeof used === "number" && limit >= 0 ? Math.max(0, limit - used) : null,
|
|
2433
|
+
});
|
|
2434
|
+
},
|
|
2435
|
+
}),
|
|
2436
|
+
integrationRoute.post(meterUsagePath, {
|
|
2437
|
+
responseFormat: "json",
|
|
2438
|
+
async handler(request, context) {
|
|
2439
|
+
if (!input.billing) {
|
|
2440
|
+
return Response.json({
|
|
2441
|
+
error: "Stripe billing is not configured for this integration.",
|
|
2442
|
+
}, {
|
|
2443
|
+
status: 400,
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
2447
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
2448
|
+
if (!owner) {
|
|
2449
|
+
return Response.json({
|
|
2450
|
+
error: "You need to sign in before loading Stripe meter usage.",
|
|
2451
|
+
}, {
|
|
2452
|
+
status: 401,
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
2455
|
+
const body = await readJsonObject(request);
|
|
2456
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
2457
|
+
if (!key) {
|
|
2458
|
+
return Response.json({
|
|
2459
|
+
error: "Stripe meter usage requires a key.",
|
|
2460
|
+
}, {
|
|
2461
|
+
status: 400,
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
const meter = getBillingMeter(input.billing, key);
|
|
2465
|
+
if (!meter) {
|
|
2466
|
+
return Response.json({
|
|
2467
|
+
error: `Stripe billing meter "${key}" is not configured.`,
|
|
2468
|
+
}, {
|
|
2469
|
+
status: 404,
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
2473
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
2474
|
+
const snapshot = await getBillingAccount(owner);
|
|
2475
|
+
if (!snapshot?.stripeCustomerId) {
|
|
2476
|
+
return Response.json({
|
|
2477
|
+
error: "The active billing owner does not have a Stripe customer yet. Subscribe first before loading meter usage.",
|
|
2478
|
+
}, {
|
|
2479
|
+
status: 400,
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
if (!stripeSdk) {
|
|
2483
|
+
return Response.json({
|
|
2484
|
+
error: "Stripe meter usage summaries require a real Stripe SDK instance instead of a mock adapter.",
|
|
2485
|
+
}, {
|
|
2486
|
+
status: 501,
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
const prepared = await prepareStripeMeterContext({
|
|
2490
|
+
owner,
|
|
2491
|
+
key,
|
|
2492
|
+
snapshot,
|
|
2493
|
+
billing: input.billing,
|
|
2494
|
+
products: configuredProducts,
|
|
2495
|
+
persistence,
|
|
2496
|
+
stripeSdk,
|
|
2497
|
+
instance,
|
|
2498
|
+
});
|
|
2499
|
+
const stripeCurrentPeriodUsed = await loadStripeMeterCurrentPeriodUsage({
|
|
2500
|
+
stripeSdk,
|
|
2501
|
+
meterId: prepared.meterId,
|
|
2502
|
+
customerId: prepared.snapshot.stripeCustomerId ?? snapshot.stripeCustomerId,
|
|
2503
|
+
currentPeriodStart: prepared.currentPeriodStart,
|
|
2504
|
+
currentPeriodEnd: prepared.currentPeriodEnd,
|
|
2505
|
+
});
|
|
2506
|
+
const currentPeriodUsed = resolveEffectiveStripeMeterCurrentPeriodUsed({
|
|
2507
|
+
customerId: prepared.snapshot.stripeCustomerId ?? snapshot.stripeCustomerId,
|
|
2508
|
+
key,
|
|
2509
|
+
currentPeriodStart: prepared.currentPeriodStart,
|
|
2510
|
+
currentPeriodEnd: prepared.currentPeriodEnd,
|
|
2511
|
+
currentPeriodUsed: stripeCurrentPeriodUsed,
|
|
2512
|
+
});
|
|
2513
|
+
const includedLimit = getBillingLimitForKey(input.billing, prepared.snapshot.planId, prepared.snapshot, key);
|
|
2514
|
+
const softLimit = resolveMeterSoftLimit(input.billing, meter, prepared.snapshot.planId, prepared.snapshot, key, includedLimit);
|
|
2515
|
+
const hardLimit = resolveMeterHardLimit(meter, prepared.snapshot.planId, includedLimit);
|
|
2516
|
+
const evaluation = evaluateStripeMeterUsage({
|
|
2517
|
+
meter,
|
|
2518
|
+
billingStatus: prepared.subscriptionStatus,
|
|
2519
|
+
attached: prepared.attached,
|
|
2520
|
+
currentPeriodUsed,
|
|
2521
|
+
includedLimit,
|
|
2522
|
+
softLimit,
|
|
2523
|
+
hardLimit,
|
|
2524
|
+
});
|
|
2525
|
+
return Response.json({
|
|
2526
|
+
planId: prepared.snapshot.planId,
|
|
2527
|
+
productId: prepared.snapshot.productId,
|
|
2528
|
+
key,
|
|
2529
|
+
eventName: meter.eventName,
|
|
2530
|
+
customerId: prepared.snapshot.stripeCustomerId ?? snapshot.stripeCustomerId,
|
|
2531
|
+
subscriptionId: prepared.snapshot.stripeSubscriptionId,
|
|
2532
|
+
subscriptionStatus: prepared.subscriptionStatus,
|
|
2533
|
+
attached: prepared.attached,
|
|
2534
|
+
attachedPriceId: prepared.attachedPriceId,
|
|
2535
|
+
currentPeriodStart: prepared.currentPeriodStart,
|
|
2536
|
+
currentPeriodEnd: prepared.currentPeriodEnd,
|
|
2537
|
+
currentPeriodUsed: evaluation.currentPeriodUsed,
|
|
2538
|
+
includedLimit: evaluation.includedLimit,
|
|
2539
|
+
softLimit: evaluation.softLimit,
|
|
2540
|
+
hardLimit: evaluation.hardLimit,
|
|
2541
|
+
remainingIncluded: evaluation.remainingIncluded,
|
|
2542
|
+
remainingHard: evaluation.remainingHard,
|
|
2543
|
+
state: evaluation.state,
|
|
2544
|
+
warning: evaluation.warning,
|
|
2545
|
+
});
|
|
2546
|
+
},
|
|
2547
|
+
}),
|
|
2548
|
+
integrationRoute.get(currentChargesPath, {
|
|
2549
|
+
responseFormat: "json",
|
|
2550
|
+
async handler(_request, context) {
|
|
2551
|
+
if (!input.billing) {
|
|
2552
|
+
return Response.json({
|
|
2553
|
+
error: "Stripe billing is not configured for this integration.",
|
|
2554
|
+
}, {
|
|
2555
|
+
status: 400,
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
2559
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
2560
|
+
if (!owner) {
|
|
2561
|
+
return Response.json({
|
|
2562
|
+
error: "You need to sign in before loading the current Stripe charges.",
|
|
2563
|
+
}, {
|
|
2564
|
+
status: 401,
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
2568
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
2569
|
+
const snapshot = await getBillingAccount(owner);
|
|
2570
|
+
if (!snapshot?.stripeCustomerId || !snapshot.stripeSubscriptionId) {
|
|
2571
|
+
return Response.json({
|
|
2572
|
+
error: "The active billing owner does not have a paid Stripe subscription yet. Subscribe first before loading current charges.",
|
|
2573
|
+
}, {
|
|
2574
|
+
status: 400,
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
const nextSnapshot = await ensureConfiguredMeterItemsAttached({
|
|
2578
|
+
owner,
|
|
2579
|
+
snapshot,
|
|
2580
|
+
product: findConfiguredProduct(configuredProducts, snapshot.productId),
|
|
2581
|
+
billing: input.billing,
|
|
2582
|
+
products: configuredProducts,
|
|
2583
|
+
persistence,
|
|
2584
|
+
stripeSdk,
|
|
2585
|
+
instance,
|
|
2586
|
+
});
|
|
2587
|
+
const product = findConfiguredProduct(configuredProducts, nextSnapshot.productId);
|
|
2588
|
+
const previewCustomerId = nextSnapshot.stripeCustomerId ?? snapshot.stripeCustomerId;
|
|
2589
|
+
const previewSubscriptionId = nextSnapshot.stripeSubscriptionId ?? snapshot.stripeSubscriptionId;
|
|
2590
|
+
if (!previewCustomerId || !previewSubscriptionId) {
|
|
2591
|
+
return Response.json({
|
|
2592
|
+
error: "The active billing owner does not have a Stripe subscription available for current charge preview.",
|
|
2593
|
+
}, {
|
|
2594
|
+
status: 400,
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
let preview;
|
|
2598
|
+
if (typeof instance.previewUpcomingInvoice === "function") {
|
|
2599
|
+
preview = await instance.previewUpcomingInvoice({
|
|
2600
|
+
customerId: previewCustomerId,
|
|
2601
|
+
subscriptionId: previewSubscriptionId,
|
|
2602
|
+
product,
|
|
2603
|
+
});
|
|
2604
|
+
}
|
|
2605
|
+
else if (stripeSdk) {
|
|
2606
|
+
preview = await createStripeUpcomingInvoicePreviewFromSdk({
|
|
2607
|
+
stripe: stripeSdk,
|
|
2608
|
+
customerId: previewCustomerId,
|
|
2609
|
+
subscriptionId: previewSubscriptionId,
|
|
2610
|
+
product,
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2613
|
+
else {
|
|
2614
|
+
return Response.json({
|
|
2615
|
+
error: "Current Stripe charges require either a real Stripe SDK instance or an adapter that supports previewUpcomingInvoice().",
|
|
2616
|
+
}, {
|
|
2617
|
+
status: 501,
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
return Response.json(toStripeCurrentChargesResult({
|
|
2621
|
+
owner,
|
|
2622
|
+
snapshot: {
|
|
2623
|
+
...nextSnapshot,
|
|
2624
|
+
stripeCustomerId: previewCustomerId,
|
|
2625
|
+
stripeSubscriptionId: previewSubscriptionId,
|
|
2626
|
+
},
|
|
2627
|
+
subscriptionStatus: nextSnapshot.status,
|
|
2628
|
+
currentPeriodStart: null,
|
|
2629
|
+
currentPeriodEnd: nextSnapshot.currentPeriodEnd?.toISOString() ?? null,
|
|
2630
|
+
preview,
|
|
2631
|
+
billing: input.billing,
|
|
2632
|
+
}));
|
|
2633
|
+
},
|
|
2634
|
+
}),
|
|
2635
|
+
integrationRoute.get(upcomingInvoicePath, {
|
|
2636
|
+
responseFormat: "json",
|
|
2637
|
+
async handler(_request, context) {
|
|
2638
|
+
if (!input.billing) {
|
|
2639
|
+
return Response.json({
|
|
2640
|
+
error: "Stripe billing is not configured for this integration.",
|
|
2641
|
+
}, {
|
|
2642
|
+
status: 400,
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
2646
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
2647
|
+
if (!owner) {
|
|
2648
|
+
return Response.json({
|
|
2649
|
+
error: "You need to sign in before loading the upcoming Stripe invoice.",
|
|
2650
|
+
}, {
|
|
2651
|
+
status: 401,
|
|
2652
|
+
});
|
|
2653
|
+
}
|
|
2654
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
2655
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
2656
|
+
const snapshot = await getBillingAccount(owner);
|
|
2657
|
+
if (!snapshot?.stripeCustomerId || !snapshot.stripeSubscriptionId) {
|
|
2658
|
+
return Response.json({
|
|
2659
|
+
error: "The active billing owner does not have a paid Stripe subscription yet. Subscribe first before loading the upcoming invoice preview.",
|
|
2660
|
+
}, {
|
|
2661
|
+
status: 400,
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
const nextSnapshot = await ensureConfiguredMeterItemsAttached({
|
|
2665
|
+
owner,
|
|
2666
|
+
snapshot,
|
|
2667
|
+
product: findConfiguredProduct(configuredProducts, snapshot.productId),
|
|
2668
|
+
billing: input.billing,
|
|
2669
|
+
products: configuredProducts,
|
|
2670
|
+
persistence,
|
|
2671
|
+
stripeSdk,
|
|
2672
|
+
instance,
|
|
2673
|
+
});
|
|
2674
|
+
const product = findConfiguredProduct(configuredProducts, nextSnapshot.productId);
|
|
2675
|
+
const monthlyMeteringActive = product?.interval === "month" &&
|
|
2676
|
+
!!product.meterPriceIds &&
|
|
2677
|
+
Object.keys(product.meterPriceIds).length > 0;
|
|
2678
|
+
const note = product?.interval === "year"
|
|
2679
|
+
? "In this demo, Stripe metered token and API-call overage is attached to the monthly Pro and Business subscriptions only. Yearly products currently preview fixed recurring pricing and seat add-ons."
|
|
2680
|
+
: monthlyMeteringActive
|
|
2681
|
+
? "This preview comes from Stripe's upcoming invoice API and includes fixed subscription charges, seat add-ons, prorations, and any metered overage recorded so far this billing period."
|
|
2682
|
+
: null;
|
|
2683
|
+
const previewCustomerId = nextSnapshot.stripeCustomerId ?? snapshot.stripeCustomerId;
|
|
2684
|
+
const previewSubscriptionId = nextSnapshot.stripeSubscriptionId ?? snapshot.stripeSubscriptionId;
|
|
2685
|
+
if (!previewCustomerId || !previewSubscriptionId) {
|
|
2686
|
+
return Response.json({
|
|
2687
|
+
error: "The active billing owner does not have a Stripe subscription available for invoice preview.",
|
|
2688
|
+
}, {
|
|
2689
|
+
status: 400,
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
let preview;
|
|
2693
|
+
if (typeof instance.previewUpcomingInvoice === "function") {
|
|
2694
|
+
preview = await instance.previewUpcomingInvoice({
|
|
2695
|
+
customerId: previewCustomerId,
|
|
2696
|
+
subscriptionId: previewSubscriptionId,
|
|
2697
|
+
product,
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
else if (stripeSdk) {
|
|
2701
|
+
preview = await createStripeUpcomingInvoicePreviewFromSdk({
|
|
2702
|
+
stripe: stripeSdk,
|
|
2703
|
+
customerId: previewCustomerId,
|
|
2704
|
+
subscriptionId: previewSubscriptionId,
|
|
2705
|
+
product,
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
else {
|
|
2709
|
+
return Response.json({
|
|
2710
|
+
error: "Upcoming invoice previews require either a real Stripe SDK instance or an adapter that supports previewUpcomingInvoice().",
|
|
2711
|
+
}, {
|
|
2712
|
+
status: 501,
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
return Response.json({
|
|
2716
|
+
planId: nextSnapshot.planId,
|
|
2717
|
+
productId: nextSnapshot.productId,
|
|
2718
|
+
customerId: previewCustomerId,
|
|
2719
|
+
subscriptionId: previewSubscriptionId,
|
|
2720
|
+
subscriptionStatus: nextSnapshot.status,
|
|
2721
|
+
nextBillingAt: nextSnapshot.currentPeriodEnd?.toISOString() ?? null,
|
|
2722
|
+
currency: preview.currency,
|
|
2723
|
+
generatedAt: new Date().toISOString(),
|
|
2724
|
+
monthlyMeteringActive,
|
|
2725
|
+
note,
|
|
2726
|
+
totals: preview.totals,
|
|
2727
|
+
lines: preview.lines,
|
|
2728
|
+
});
|
|
2729
|
+
},
|
|
2730
|
+
}),
|
|
2731
|
+
integrationRoute.post(reportUsagePath, {
|
|
2732
|
+
responseFormat: "json",
|
|
2733
|
+
async handler(request, context) {
|
|
2734
|
+
if (!input.billing) {
|
|
2735
|
+
return Response.json({
|
|
2736
|
+
error: "Stripe billing is not configured for this integration.",
|
|
2737
|
+
}, {
|
|
2738
|
+
status: 400,
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
2742
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
2743
|
+
if (!owner) {
|
|
2744
|
+
return Response.json({
|
|
2745
|
+
error: "You need to sign in before reporting Stripe meter usage.",
|
|
2746
|
+
}, {
|
|
2747
|
+
status: 401,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
const body = await readJsonObject(request);
|
|
2751
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
2752
|
+
const quantity = typeof body.quantity === "number" && Number.isFinite(body.quantity)
|
|
2753
|
+
? body.quantity
|
|
2754
|
+
: Number.NaN;
|
|
2755
|
+
const idempotencyKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
|
|
2756
|
+
if (!key) {
|
|
2757
|
+
return Response.json({
|
|
2758
|
+
error: "Stripe billing usage reporting requires a key.",
|
|
2759
|
+
}, {
|
|
2760
|
+
status: 400,
|
|
2761
|
+
});
|
|
2762
|
+
}
|
|
2763
|
+
if (!Number.isFinite(quantity) || quantity <= 0) {
|
|
2764
|
+
return Response.json({
|
|
2765
|
+
error: "Stripe billing usage reporting requires a quantity greater than zero.",
|
|
2766
|
+
}, {
|
|
2767
|
+
status: 400,
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
if (!idempotencyKey) {
|
|
2771
|
+
return Response.json({
|
|
2772
|
+
error: "Stripe billing usage reporting requires an idempotencyKey.",
|
|
2773
|
+
}, {
|
|
2774
|
+
status: 400,
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
const meter = getBillingMeter(input.billing, key);
|
|
2778
|
+
if (!meter) {
|
|
2779
|
+
return Response.json({
|
|
2780
|
+
error: `Stripe billing meter "${key}" is not configured.`,
|
|
2781
|
+
}, {
|
|
2782
|
+
status: 404,
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
let occurredAt;
|
|
2786
|
+
try {
|
|
2787
|
+
occurredAt = resolveOccurredAt(body.occurredAt);
|
|
2788
|
+
}
|
|
2789
|
+
catch (error) {
|
|
2790
|
+
return Response.json({
|
|
2791
|
+
error: error instanceof Error
|
|
2792
|
+
? error.message
|
|
2793
|
+
: "Stripe billing usage reporting received an invalid occurredAt timestamp.",
|
|
2794
|
+
}, {
|
|
2795
|
+
status: 400,
|
|
2796
|
+
});
|
|
2797
|
+
}
|
|
2798
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
2799
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
2800
|
+
const snapshot = await getBillingAccount(owner);
|
|
2801
|
+
const stripeCustomerId = snapshot?.stripeCustomerId;
|
|
2802
|
+
if (!stripeCustomerId) {
|
|
2803
|
+
return Response.json({
|
|
2804
|
+
error: "The active billing owner does not have a Stripe customer yet. Subscribe first before reporting meter usage.",
|
|
2805
|
+
}, {
|
|
2806
|
+
status: 400,
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
if (typeof instance.reportUsage !== "function") {
|
|
2810
|
+
throw new Error("Stripe meter usage reporting requires an adapter that supports reportUsage.");
|
|
2811
|
+
}
|
|
2812
|
+
try {
|
|
2813
|
+
let currentPeriodUsed = null;
|
|
2814
|
+
let projectedCurrentPeriodUsed = null;
|
|
2815
|
+
let softLimit = null;
|
|
2816
|
+
let hardLimit = null;
|
|
2817
|
+
let state;
|
|
2818
|
+
let warning = null;
|
|
2819
|
+
let pendingProjectionContext = null;
|
|
2820
|
+
if ((meter.guard?.blockOnPastDue ?? true) && snapshot) {
|
|
2821
|
+
const blockedByStatus = evaluateStripeMeterUsage({
|
|
2822
|
+
meter,
|
|
2823
|
+
billingStatus: snapshot.status,
|
|
2824
|
+
attached: true,
|
|
2825
|
+
currentPeriodUsed: 0,
|
|
2826
|
+
includedLimit: getBillingLimitForKey(input.billing, snapshot.planId, snapshot, key),
|
|
2827
|
+
softLimit: resolveMeterSoftLimit(input.billing, meter, snapshot.planId, snapshot, key, getBillingLimitForKey(input.billing, snapshot.planId, snapshot, key)),
|
|
2828
|
+
hardLimit: resolveMeterHardLimit(meter, snapshot.planId, getBillingLimitForKey(input.billing, snapshot.planId, snapshot, key)),
|
|
2829
|
+
});
|
|
2830
|
+
if (blockedByStatus.state === "blocked_past_due") {
|
|
2831
|
+
return Response.json({
|
|
2832
|
+
error: blockedByStatus.warning,
|
|
2833
|
+
}, {
|
|
2834
|
+
status: 400,
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
if (stripeSdk && snapshot) {
|
|
2839
|
+
const prepared = await prepareStripeMeterContext({
|
|
2840
|
+
owner,
|
|
2841
|
+
key,
|
|
2842
|
+
snapshot,
|
|
2843
|
+
billing: input.billing,
|
|
2844
|
+
products: configuredProducts,
|
|
2845
|
+
persistence,
|
|
2846
|
+
stripeSdk,
|
|
2847
|
+
instance,
|
|
2848
|
+
});
|
|
2849
|
+
if (!prepared.attachedPriceId) {
|
|
2850
|
+
return Response.json({
|
|
2851
|
+
error: `The current subscription product "${prepared.snapshot.productId ?? "unknown"}" does not have a metered price configured for "${key}".`,
|
|
2852
|
+
}, {
|
|
2853
|
+
status: 400,
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
const stripeCurrentPeriodUsed = await loadStripeMeterCurrentPeriodUsage({
|
|
2857
|
+
stripeSdk,
|
|
2858
|
+
meterId: prepared.meterId,
|
|
2859
|
+
customerId: prepared.snapshot.stripeCustomerId ?? stripeCustomerId,
|
|
2860
|
+
currentPeriodStart: prepared.currentPeriodStart,
|
|
2861
|
+
currentPeriodEnd: prepared.currentPeriodEnd,
|
|
2862
|
+
});
|
|
2863
|
+
currentPeriodUsed = resolveEffectiveStripeMeterCurrentPeriodUsed({
|
|
2864
|
+
customerId: prepared.snapshot.stripeCustomerId ?? stripeCustomerId,
|
|
2865
|
+
key,
|
|
2866
|
+
currentPeriodStart: prepared.currentPeriodStart,
|
|
2867
|
+
currentPeriodEnd: prepared.currentPeriodEnd,
|
|
2868
|
+
currentPeriodUsed: stripeCurrentPeriodUsed,
|
|
2869
|
+
});
|
|
2870
|
+
const includedLimit = getBillingLimitForKey(input.billing, prepared.snapshot.planId, prepared.snapshot, key);
|
|
2871
|
+
softLimit = resolveMeterSoftLimit(input.billing, meter, prepared.snapshot.planId, prepared.snapshot, key, includedLimit);
|
|
2872
|
+
hardLimit = resolveMeterHardLimit(meter, prepared.snapshot.planId, includedLimit);
|
|
2873
|
+
const currentEvaluation = evaluateStripeMeterUsage({
|
|
2874
|
+
meter,
|
|
2875
|
+
billingStatus: prepared.subscriptionStatus,
|
|
2876
|
+
attached: prepared.attached,
|
|
2877
|
+
currentPeriodUsed,
|
|
2878
|
+
includedLimit,
|
|
2879
|
+
softLimit,
|
|
2880
|
+
hardLimit,
|
|
2881
|
+
});
|
|
2882
|
+
if (currentEvaluation.state === "blocked_past_due" ||
|
|
2883
|
+
currentEvaluation.state === "subscription_missing_meter_price" ||
|
|
2884
|
+
currentEvaluation.state === "hard_limit_reached") {
|
|
2885
|
+
return Response.json({
|
|
2886
|
+
error: currentEvaluation.warning,
|
|
2887
|
+
}, {
|
|
2888
|
+
status: 400,
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
pendingProjectionContext = {
|
|
2892
|
+
customerId: prepared.snapshot.stripeCustomerId ?? stripeCustomerId,
|
|
2893
|
+
currentPeriodStart: prepared.currentPeriodStart,
|
|
2894
|
+
currentPeriodEnd: prepared.currentPeriodEnd,
|
|
2895
|
+
};
|
|
2896
|
+
const existingProjection = getPendingStripeMeterProjectionEvent({
|
|
2897
|
+
customerId: pendingProjectionContext.customerId,
|
|
2898
|
+
key,
|
|
2899
|
+
currentPeriodStart: pendingProjectionContext.currentPeriodStart,
|
|
2900
|
+
currentPeriodEnd: pendingProjectionContext.currentPeriodEnd,
|
|
2901
|
+
identifier: idempotencyKey,
|
|
2902
|
+
});
|
|
2903
|
+
if (existingProjection) {
|
|
2904
|
+
projectedCurrentPeriodUsed = existingProjection.projectedCurrentPeriodUsed;
|
|
2905
|
+
const projectedEvaluation = evaluateStripeMeterUsage({
|
|
2906
|
+
meter,
|
|
2907
|
+
billingStatus: prepared.subscriptionStatus,
|
|
2908
|
+
attached: prepared.attached,
|
|
2909
|
+
currentPeriodUsed: projectedCurrentPeriodUsed,
|
|
2910
|
+
includedLimit,
|
|
2911
|
+
softLimit,
|
|
2912
|
+
hardLimit,
|
|
2913
|
+
});
|
|
2914
|
+
return Response.json({
|
|
2915
|
+
key,
|
|
2916
|
+
quantity: existingProjection.quantity,
|
|
2917
|
+
customerId: prepared.snapshot.stripeCustomerId ?? stripeCustomerId,
|
|
2918
|
+
stripeEventName: meter.eventName,
|
|
2919
|
+
stripeEventIdentifier: idempotencyKey,
|
|
2920
|
+
occurredAt: existingProjection.occurredAt,
|
|
2921
|
+
currentPeriodUsed,
|
|
2922
|
+
projectedCurrentPeriodUsed,
|
|
2923
|
+
softLimit,
|
|
2924
|
+
hardLimit,
|
|
2925
|
+
state: projectedEvaluation.state,
|
|
2926
|
+
warning: projectedEvaluation.warning,
|
|
2927
|
+
});
|
|
2928
|
+
}
|
|
2929
|
+
projectedCurrentPeriodUsed = currentPeriodUsed + quantity;
|
|
2930
|
+
if (typeof hardLimit === "number" &&
|
|
2931
|
+
hardLimit >= 0 &&
|
|
2932
|
+
projectedCurrentPeriodUsed > hardLimit) {
|
|
2933
|
+
return Response.json({
|
|
2934
|
+
error: `This usage report would push "${key}" to ${projectedCurrentPeriodUsed.toLocaleString()}, above the configured hard cap of ${hardLimit.toLocaleString()} for the current billing period.`,
|
|
2935
|
+
}, {
|
|
2936
|
+
status: 400,
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
const projectedEvaluation = evaluateStripeMeterUsage({
|
|
2940
|
+
meter,
|
|
2941
|
+
billingStatus: prepared.subscriptionStatus,
|
|
2942
|
+
attached: prepared.attached,
|
|
2943
|
+
currentPeriodUsed: projectedCurrentPeriodUsed,
|
|
2944
|
+
includedLimit,
|
|
2945
|
+
softLimit,
|
|
2946
|
+
hardLimit,
|
|
2947
|
+
});
|
|
2948
|
+
state = projectedEvaluation.state;
|
|
2949
|
+
warning = projectedEvaluation.warning;
|
|
2950
|
+
}
|
|
2951
|
+
const reported = await instance.reportUsage({
|
|
2952
|
+
customerId: stripeCustomerId,
|
|
2953
|
+
key,
|
|
2954
|
+
meter,
|
|
2955
|
+
quantity,
|
|
2956
|
+
idempotencyKey,
|
|
2957
|
+
occurredAt,
|
|
2958
|
+
properties: normalizeUsageProperties(body.properties),
|
|
2959
|
+
});
|
|
2960
|
+
await input.billing.hooks?.onUsageReported?.({
|
|
2961
|
+
owner,
|
|
2962
|
+
key,
|
|
2963
|
+
quantity,
|
|
2964
|
+
idempotencyKey,
|
|
2965
|
+
occurredAt: reported.occurredAt,
|
|
2966
|
+
stripeCustomerId: reported.customerId,
|
|
2967
|
+
stripeEventName: reported.eventName,
|
|
2968
|
+
stripeEventIdentifier: reported.identifier,
|
|
2969
|
+
properties: normalizeUsageProperties(body.properties),
|
|
2970
|
+
}, persistence.tools);
|
|
2971
|
+
if (pendingProjectionContext && projectedCurrentPeriodUsed != null) {
|
|
2972
|
+
rememberPendingStripeMeterProjection({
|
|
2973
|
+
customerId: pendingProjectionContext.customerId,
|
|
2974
|
+
key,
|
|
2975
|
+
currentPeriodStart: pendingProjectionContext.currentPeriodStart,
|
|
2976
|
+
currentPeriodEnd: pendingProjectionContext.currentPeriodEnd,
|
|
2977
|
+
identifier: reported.identifier,
|
|
2978
|
+
quantity,
|
|
2979
|
+
occurredAt: reported.occurredAt,
|
|
2980
|
+
projectedCurrentPeriodUsed,
|
|
2981
|
+
});
|
|
2982
|
+
}
|
|
2983
|
+
return Response.json({
|
|
2984
|
+
key,
|
|
2985
|
+
quantity,
|
|
2986
|
+
customerId: reported.customerId,
|
|
2987
|
+
stripeEventName: reported.eventName,
|
|
2988
|
+
stripeEventIdentifier: reported.identifier,
|
|
2989
|
+
occurredAt: reported.occurredAt,
|
|
2990
|
+
currentPeriodUsed,
|
|
2991
|
+
projectedCurrentPeriodUsed,
|
|
2992
|
+
softLimit,
|
|
2993
|
+
hardLimit,
|
|
2994
|
+
state,
|
|
2995
|
+
warning,
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
catch (error) {
|
|
2999
|
+
return Response.json({
|
|
3000
|
+
error: error instanceof Error ? error.message : "Stripe meter usage reporting failed.",
|
|
3001
|
+
}, {
|
|
3002
|
+
status: 400,
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
},
|
|
3006
|
+
}),
|
|
3007
|
+
integrationRoute.post(checkPath, {
|
|
3008
|
+
responseFormat: "json",
|
|
3009
|
+
async handler(request, context) {
|
|
3010
|
+
if (!input.billing) {
|
|
3011
|
+
return Response.json({
|
|
3012
|
+
error: "Stripe billing is not configured for this integration.",
|
|
3013
|
+
}, {
|
|
3014
|
+
status: 400,
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
3018
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
3019
|
+
if (!owner) {
|
|
3020
|
+
return Response.json({
|
|
3021
|
+
error: "You need to sign in before checking billing limits.",
|
|
3022
|
+
}, {
|
|
3023
|
+
status: 401,
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
const body = await readJsonObject(request);
|
|
3027
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
3028
|
+
const amount = typeof body.amount === "number" && Number.isFinite(body.amount) ? body.amount : 1;
|
|
3029
|
+
if (!key) {
|
|
3030
|
+
return Response.json({
|
|
3031
|
+
error: "Stripe billing check requires a key.",
|
|
3032
|
+
}, {
|
|
3033
|
+
status: 400,
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
if (amount <= 0) {
|
|
3037
|
+
return Response.json({
|
|
3038
|
+
error: "Stripe billing check amount must be greater than zero.",
|
|
3039
|
+
}, {
|
|
3040
|
+
status: 400,
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
3044
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
3045
|
+
const snapshot = await getBillingAccount(owner);
|
|
3046
|
+
const planId = snapshot?.planId ?? "free";
|
|
3047
|
+
const limit = getBillingLimitForKey(input.billing, planId, snapshot, key);
|
|
3048
|
+
if (limit === null) {
|
|
3049
|
+
return Response.json({
|
|
3050
|
+
error: `Stripe billing limit "${key}" is not defined on plan "${planId}".`,
|
|
3051
|
+
}, {
|
|
3052
|
+
status: 404,
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
const used = await resolveBillingUsage(input.billing, owner, key, persistence.tools);
|
|
3056
|
+
const meter = getBillingMeter(input.billing, key);
|
|
3057
|
+
const blockedByMeterStatus = meter &&
|
|
3058
|
+
snapshot &&
|
|
3059
|
+
(meter.guard?.blockOnPastDue ?? true) &&
|
|
3060
|
+
isPastDueBillingStatus(snapshot.status);
|
|
3061
|
+
if (limit >= 0 && used === null) {
|
|
3062
|
+
return Response.json({
|
|
3063
|
+
error: `Stripe billing usage did not resolve a numeric value for "${key}".`,
|
|
3064
|
+
}, {
|
|
3065
|
+
status: 400,
|
|
3066
|
+
});
|
|
3067
|
+
}
|
|
3068
|
+
return Response.json({
|
|
3069
|
+
planId,
|
|
3070
|
+
key,
|
|
3071
|
+
amount,
|
|
3072
|
+
used,
|
|
3073
|
+
limit,
|
|
3074
|
+
remaining: typeof used === "number" && limit >= 0 ? Math.max(0, limit - used) : null,
|
|
3075
|
+
allowed: blockedByMeterStatus
|
|
3076
|
+
? false
|
|
3077
|
+
: limit < 0 || used === null
|
|
3078
|
+
? true
|
|
3079
|
+
: used + amount <= limit,
|
|
3080
|
+
});
|
|
3081
|
+
},
|
|
3082
|
+
}),
|
|
3083
|
+
integrationRoute.post(checkoutPath, {
|
|
3084
|
+
responseFormat: "json",
|
|
3085
|
+
async handler(request, context) {
|
|
3086
|
+
const body = await readJsonObject(request);
|
|
3087
|
+
const productId = typeof body.productId === "string" ? body.productId : "";
|
|
3088
|
+
if (!productId) {
|
|
3089
|
+
return Response.json({
|
|
3090
|
+
error: "Stripe checkout requires a productId.",
|
|
3091
|
+
}, {
|
|
3092
|
+
status: 400,
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
try {
|
|
3096
|
+
const product = getProduct(configuredProducts, productId);
|
|
3097
|
+
const billingTools = input.billing
|
|
3098
|
+
? createBillingHookTools(context, stripeSdk, integrationSchema)
|
|
3099
|
+
: undefined;
|
|
3100
|
+
const billingOwner = input.billing && billingTools
|
|
3101
|
+
? await resolveBillingOwner(input.billing, context, billingTools)
|
|
3102
|
+
: null;
|
|
3103
|
+
if (input.billing && !billingOwner) {
|
|
3104
|
+
return Response.json({
|
|
3105
|
+
error: "You need to sign in before starting a Stripe checkout.",
|
|
3106
|
+
}, {
|
|
3107
|
+
status: 401,
|
|
3108
|
+
});
|
|
3109
|
+
}
|
|
3110
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
3111
|
+
const existingSnapshot = input.billing && billingOwner && persistence.getBillingAccount
|
|
3112
|
+
? await persistence.getBillingAccount(billingOwner)
|
|
3113
|
+
: null;
|
|
3114
|
+
const resolvedPlanId = resolvePlanIdForProduct(product, existingSnapshot);
|
|
3115
|
+
const trialBehavior = normalizeCheckoutTrialBehavior(body.trialBehavior);
|
|
3116
|
+
const quantity = resolveQuantity(typeof body.quantity === "number" ? body.quantity : undefined, product.quantity ?? 1);
|
|
3117
|
+
const checkoutLineItems = resolveCheckoutLineItems(product, quantity, input.billing, resolvedPlanId);
|
|
3118
|
+
const successUrl = resolveCheckoutSuccessUrl(resolvePath(typeof body.successPath === "string" ? body.successPath : undefined, "Stripe checkout successPath", successPath), context.request, env.appBaseUrl);
|
|
3119
|
+
const cancelUrl = resolveAbsoluteDestination(resolvePath(typeof body.cancelPath === "string" ? body.cancelPath : undefined, "Stripe checkout cancelPath", cancelPath), context.request, env.appBaseUrl);
|
|
3120
|
+
const ensuredCustomer = input.billing && billingOwner
|
|
3121
|
+
? await requireBillingMethod(persistence.ensureCustomer, "ensureCustomer")(billingOwner)
|
|
3122
|
+
: null;
|
|
3123
|
+
const resolvedTrial = input.billing && billingOwner && trialBehavior !== "none"
|
|
3124
|
+
? await resolveCheckoutTrial(input.billing, billingOwner, product, existingSnapshot, persistence.tools)
|
|
3125
|
+
: null;
|
|
3126
|
+
if (trialBehavior === "require" && !resolvedTrial) {
|
|
3127
|
+
return Response.json({
|
|
3128
|
+
error: "This billing owner is not eligible for a free trial on the selected product.",
|
|
3129
|
+
}, {
|
|
3130
|
+
status: 400,
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
const metadata = body.metadata && typeof body.metadata === "object"
|
|
3134
|
+
? Object.fromEntries(Object.entries(body.metadata).flatMap(([key, value]) => typeof value === "string" ? [[key, value]] : []))
|
|
3135
|
+
: {};
|
|
3136
|
+
const session = await instance.createCheckoutSession({
|
|
3137
|
+
product,
|
|
3138
|
+
quantity,
|
|
3139
|
+
lineItems: checkoutLineItems,
|
|
3140
|
+
customerId: ensuredCustomer?.customerId,
|
|
3141
|
+
customerEmail: billingOwner?.email ??
|
|
3142
|
+
(typeof body.customerEmail === "string" ? body.customerEmail : undefined),
|
|
3143
|
+
successUrl,
|
|
3144
|
+
cancelUrl,
|
|
3145
|
+
trialDays: resolvedTrial?.days ?? null,
|
|
3146
|
+
metadata: {
|
|
3147
|
+
...metadata,
|
|
3148
|
+
planId: resolvedPlanId,
|
|
3149
|
+
productId: product.id,
|
|
3150
|
+
...(billingOwner
|
|
3151
|
+
? {
|
|
3152
|
+
ownerId: billingOwner.id,
|
|
3153
|
+
ownerKind: billingOwner.kind,
|
|
3154
|
+
}
|
|
3155
|
+
: {}),
|
|
3156
|
+
},
|
|
3157
|
+
allowPromotionCodes: input.allowPromotionCodes,
|
|
3158
|
+
automaticTax: input.automaticTax,
|
|
3159
|
+
});
|
|
3160
|
+
const result = {
|
|
3161
|
+
productId: product.id,
|
|
3162
|
+
planId: resolvedPlanId,
|
|
3163
|
+
sessionId: session.id,
|
|
3164
|
+
redirectTo: session.url,
|
|
3165
|
+
mode: product.mode ?? "payment",
|
|
3166
|
+
trialApplied: Boolean(resolvedTrial),
|
|
3167
|
+
trialDays: resolvedTrial?.days ?? null,
|
|
3168
|
+
};
|
|
3169
|
+
if (input.billing && billingOwner) {
|
|
3170
|
+
await input.billing.hooks?.onCheckoutCreated?.({
|
|
3171
|
+
owner: billingOwner,
|
|
3172
|
+
planId: resolvedPlanId,
|
|
3173
|
+
productId: product.id,
|
|
3174
|
+
sessionId: result.sessionId,
|
|
3175
|
+
redirectTo: result.redirectTo,
|
|
3176
|
+
trialApplied: result.trialApplied,
|
|
3177
|
+
trialDays: result.trialDays,
|
|
3178
|
+
}, persistence.tools);
|
|
3179
|
+
}
|
|
3180
|
+
if (request.headers.get("x-farm-integration-client") === "1") {
|
|
3181
|
+
return Response.json(result);
|
|
3182
|
+
}
|
|
3183
|
+
return Response.redirect(result.redirectTo, 303);
|
|
3184
|
+
}
|
|
3185
|
+
catch (error) {
|
|
3186
|
+
return Response.json({
|
|
3187
|
+
error: error instanceof Error ? error.message : "Stripe checkout failed.",
|
|
3188
|
+
}, {
|
|
3189
|
+
status: 400,
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
},
|
|
3193
|
+
}),
|
|
3194
|
+
integrationRoute.post(upgradePath, {
|
|
3195
|
+
responseFormat: "json",
|
|
3196
|
+
async handler(request, context) {
|
|
3197
|
+
if (!input.billing) {
|
|
3198
|
+
return Response.json({
|
|
3199
|
+
error: "Stripe billing is not configured for this integration.",
|
|
3200
|
+
}, {
|
|
3201
|
+
status: 400,
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
const billingTools = createBillingHookTools(context, stripeSdk, integrationSchema);
|
|
3205
|
+
const owner = await resolveBillingOwner(input.billing, context, billingTools);
|
|
3206
|
+
if (!owner) {
|
|
3207
|
+
return Response.json({
|
|
3208
|
+
error: "You need to sign in before upgrading a Stripe subscription.",
|
|
3209
|
+
}, {
|
|
3210
|
+
status: 401,
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
try {
|
|
3214
|
+
const body = await readJsonObject(request);
|
|
3215
|
+
const quantity = resolveQuantity(typeof body.quantity === "number" ? body.quantity : undefined, Number.NaN);
|
|
3216
|
+
const prorationBehavior = normalizeProrationBehavior(body.prorationBehavior);
|
|
3217
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
3218
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
3219
|
+
const existingSnapshot = await getBillingAccount(owner);
|
|
3220
|
+
if (!existingSnapshot?.stripeSubscriptionId) {
|
|
3221
|
+
return Response.json({
|
|
3222
|
+
error: "The active billing owner does not have a Stripe subscription yet. Subscribe first before upgrading seats.",
|
|
3223
|
+
}, {
|
|
3224
|
+
status: 400,
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
const product = findConfiguredProduct(configuredProducts, existingSnapshot.productId);
|
|
3228
|
+
if (!product || product.kind !== "subscription") {
|
|
3229
|
+
return Response.json({
|
|
3230
|
+
error: "The active billing owner is not linked to a configurable subscription product.",
|
|
3231
|
+
}, {
|
|
3232
|
+
status: 400,
|
|
3233
|
+
});
|
|
3234
|
+
}
|
|
3235
|
+
if (product.seatBilling === "included_plus_add_on") {
|
|
3236
|
+
const includedSeats = getConfiguredSeatBaseLimit(input.billing, product);
|
|
3237
|
+
if (includedSeats === null) {
|
|
3238
|
+
return Response.json({
|
|
3239
|
+
error: "This subscription product does not have a valid included seat limit configured.",
|
|
3240
|
+
}, {
|
|
3241
|
+
status: 400,
|
|
3242
|
+
});
|
|
3243
|
+
}
|
|
3244
|
+
if (quantity < includedSeats) {
|
|
3245
|
+
return Response.json({
|
|
3246
|
+
error: `This plan already includes ${includedSeats} seats, so the total seat quantity cannot be set lower than ${includedSeats}.`,
|
|
3247
|
+
}, {
|
|
3248
|
+
status: 400,
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
if (quantity > includedSeats && !product.seatPriceId) {
|
|
3252
|
+
return Response.json({
|
|
3253
|
+
error: `Stripe extra-seat pricing is not configured for "${product.id}" yet. Add the matching seat price ID before trying to purchase more seats.`,
|
|
3254
|
+
}, {
|
|
3255
|
+
status: 400,
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
const currentSeatUsage = await resolveBillingUsage(input.billing, owner, "seats", persistence.tools);
|
|
3260
|
+
if (typeof currentSeatUsage === "number" && currentSeatUsage > quantity) {
|
|
3261
|
+
return Response.json({
|
|
3262
|
+
error: `This organization is currently using ${currentSeatUsage} seats. Reduce usage before lowering purchased seats below that value.`,
|
|
3263
|
+
}, {
|
|
3264
|
+
status: 400,
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
3267
|
+
if (typeof instance.updateSubscription !== "function") {
|
|
3268
|
+
throw new Error("Stripe subscription changes require an adapter that supports updateSubscription.");
|
|
3269
|
+
}
|
|
3270
|
+
const lineItems = resolveCheckoutLineItems(product, quantity, input.billing, existingSnapshot.planId);
|
|
3271
|
+
const updatedSubscription = await instance.updateSubscription({
|
|
3272
|
+
subscriptionId: existingSnapshot.stripeSubscriptionId,
|
|
3273
|
+
product,
|
|
3274
|
+
lineItems,
|
|
3275
|
+
prorationBehavior,
|
|
3276
|
+
});
|
|
3277
|
+
const nextSnapshot = createBillingSnapshotFromSubscriptionChange(owner, updatedSubscription, input.billing, configuredProducts, existingSnapshot);
|
|
3278
|
+
await persistBillingSnapshot(nextSnapshot, input.billing, persistence, existingSnapshot);
|
|
3279
|
+
return Response.json({
|
|
3280
|
+
planId: nextSnapshot.planId,
|
|
3281
|
+
productId: nextSnapshot.productId,
|
|
3282
|
+
status: nextSnapshot.status,
|
|
3283
|
+
stripeCustomerId: nextSnapshot.stripeCustomerId,
|
|
3284
|
+
stripeSubscriptionId: nextSnapshot.stripeSubscriptionId ?? "",
|
|
3285
|
+
currentPeriodEnd: nextSnapshot.currentPeriodEnd?.toISOString() ?? null,
|
|
3286
|
+
cancelAtPeriodEnd: nextSnapshot.cancelAtPeriodEnd,
|
|
3287
|
+
seatQuantity: nextSnapshot.seatQuantity,
|
|
3288
|
+
});
|
|
3289
|
+
}
|
|
3290
|
+
catch (error) {
|
|
3291
|
+
return Response.json({
|
|
3292
|
+
error: error instanceof Error ? error.message : "Stripe subscription upgrade failed.",
|
|
3293
|
+
}, {
|
|
3294
|
+
status: 400,
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
},
|
|
3298
|
+
}),
|
|
3299
|
+
integrationRoute.post(portalPath, {
|
|
3300
|
+
responseFormat: "json",
|
|
3301
|
+
async handler(request, context) {
|
|
3302
|
+
const body = await readJsonObject(request);
|
|
3303
|
+
try {
|
|
3304
|
+
let customerId = typeof body.customerId === "string" ? body.customerId : undefined;
|
|
3305
|
+
const billingTools = input.billing
|
|
3306
|
+
? createBillingHookTools(context, stripeSdk, integrationSchema)
|
|
3307
|
+
: undefined;
|
|
3308
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema, billingTools);
|
|
3309
|
+
if (!customerId && typeof body.sessionId === "string") {
|
|
3310
|
+
const session = await instance.retrieveCheckoutSession(body.sessionId);
|
|
3311
|
+
customerId = session.customerId ?? undefined;
|
|
3312
|
+
}
|
|
3313
|
+
if (!customerId && input.billing) {
|
|
3314
|
+
const owner = await resolveBillingOwner(input.billing, context, persistence.tools);
|
|
3315
|
+
if (owner) {
|
|
3316
|
+
const getBillingAccount = requireBillingMethod(persistence.getBillingAccount, "getBillingAccount");
|
|
3317
|
+
const snapshot = await getBillingAccount(owner);
|
|
3318
|
+
customerId = snapshot?.stripeCustomerId ?? undefined;
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
if (!customerId) {
|
|
3322
|
+
return Response.json({
|
|
3323
|
+
error: "Stripe portal requires a customerId or a sessionId with a customer.",
|
|
3324
|
+
}, {
|
|
3325
|
+
status: 400,
|
|
3326
|
+
});
|
|
3327
|
+
}
|
|
3328
|
+
const returnUrl = resolveAbsoluteDestination(resolvePath(typeof body.returnTo === "string" ? body.returnTo : undefined, "Stripe portal returnTo", successPath), context.request, env.appBaseUrl, typeof body.sessionId === "string"
|
|
3329
|
+
? {
|
|
3330
|
+
session_id: body.sessionId,
|
|
3331
|
+
}
|
|
3332
|
+
: undefined);
|
|
3333
|
+
const portal = await instance.createPortalSession({
|
|
3334
|
+
customerId,
|
|
3335
|
+
returnUrl,
|
|
3336
|
+
});
|
|
3337
|
+
const result = {
|
|
3338
|
+
customerId,
|
|
3339
|
+
redirectTo: portal.url,
|
|
3340
|
+
};
|
|
3341
|
+
if (request.headers.get("x-farm-integration-client") === "1") {
|
|
3342
|
+
return Response.json(result);
|
|
3343
|
+
}
|
|
3344
|
+
return Response.redirect(result.redirectTo, 303);
|
|
3345
|
+
}
|
|
3346
|
+
catch (error) {
|
|
3347
|
+
return Response.json({
|
|
3348
|
+
error: error instanceof Error ? error.message : "Stripe customer portal failed.",
|
|
3349
|
+
}, {
|
|
3350
|
+
status: 400,
|
|
3351
|
+
});
|
|
3352
|
+
}
|
|
3353
|
+
},
|
|
3354
|
+
}),
|
|
3355
|
+
integrationRoute.get(sessionPath, {
|
|
3356
|
+
responseFormat: "json",
|
|
3357
|
+
async handler(request, context) {
|
|
3358
|
+
const sessionId = new URL(request.url).searchParams.get("sessionId");
|
|
3359
|
+
if (!sessionId) {
|
|
3360
|
+
return Response.json({
|
|
3361
|
+
error: "Stripe session lookup requires sessionId.",
|
|
3362
|
+
}, {
|
|
3363
|
+
status: 400,
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
try {
|
|
3367
|
+
const session = await instance.retrieveCheckoutSession(sessionId);
|
|
3368
|
+
if (input.billing) {
|
|
3369
|
+
const persistence = resolveBillingPersistence(input.billing, context, stripeSdk, integrationSchema);
|
|
3370
|
+
const snapshot = await resolveBillingSnapshotForSession(session, configuredProducts, input.billing, persistence, context);
|
|
3371
|
+
if (snapshot) {
|
|
3372
|
+
const previousSnapshot = session.customerId && persistence.getBillingAccountByStripeCustomerId
|
|
3373
|
+
? await persistence.getBillingAccountByStripeCustomerId(session.customerId)
|
|
3374
|
+
: null;
|
|
3375
|
+
await persistBillingSnapshot(snapshot, input.billing, persistence, previousSnapshot);
|
|
3376
|
+
await input.billing.hooks?.onCheckoutCompleted?.({
|
|
3377
|
+
...snapshot,
|
|
3378
|
+
sessionId: session.id,
|
|
3379
|
+
}, persistence.tools);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
return Response.json(session);
|
|
3383
|
+
}
|
|
3384
|
+
catch (error) {
|
|
3385
|
+
return Response.json({
|
|
3386
|
+
error: error instanceof Error ? error.message : "Stripe session lookup failed.",
|
|
3387
|
+
}, {
|
|
3388
|
+
status: 404,
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
},
|
|
3392
|
+
}),
|
|
3393
|
+
...webhookRoutes,
|
|
3394
|
+
],
|
|
3395
|
+
});
|
|
3396
|
+
}
|