stripe-no-webhooks 0.0.8 → 0.0.11
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/README.md +118 -104
- package/bin/cli.js +41 -852
- package/bin/commands/backfill.js +389 -0
- package/bin/commands/config.js +272 -0
- package/bin/commands/generate.js +110 -0
- package/bin/commands/helpers/backfill-maps.js +279 -0
- package/bin/commands/helpers/dev-webhook-listener.js +76 -0
- package/bin/commands/helpers/sync-helpers.js +190 -0
- package/bin/commands/helpers/utils.js +168 -0
- package/bin/commands/migrate.js +104 -0
- package/bin/commands/sync.js +433 -0
- package/dist/BillingConfig-CpHPJg4Q.d.mts +54 -0
- package/dist/BillingConfig-CpHPJg4Q.d.ts +54 -0
- package/dist/client.d.mts +32 -8
- package/dist/client.d.ts +32 -8
- package/dist/client.js +82 -20
- package/dist/client.mjs +80 -19
- package/dist/index.d.mts +460 -66
- package/dist/index.d.ts +460 -66
- package/dist/index.js +2736 -168
- package/dist/index.mjs +2730 -167
- package/package.json +9 -3
- package/src/templates/PricingPage.tsx +450 -0
- package/src/templates/app-router.ts +23 -16
- package/src/templates/lib-billing.ts +27 -0
- package/src/templates/pages-router.ts +25 -18
package/dist/index.js
CHANGED
|
@@ -30,16 +30,22 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
-
|
|
33
|
+
Billing: () => Billing,
|
|
34
|
+
CreditError: () => CreditError,
|
|
35
|
+
FasterStripe: () => FasterStripe,
|
|
36
|
+
createHandler: () => createHandler,
|
|
37
|
+
credits: () => credits,
|
|
38
|
+
initCredits: () => initCredits
|
|
34
39
|
});
|
|
35
40
|
module.exports = __toCommonJS(index_exports);
|
|
36
41
|
|
|
37
|
-
// src/
|
|
38
|
-
var import_stripe_sync_engine = require("@
|
|
42
|
+
// src/Billing.ts
|
|
43
|
+
var import_stripe_sync_engine = require("@pretzelai/stripe-sync-engine");
|
|
39
44
|
var import_stripe = __toESM(require("stripe"));
|
|
45
|
+
var import_pg = require("pg");
|
|
40
46
|
|
|
41
|
-
// src/
|
|
42
|
-
|
|
47
|
+
// src/helpers.ts
|
|
48
|
+
function getMode(stripeKey) {
|
|
43
49
|
if (stripeKey.includes("_test_")) {
|
|
44
50
|
return "test";
|
|
45
51
|
} else if (stripeKey.includes("_live_")) {
|
|
@@ -47,219 +53,2781 @@ var getMode = (stripeKey) => {
|
|
|
47
53
|
} else {
|
|
48
54
|
throw new Error("Invalid Stripe key");
|
|
49
55
|
}
|
|
56
|
+
}
|
|
57
|
+
function planHasCredits(plan) {
|
|
58
|
+
return plan?.credits !== void 0 && Object.keys(plan.credits).length > 0;
|
|
59
|
+
}
|
|
60
|
+
function findPlanByPriceId(billingConfig, mode, priceId) {
|
|
61
|
+
const plans = billingConfig?.[mode]?.plans;
|
|
62
|
+
return plans?.find((p) => p.price.some((pr) => pr.id === priceId)) ?? null;
|
|
63
|
+
}
|
|
64
|
+
function getPlanFromSubscription(subscription, billingConfig, mode) {
|
|
65
|
+
const price = subscription.items.data[0]?.price;
|
|
66
|
+
if (!price) return null;
|
|
67
|
+
const priceId = typeof price === "string" ? price : price.id;
|
|
68
|
+
return findPlanByPriceId(billingConfig, mode, priceId);
|
|
69
|
+
}
|
|
70
|
+
function getCustomerIdFromSubscription(subscription) {
|
|
71
|
+
return typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id;
|
|
72
|
+
}
|
|
73
|
+
async function getActiveSubscription(stripe, customerId) {
|
|
74
|
+
const subscriptions = await stripe.subscriptions.list({
|
|
75
|
+
customer: customerId,
|
|
76
|
+
limit: 10,
|
|
77
|
+
expand: ["data.items.data.price"]
|
|
78
|
+
});
|
|
79
|
+
return subscriptions.data.find(
|
|
80
|
+
(s) => s.status === "active" || s.status === "trialing"
|
|
81
|
+
) ?? null;
|
|
82
|
+
}
|
|
83
|
+
async function getStripeCustomerId(pool2, schema2, userId) {
|
|
84
|
+
const result = await pool2.query(
|
|
85
|
+
`SELECT stripe_customer_id FROM ${schema2}.user_stripe_customer_map WHERE user_id = $1`,
|
|
86
|
+
[userId]
|
|
87
|
+
);
|
|
88
|
+
return result.rows[0]?.stripe_customer_id ?? null;
|
|
89
|
+
}
|
|
90
|
+
async function getUserIdFromCustomer(pool2, schema2, customerId) {
|
|
91
|
+
const result = await pool2.query(
|
|
92
|
+
`SELECT metadata->>'user_id' as user_id FROM ${schema2}.customers WHERE id = $1`,
|
|
93
|
+
[customerId]
|
|
94
|
+
);
|
|
95
|
+
return result.rows[0]?.user_id ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/credits/types.ts
|
|
99
|
+
var CreditError = class extends Error {
|
|
100
|
+
constructor(code, message, details) {
|
|
101
|
+
super(message);
|
|
102
|
+
this.code = code;
|
|
103
|
+
this.details = details;
|
|
104
|
+
this.name = "CreditError";
|
|
105
|
+
}
|
|
50
106
|
};
|
|
51
107
|
|
|
52
|
-
// src/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
108
|
+
// src/credits/db.ts
|
|
109
|
+
var pool = null;
|
|
110
|
+
var schema = "stripe";
|
|
111
|
+
function setPool(p, s = "stripe") {
|
|
112
|
+
pool = p;
|
|
113
|
+
schema = s;
|
|
114
|
+
}
|
|
115
|
+
function ensurePool() {
|
|
116
|
+
if (!pool) {
|
|
117
|
+
throw new Error("Database pool not initialized. Call setPool() first.");
|
|
118
|
+
}
|
|
119
|
+
return pool;
|
|
120
|
+
}
|
|
121
|
+
async function getBalance(userId, creditType) {
|
|
122
|
+
const p = ensurePool();
|
|
123
|
+
const result = await p.query(
|
|
124
|
+
`SELECT balance FROM ${schema}.credit_balances WHERE user_id = $1 AND credit_type_id = $2`,
|
|
125
|
+
[userId, creditType]
|
|
126
|
+
);
|
|
127
|
+
return result.rows.length > 0 ? Number(result.rows[0].balance) : 0;
|
|
128
|
+
}
|
|
129
|
+
async function getAllBalances(userId) {
|
|
130
|
+
const p = ensurePool();
|
|
131
|
+
const result = await p.query(
|
|
132
|
+
`SELECT credit_type_id, balance FROM ${schema}.credit_balances WHERE user_id = $1`,
|
|
133
|
+
[userId]
|
|
134
|
+
);
|
|
135
|
+
const balances = {};
|
|
136
|
+
for (const row of result.rows) {
|
|
137
|
+
balances[row.credit_type_id] = Number(row.balance);
|
|
138
|
+
}
|
|
139
|
+
return balances;
|
|
140
|
+
}
|
|
141
|
+
async function hasCredits(userId, creditType, amount) {
|
|
142
|
+
const balance = await getBalance(userId, creditType);
|
|
143
|
+
return balance >= amount;
|
|
144
|
+
}
|
|
145
|
+
async function checkIdempotencyKey(key) {
|
|
146
|
+
const p = ensurePool();
|
|
147
|
+
const result = await p.query(
|
|
148
|
+
`SELECT 1 FROM ${schema}.credit_ledger WHERE idempotency_key = $1`,
|
|
149
|
+
[key]
|
|
150
|
+
);
|
|
151
|
+
return result.rows.length > 0;
|
|
152
|
+
}
|
|
153
|
+
async function checkIdempotencyKeyPrefix(prefix) {
|
|
154
|
+
const p = ensurePool();
|
|
155
|
+
const result = await p.query(
|
|
156
|
+
`SELECT 1 FROM ${schema}.credit_ledger WHERE idempotency_key LIKE $1 LIMIT 1`,
|
|
157
|
+
[prefix + "%"]
|
|
158
|
+
);
|
|
159
|
+
return result.rows.length > 0;
|
|
160
|
+
}
|
|
161
|
+
async function countAutoTopUpsThisMonth(userId, creditType) {
|
|
162
|
+
const p = ensurePool();
|
|
163
|
+
const result = await p.query(
|
|
164
|
+
`SELECT COUNT(*) FROM ${schema}.credit_ledger
|
|
165
|
+
WHERE user_id = $1
|
|
166
|
+
AND credit_type_id = $2
|
|
167
|
+
AND source = 'auto_topup'
|
|
168
|
+
AND created_at >= date_trunc('month', now() AT TIME ZONE 'UTC')`,
|
|
169
|
+
[userId, creditType]
|
|
170
|
+
);
|
|
171
|
+
return parseInt(result.rows[0].count, 10);
|
|
172
|
+
}
|
|
173
|
+
function isUniqueViolation(error) {
|
|
174
|
+
return error instanceof Error && "code" in error && error.code === "23505";
|
|
175
|
+
}
|
|
176
|
+
async function writeLedgerEntry(client, userId, creditType, amount, balanceAfter, params) {
|
|
177
|
+
try {
|
|
178
|
+
await client.query(
|
|
179
|
+
`INSERT INTO ${schema}.credit_ledger
|
|
180
|
+
(user_id, credit_type_id, amount, balance_after, transaction_type, source, source_id, description, metadata, idempotency_key)
|
|
181
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
182
|
+
[
|
|
183
|
+
userId,
|
|
184
|
+
creditType,
|
|
185
|
+
amount,
|
|
186
|
+
balanceAfter,
|
|
187
|
+
params.transactionType,
|
|
188
|
+
params.source,
|
|
189
|
+
params.sourceId ?? null,
|
|
190
|
+
params.description ?? null,
|
|
191
|
+
params.metadata ? JSON.stringify(params.metadata) : null,
|
|
192
|
+
params.idempotencyKey ?? null
|
|
193
|
+
]
|
|
194
|
+
);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (isUniqueViolation(error) && params.idempotencyKey) {
|
|
197
|
+
throw new CreditError(
|
|
198
|
+
"IDEMPOTENCY_CONFLICT",
|
|
199
|
+
"Operation already processed",
|
|
200
|
+
{
|
|
201
|
+
idempotencyKey: params.idempotencyKey
|
|
202
|
+
}
|
|
84
203
|
);
|
|
85
204
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function ensureBalanceRowExists(client, userId, creditType) {
|
|
209
|
+
await client.query(
|
|
210
|
+
`INSERT INTO ${schema}.credit_balances (user_id, credit_type_id, balance)
|
|
211
|
+
VALUES ($1, $2, 0)
|
|
212
|
+
ON CONFLICT DO NOTHING`,
|
|
213
|
+
[userId, creditType]
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
async function getBalanceForUpdate(client, userId, creditType) {
|
|
217
|
+
await ensureBalanceRowExists(client, userId, creditType);
|
|
218
|
+
const result = await client.query(
|
|
219
|
+
`SELECT balance FROM ${schema}.credit_balances
|
|
220
|
+
WHERE user_id = $1 AND credit_type_id = $2
|
|
221
|
+
FOR UPDATE`,
|
|
222
|
+
[userId, creditType]
|
|
223
|
+
);
|
|
224
|
+
return Number(result.rows[0].balance);
|
|
225
|
+
}
|
|
226
|
+
async function setBalanceValue(client, userId, creditType, newBalance) {
|
|
227
|
+
await client.query(
|
|
228
|
+
`UPDATE ${schema}.credit_balances
|
|
229
|
+
SET balance = $3, updated_at = now()
|
|
230
|
+
WHERE user_id = $1 AND credit_type_id = $2`,
|
|
231
|
+
[userId, creditType, newBalance]
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
async function atomicAdd(userId, creditType, amount, params) {
|
|
235
|
+
const p = ensurePool();
|
|
236
|
+
const client = await p.connect();
|
|
237
|
+
try {
|
|
238
|
+
await client.query("BEGIN");
|
|
239
|
+
const currentBalance = await getBalanceForUpdate(
|
|
240
|
+
client,
|
|
241
|
+
userId,
|
|
242
|
+
creditType
|
|
243
|
+
);
|
|
244
|
+
const newBalance = currentBalance + amount;
|
|
245
|
+
await setBalanceValue(client, userId, creditType, newBalance);
|
|
246
|
+
await writeLedgerEntry(
|
|
247
|
+
client,
|
|
248
|
+
userId,
|
|
249
|
+
creditType,
|
|
250
|
+
amount,
|
|
251
|
+
newBalance,
|
|
252
|
+
params
|
|
253
|
+
);
|
|
254
|
+
await client.query("COMMIT");
|
|
255
|
+
return newBalance;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
await client.query("ROLLBACK");
|
|
258
|
+
throw error;
|
|
259
|
+
} finally {
|
|
260
|
+
client.release();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function atomicConsume(userId, creditType, amount, params) {
|
|
264
|
+
const p = ensurePool();
|
|
265
|
+
const client = await p.connect();
|
|
266
|
+
try {
|
|
267
|
+
await client.query("BEGIN");
|
|
268
|
+
const currentBalance = await getBalanceForUpdate(
|
|
269
|
+
client,
|
|
270
|
+
userId,
|
|
271
|
+
creditType
|
|
272
|
+
);
|
|
273
|
+
if (currentBalance < amount) {
|
|
274
|
+
await client.query("ROLLBACK");
|
|
275
|
+
return { success: false, currentBalance };
|
|
90
276
|
}
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
277
|
+
const newBalance = currentBalance - amount;
|
|
278
|
+
await setBalanceValue(client, userId, creditType, newBalance);
|
|
279
|
+
await writeLedgerEntry(
|
|
280
|
+
client,
|
|
281
|
+
userId,
|
|
282
|
+
creditType,
|
|
283
|
+
-amount,
|
|
284
|
+
newBalance,
|
|
285
|
+
params
|
|
286
|
+
);
|
|
287
|
+
await client.query("COMMIT");
|
|
288
|
+
return { success: true, newBalance };
|
|
289
|
+
} catch (error) {
|
|
290
|
+
await client.query("ROLLBACK");
|
|
291
|
+
throw error;
|
|
292
|
+
} finally {
|
|
293
|
+
client.release();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async function atomicRevoke(userId, creditType, maxAmount, params) {
|
|
297
|
+
const p = ensurePool();
|
|
298
|
+
const client = await p.connect();
|
|
299
|
+
try {
|
|
300
|
+
await client.query("BEGIN");
|
|
301
|
+
const currentBalance = await getBalanceForUpdate(
|
|
302
|
+
client,
|
|
303
|
+
userId,
|
|
304
|
+
creditType
|
|
305
|
+
);
|
|
306
|
+
const amountRevoked = Math.min(maxAmount, currentBalance);
|
|
307
|
+
const newBalance = currentBalance - amountRevoked;
|
|
308
|
+
if (amountRevoked > 0) {
|
|
309
|
+
await setBalanceValue(client, userId, creditType, newBalance);
|
|
310
|
+
await writeLedgerEntry(
|
|
311
|
+
client,
|
|
312
|
+
userId,
|
|
313
|
+
creditType,
|
|
314
|
+
-amountRevoked,
|
|
315
|
+
newBalance,
|
|
316
|
+
params
|
|
95
317
|
);
|
|
96
318
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
319
|
+
await client.query("COMMIT");
|
|
320
|
+
return { newBalance, amountRevoked };
|
|
321
|
+
} catch (error) {
|
|
322
|
+
await client.query("ROLLBACK");
|
|
323
|
+
throw error;
|
|
324
|
+
} finally {
|
|
325
|
+
client.release();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function atomicSet(userId, creditType, newBalance, params) {
|
|
329
|
+
const p = ensurePool();
|
|
330
|
+
const client = await p.connect();
|
|
331
|
+
try {
|
|
332
|
+
await client.query("BEGIN");
|
|
333
|
+
const previousBalance = await getBalanceForUpdate(
|
|
334
|
+
client,
|
|
335
|
+
userId,
|
|
336
|
+
creditType
|
|
337
|
+
);
|
|
338
|
+
const adjustment = newBalance - previousBalance;
|
|
339
|
+
if (adjustment !== 0) {
|
|
340
|
+
await setBalanceValue(client, userId, creditType, newBalance);
|
|
341
|
+
await writeLedgerEntry(
|
|
342
|
+
client,
|
|
343
|
+
userId,
|
|
344
|
+
creditType,
|
|
345
|
+
adjustment,
|
|
346
|
+
newBalance,
|
|
347
|
+
params
|
|
100
348
|
);
|
|
101
349
|
}
|
|
102
|
-
|
|
350
|
+
await client.query("COMMIT");
|
|
351
|
+
return { previousBalance };
|
|
352
|
+
} catch (error) {
|
|
353
|
+
await client.query("ROLLBACK");
|
|
354
|
+
throw error;
|
|
355
|
+
} finally {
|
|
356
|
+
client.release();
|
|
103
357
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
358
|
+
}
|
|
359
|
+
async function getHistory(userId, options) {
|
|
360
|
+
const { creditType, limit = 50, offset = 0 } = options ?? {};
|
|
361
|
+
const p = ensurePool();
|
|
362
|
+
let query = `
|
|
363
|
+
SELECT id, user_id, credit_type_id, amount, balance_after,
|
|
364
|
+
transaction_type, source, source_id, description, metadata, created_at
|
|
365
|
+
FROM ${schema}.credit_ledger
|
|
366
|
+
WHERE user_id = $1
|
|
367
|
+
`;
|
|
368
|
+
const params = [userId];
|
|
369
|
+
if (creditType) {
|
|
370
|
+
query += ` AND credit_type_id = $2`;
|
|
371
|
+
params.push(creditType);
|
|
107
372
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
373
|
+
query += ` ORDER BY created_at DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
|
|
374
|
+
params.push(limit, offset);
|
|
375
|
+
const result = await p.query(query, params);
|
|
376
|
+
return result.rows.map((row) => ({
|
|
377
|
+
id: row.id,
|
|
378
|
+
userId: row.user_id,
|
|
379
|
+
creditType: row.credit_type_id,
|
|
380
|
+
amount: Number(row.amount),
|
|
381
|
+
balanceAfter: Number(row.balance_after),
|
|
382
|
+
transactionType: row.transaction_type,
|
|
383
|
+
source: row.source,
|
|
384
|
+
sourceId: row.source_id ?? void 0,
|
|
385
|
+
description: row.description ?? void 0,
|
|
386
|
+
metadata: row.metadata ?? void 0,
|
|
387
|
+
createdAt: new Date(row.created_at)
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
async function getActiveSeatUsers(subscriptionId) {
|
|
391
|
+
const p = ensurePool();
|
|
392
|
+
const result = await p.query(
|
|
393
|
+
`SELECT user_id FROM (
|
|
394
|
+
SELECT DISTINCT ON (user_id) user_id, source
|
|
395
|
+
FROM ${schema}.credit_ledger
|
|
396
|
+
WHERE source_id = $1
|
|
397
|
+
AND source IN ('seat_grant', 'seat_revoke')
|
|
398
|
+
ORDER BY user_id, created_at DESC
|
|
399
|
+
) active
|
|
400
|
+
WHERE source = 'seat_grant'`,
|
|
401
|
+
[subscriptionId]
|
|
402
|
+
);
|
|
403
|
+
return result.rows.map((row) => row.user_id);
|
|
404
|
+
}
|
|
405
|
+
async function getUserSeatSubscription(userId) {
|
|
406
|
+
const p = ensurePool();
|
|
407
|
+
const result = await p.query(
|
|
408
|
+
`SELECT source_id as subscription_id FROM (
|
|
409
|
+
SELECT DISTINCT ON (user_id) user_id, source, source_id
|
|
410
|
+
FROM ${schema}.credit_ledger
|
|
411
|
+
WHERE user_id = $1
|
|
412
|
+
AND source IN ('seat_grant', 'seat_revoke')
|
|
413
|
+
ORDER BY user_id, created_at DESC
|
|
414
|
+
) latest
|
|
415
|
+
WHERE source = 'seat_grant'`,
|
|
416
|
+
[userId]
|
|
417
|
+
);
|
|
418
|
+
return result.rows[0]?.subscription_id ?? null;
|
|
419
|
+
}
|
|
420
|
+
async function getCreditsGrantedBySource(userId, sourceId) {
|
|
421
|
+
const p = ensurePool();
|
|
422
|
+
const result = await p.query(
|
|
423
|
+
`SELECT credit_type_id, SUM(amount) as net_amount
|
|
424
|
+
FROM ${schema}.credit_ledger
|
|
425
|
+
WHERE user_id = $1
|
|
426
|
+
AND source_id = $2
|
|
427
|
+
AND source IN ('subscription', 'renewal', 'seat_grant', 'plan_change', 'cancellation', 'seat_revoke')
|
|
428
|
+
GROUP BY credit_type_id
|
|
429
|
+
HAVING SUM(amount) > 0`,
|
|
430
|
+
[userId, sourceId]
|
|
431
|
+
);
|
|
432
|
+
const net = {};
|
|
433
|
+
for (const row of result.rows) {
|
|
434
|
+
net[row.credit_type_id] = Number(row.net_amount);
|
|
435
|
+
}
|
|
436
|
+
return net;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/credits/grant.ts
|
|
440
|
+
async function consume(params) {
|
|
441
|
+
const { userId, creditType, amount, description, metadata, idempotencyKey } = params;
|
|
442
|
+
if (amount <= 0) {
|
|
443
|
+
throw new CreditError("INVALID_AMOUNT", "Amount must be positive");
|
|
444
|
+
}
|
|
445
|
+
if (idempotencyKey) {
|
|
446
|
+
const exists = await checkIdempotencyKey(idempotencyKey);
|
|
447
|
+
if (exists) {
|
|
448
|
+
throw new CreditError("IDEMPOTENCY_CONFLICT", "Operation already processed", { idempotencyKey });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const result = await atomicConsume(userId, creditType, amount, {
|
|
452
|
+
transactionType: "consume",
|
|
453
|
+
source: "usage",
|
|
454
|
+
description,
|
|
455
|
+
metadata,
|
|
456
|
+
idempotencyKey
|
|
457
|
+
});
|
|
458
|
+
if (result.success === false) {
|
|
459
|
+
return { success: false, balance: result.currentBalance };
|
|
460
|
+
}
|
|
461
|
+
return { success: true, balance: result.newBalance };
|
|
462
|
+
}
|
|
463
|
+
async function grant(params) {
|
|
464
|
+
const {
|
|
465
|
+
userId,
|
|
466
|
+
creditType,
|
|
467
|
+
amount,
|
|
468
|
+
source = "manual",
|
|
469
|
+
sourceId,
|
|
470
|
+
description,
|
|
471
|
+
metadata,
|
|
472
|
+
idempotencyKey
|
|
473
|
+
} = params;
|
|
474
|
+
if (amount <= 0) {
|
|
475
|
+
throw new CreditError("INVALID_AMOUNT", "Amount must be positive");
|
|
476
|
+
}
|
|
477
|
+
if (idempotencyKey) {
|
|
478
|
+
const exists = await checkIdempotencyKey(idempotencyKey);
|
|
479
|
+
if (exists) {
|
|
480
|
+
throw new CreditError(
|
|
481
|
+
"IDEMPOTENCY_CONFLICT",
|
|
482
|
+
"Operation already processed",
|
|
483
|
+
{ idempotencyKey }
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return atomicAdd(userId, creditType, amount, {
|
|
488
|
+
transactionType: "grant",
|
|
489
|
+
source,
|
|
490
|
+
sourceId,
|
|
491
|
+
description,
|
|
492
|
+
metadata,
|
|
493
|
+
idempotencyKey
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async function revoke(params) {
|
|
497
|
+
const {
|
|
498
|
+
userId,
|
|
499
|
+
creditType,
|
|
500
|
+
amount,
|
|
501
|
+
source = "manual",
|
|
502
|
+
sourceId,
|
|
503
|
+
description,
|
|
504
|
+
metadata,
|
|
505
|
+
idempotencyKey
|
|
506
|
+
} = params;
|
|
507
|
+
if (amount <= 0) {
|
|
508
|
+
throw new CreditError("INVALID_AMOUNT", "Amount must be positive");
|
|
509
|
+
}
|
|
510
|
+
if (idempotencyKey) {
|
|
511
|
+
const exists = await checkIdempotencyKey(idempotencyKey);
|
|
512
|
+
if (exists) {
|
|
513
|
+
throw new CreditError(
|
|
514
|
+
"IDEMPOTENCY_CONFLICT",
|
|
515
|
+
"Operation already processed",
|
|
516
|
+
{ idempotencyKey }
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
const result = await atomicRevoke(userId, creditType, amount, {
|
|
521
|
+
transactionType: "revoke",
|
|
522
|
+
source,
|
|
523
|
+
sourceId,
|
|
524
|
+
description,
|
|
525
|
+
metadata,
|
|
526
|
+
idempotencyKey
|
|
527
|
+
});
|
|
528
|
+
return { balance: result.newBalance, amountRevoked: result.amountRevoked };
|
|
529
|
+
}
|
|
530
|
+
async function revokeAll(params) {
|
|
531
|
+
const currentBalance = await getBalance(params.userId, params.creditType);
|
|
532
|
+
if (currentBalance === 0) {
|
|
533
|
+
return { previousBalance: 0, amountRevoked: 0 };
|
|
534
|
+
}
|
|
535
|
+
const result = await revoke({ ...params, amount: currentBalance });
|
|
536
|
+
const previousBalance = result.balance + result.amountRevoked;
|
|
537
|
+
return {
|
|
538
|
+
previousBalance,
|
|
539
|
+
amountRevoked: result.amountRevoked
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
async function revokeAllCreditTypesForUser(params) {
|
|
543
|
+
const allBalances = await getAllBalances(params.userId);
|
|
544
|
+
const results = {};
|
|
545
|
+
for (const [creditType, balance] of Object.entries(allBalances)) {
|
|
546
|
+
if (balance > 0) {
|
|
547
|
+
const result = await revoke({
|
|
548
|
+
userId: params.userId,
|
|
549
|
+
creditType,
|
|
550
|
+
amount: balance,
|
|
551
|
+
source: params.source,
|
|
552
|
+
description: params.description,
|
|
553
|
+
metadata: params.metadata
|
|
554
|
+
});
|
|
555
|
+
results[creditType] = {
|
|
556
|
+
previousBalance: balance,
|
|
557
|
+
amountRevoked: result.amountRevoked
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return results;
|
|
562
|
+
}
|
|
563
|
+
async function setBalance(params) {
|
|
564
|
+
const { userId, creditType, balance, reason, metadata, idempotencyKey } = params;
|
|
565
|
+
if (balance < 0) {
|
|
566
|
+
throw new CreditError("INVALID_AMOUNT", "Balance cannot be negative");
|
|
567
|
+
}
|
|
568
|
+
if (idempotencyKey) {
|
|
569
|
+
const exists = await checkIdempotencyKey(idempotencyKey);
|
|
570
|
+
if (exists) {
|
|
571
|
+
throw new CreditError(
|
|
572
|
+
"IDEMPOTENCY_CONFLICT",
|
|
573
|
+
"Operation already processed",
|
|
574
|
+
{ idempotencyKey }
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const result = await atomicSet(userId, creditType, balance, {
|
|
579
|
+
transactionType: "adjust",
|
|
580
|
+
source: "manual",
|
|
581
|
+
description: reason,
|
|
582
|
+
metadata,
|
|
583
|
+
idempotencyKey
|
|
584
|
+
});
|
|
585
|
+
return { balance, previousBalance: result.previousBalance };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// src/credits/index.ts
|
|
589
|
+
function initCredits(pool2, schema2 = "stripe") {
|
|
590
|
+
setPool(pool2, schema2);
|
|
591
|
+
}
|
|
592
|
+
var credits = {
|
|
593
|
+
getBalance,
|
|
594
|
+
getAllBalances,
|
|
595
|
+
hasCredits,
|
|
596
|
+
consume,
|
|
597
|
+
grant,
|
|
598
|
+
revoke,
|
|
599
|
+
revokeAll,
|
|
600
|
+
revokeAllCreditTypesForUser,
|
|
601
|
+
setBalance,
|
|
602
|
+
getHistory
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// src/credits/lifecycle.ts
|
|
606
|
+
function createCreditLifecycle(config) {
|
|
607
|
+
const { pool: pool2, schema: schema2, billingConfig, mode, grantTo, callbacks } = config;
|
|
608
|
+
async function resolveUserId(subscription) {
|
|
609
|
+
if (!pool2) return null;
|
|
610
|
+
const customerId = getCustomerIdFromSubscription(subscription);
|
|
611
|
+
return getUserIdFromCustomer(pool2, schema2, customerId);
|
|
612
|
+
}
|
|
613
|
+
function resolvePlan(subscription) {
|
|
614
|
+
return getPlanFromSubscription(subscription, billingConfig, mode);
|
|
615
|
+
}
|
|
616
|
+
function resolvePlanByPriceId(priceId) {
|
|
617
|
+
return findPlanByPriceId(billingConfig, mode, priceId);
|
|
618
|
+
}
|
|
619
|
+
async function grantPlanCredits(userId, plan, subscriptionId, source, idempotencyPrefix) {
|
|
620
|
+
if (!plan.credits) return;
|
|
621
|
+
for (const [creditType, creditConfig] of Object.entries(plan.credits)) {
|
|
622
|
+
const idempotencyKey = idempotencyPrefix ? `${idempotencyPrefix}:${creditType}` : void 0;
|
|
623
|
+
const newBalance = await credits.grant({
|
|
624
|
+
userId,
|
|
625
|
+
creditType,
|
|
626
|
+
amount: creditConfig.allocation,
|
|
627
|
+
source,
|
|
628
|
+
sourceId: subscriptionId,
|
|
629
|
+
idempotencyKey
|
|
630
|
+
});
|
|
631
|
+
await callbacks?.onCreditsGranted?.({
|
|
632
|
+
userId,
|
|
633
|
+
creditType,
|
|
634
|
+
amount: creditConfig.allocation,
|
|
635
|
+
newBalance,
|
|
636
|
+
source,
|
|
637
|
+
sourceId: subscriptionId
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function grantOrResetCreditsForUser(userId, plan, subscriptionId, source, idempotencyPrefix) {
|
|
642
|
+
if (!plan.credits) return;
|
|
643
|
+
for (const [creditType, creditConfig] of Object.entries(plan.credits)) {
|
|
644
|
+
if (source === "renewal" && (creditConfig.onRenewal ?? "reset") === "reset") {
|
|
645
|
+
const balance = await credits.getBalance(userId, creditType);
|
|
646
|
+
if (balance > 0) {
|
|
647
|
+
await credits.revoke({ userId, creditType, amount: balance, source: "renewal" });
|
|
648
|
+
await callbacks?.onCreditsRevoked?.({
|
|
649
|
+
userId,
|
|
650
|
+
creditType,
|
|
651
|
+
amount: balance,
|
|
652
|
+
previousBalance: balance,
|
|
653
|
+
newBalance: 0,
|
|
654
|
+
source: "renewal"
|
|
655
|
+
});
|
|
656
|
+
}
|
|
118
657
|
}
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
658
|
+
const idempotencyKey = idempotencyPrefix ? `${idempotencyPrefix}:${creditType}` : void 0;
|
|
659
|
+
const actualSource = grantTo === "seat-users" ? "seat_grant" : source;
|
|
660
|
+
const newBalance = await credits.grant({
|
|
661
|
+
userId,
|
|
662
|
+
creditType,
|
|
663
|
+
amount: creditConfig.allocation,
|
|
664
|
+
source: actualSource,
|
|
665
|
+
sourceId: subscriptionId,
|
|
666
|
+
idempotencyKey
|
|
667
|
+
});
|
|
668
|
+
await callbacks?.onCreditsGranted?.({
|
|
669
|
+
userId,
|
|
670
|
+
creditType,
|
|
671
|
+
amount: creditConfig.allocation,
|
|
672
|
+
newBalance,
|
|
673
|
+
source: actualSource,
|
|
674
|
+
sourceId: subscriptionId
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async function revokeSubscriptionCredits(userId, subscriptionId, source) {
|
|
679
|
+
const netFromSubscription = await getCreditsGrantedBySource(userId, subscriptionId);
|
|
680
|
+
for (const [creditType, netAmount] of Object.entries(netFromSubscription)) {
|
|
681
|
+
if (netAmount > 0) {
|
|
682
|
+
const currentBalance = await credits.getBalance(userId, creditType);
|
|
683
|
+
const amountToRevoke = Math.min(netAmount, currentBalance);
|
|
684
|
+
if (amountToRevoke > 0) {
|
|
685
|
+
const result = await credits.revoke({
|
|
686
|
+
userId,
|
|
687
|
+
creditType,
|
|
688
|
+
amount: amountToRevoke,
|
|
689
|
+
source,
|
|
690
|
+
sourceId: subscriptionId
|
|
691
|
+
});
|
|
692
|
+
await callbacks?.onCreditsRevoked?.({
|
|
693
|
+
userId,
|
|
694
|
+
creditType,
|
|
695
|
+
amount: result.amountRevoked,
|
|
696
|
+
previousBalance: currentBalance,
|
|
697
|
+
newBalance: result.balance,
|
|
698
|
+
source
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
async function applyDowngradeCredits(userId, newPlan, subscriptionId, idempotencyPrefix) {
|
|
705
|
+
const allBalances = await credits.getAllBalances(userId);
|
|
706
|
+
const newPlanCreditTypes = new Set(Object.keys(newPlan?.credits ?? {}));
|
|
707
|
+
for (const [creditType, balance] of Object.entries(allBalances)) {
|
|
708
|
+
if (!newPlanCreditTypes.has(creditType) && balance > 0) {
|
|
709
|
+
const result = await credits.revoke({
|
|
710
|
+
userId,
|
|
711
|
+
creditType,
|
|
712
|
+
amount: balance,
|
|
713
|
+
source: "plan_change",
|
|
714
|
+
sourceId: subscriptionId
|
|
715
|
+
});
|
|
716
|
+
await callbacks?.onCreditsRevoked?.({
|
|
717
|
+
userId,
|
|
718
|
+
creditType,
|
|
719
|
+
amount: result.amountRevoked,
|
|
720
|
+
previousBalance: balance,
|
|
721
|
+
newBalance: result.balance,
|
|
722
|
+
source: "plan_change"
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (newPlan?.credits) {
|
|
727
|
+
for (const [creditType, creditConfig] of Object.entries(newPlan.credits)) {
|
|
728
|
+
const shouldReset = (creditConfig.onRenewal ?? "reset") === "reset";
|
|
729
|
+
if (shouldReset) {
|
|
730
|
+
const currentBalance = await credits.getBalance(userId, creditType);
|
|
731
|
+
if (currentBalance > 0) {
|
|
732
|
+
await credits.revoke({
|
|
733
|
+
userId,
|
|
734
|
+
creditType,
|
|
735
|
+
amount: currentBalance,
|
|
736
|
+
source: "plan_change",
|
|
737
|
+
sourceId: subscriptionId
|
|
738
|
+
});
|
|
739
|
+
await callbacks?.onCreditsRevoked?.({
|
|
740
|
+
userId,
|
|
741
|
+
creditType,
|
|
742
|
+
amount: currentBalance,
|
|
743
|
+
previousBalance: currentBalance,
|
|
744
|
+
newBalance: 0,
|
|
745
|
+
source: "plan_change"
|
|
746
|
+
});
|
|
129
747
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
748
|
+
}
|
|
749
|
+
const idempotencyKey = `${idempotencyPrefix}:${creditType}`;
|
|
750
|
+
const newBalance = await credits.grant({
|
|
751
|
+
userId,
|
|
752
|
+
creditType,
|
|
753
|
+
amount: creditConfig.allocation,
|
|
754
|
+
source: "subscription",
|
|
755
|
+
sourceId: subscriptionId,
|
|
756
|
+
idempotencyKey
|
|
757
|
+
});
|
|
758
|
+
await callbacks?.onCreditsGranted?.({
|
|
759
|
+
userId,
|
|
760
|
+
creditType,
|
|
761
|
+
amount: creditConfig.allocation,
|
|
762
|
+
newBalance,
|
|
763
|
+
source: "subscription",
|
|
764
|
+
sourceId: subscriptionId
|
|
765
|
+
});
|
|
138
766
|
}
|
|
139
|
-
|
|
140
|
-
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async function revokeAllCredits(userId, subscriptionId) {
|
|
770
|
+
const allBalances = await credits.getAllBalances(userId);
|
|
771
|
+
for (const [creditType, balance] of Object.entries(allBalances)) {
|
|
772
|
+
if (balance > 0) {
|
|
773
|
+
const result = await credits.revoke({
|
|
774
|
+
userId,
|
|
775
|
+
creditType,
|
|
776
|
+
amount: balance,
|
|
777
|
+
source: "cancellation",
|
|
778
|
+
sourceId: subscriptionId
|
|
779
|
+
});
|
|
780
|
+
await callbacks?.onCreditsRevoked?.({
|
|
781
|
+
userId,
|
|
782
|
+
creditType,
|
|
783
|
+
amount: result.amountRevoked,
|
|
784
|
+
previousBalance: balance,
|
|
785
|
+
newBalance: result.balance,
|
|
786
|
+
source: "cancellation"
|
|
787
|
+
});
|
|
141
788
|
}
|
|
142
|
-
|
|
143
|
-
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
async onSubscriptionCreated(subscription) {
|
|
793
|
+
if (grantTo === "manual") return;
|
|
794
|
+
const plan = resolvePlan(subscription);
|
|
795
|
+
if (!plan?.credits) return;
|
|
796
|
+
if (grantTo === "seat-users") {
|
|
797
|
+
const firstSeatUserId = subscription.metadata?.first_seat_user_id;
|
|
798
|
+
if (firstSeatUserId) {
|
|
799
|
+
await grantOrResetCreditsForUser(
|
|
800
|
+
firstSeatUserId,
|
|
801
|
+
plan,
|
|
802
|
+
subscription.id,
|
|
803
|
+
"seat_grant",
|
|
804
|
+
`seat_${firstSeatUserId}_${subscription.id}`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
144
808
|
}
|
|
145
|
-
const
|
|
146
|
-
if (!
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
809
|
+
const userId = await resolveUserId(subscription);
|
|
810
|
+
if (!userId) return;
|
|
811
|
+
await grantPlanCredits(userId, plan, subscription.id, "subscription", subscription.id);
|
|
812
|
+
},
|
|
813
|
+
async onSubscriptionRenewed(subscription, invoiceId) {
|
|
814
|
+
if (grantTo === "manual") return;
|
|
815
|
+
const plan = resolvePlan(subscription);
|
|
816
|
+
if (!plan?.credits) return;
|
|
817
|
+
if (grantTo === "seat-users") {
|
|
818
|
+
const seatUsers = await getActiveSeatUsers(subscription.id);
|
|
819
|
+
for (const userId2 of seatUsers) {
|
|
820
|
+
const alreadyProcessed2 = await checkIdempotencyKeyPrefix(`renewal_${invoiceId}_${userId2}`);
|
|
821
|
+
if (alreadyProcessed2) {
|
|
822
|
+
throw new CreditError(
|
|
823
|
+
"IDEMPOTENCY_CONFLICT",
|
|
824
|
+
"Operation already processed",
|
|
825
|
+
{ idempotencyKey: `renewal_${invoiceId}_${userId2}` }
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
await grantOrResetCreditsForUser(
|
|
829
|
+
userId2,
|
|
830
|
+
plan,
|
|
831
|
+
subscription.id,
|
|
832
|
+
"renewal",
|
|
833
|
+
`renewal_${invoiceId}_${userId2}`
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const userId = await resolveUserId(subscription);
|
|
839
|
+
if (!userId) return;
|
|
840
|
+
const alreadyProcessed = await checkIdempotencyKeyPrefix(invoiceId);
|
|
841
|
+
if (alreadyProcessed) {
|
|
842
|
+
throw new CreditError(
|
|
843
|
+
"IDEMPOTENCY_CONFLICT",
|
|
844
|
+
"Operation already processed",
|
|
845
|
+
{ idempotencyKey: invoiceId }
|
|
150
846
|
);
|
|
151
847
|
}
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
848
|
+
for (const [creditType, creditConfig] of Object.entries(plan.credits)) {
|
|
849
|
+
if ((creditConfig.onRenewal ?? "reset") === "reset") {
|
|
850
|
+
const balance = await credits.getBalance(userId, creditType);
|
|
851
|
+
if (balance > 0) {
|
|
852
|
+
await credits.revoke({ userId, creditType, amount: balance, source: "renewal" });
|
|
853
|
+
await callbacks?.onCreditsRevoked?.({
|
|
854
|
+
userId,
|
|
855
|
+
creditType,
|
|
856
|
+
amount: balance,
|
|
857
|
+
previousBalance: balance,
|
|
858
|
+
newBalance: 0,
|
|
859
|
+
source: "renewal"
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
await grantPlanCredits(userId, plan, subscription.id, "renewal", invoiceId);
|
|
865
|
+
},
|
|
866
|
+
async onSubscriptionCancelled(subscription) {
|
|
867
|
+
if (grantTo === "manual") return;
|
|
868
|
+
if (grantTo === "seat-users") {
|
|
869
|
+
const seatUsers = await getActiveSeatUsers(subscription.id);
|
|
870
|
+
for (const userId2 of seatUsers) {
|
|
871
|
+
await revokeAllCredits(userId2, subscription.id);
|
|
872
|
+
}
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const userId = await resolveUserId(subscription);
|
|
876
|
+
if (!userId) return;
|
|
877
|
+
await revokeAllCredits(userId, subscription.id);
|
|
878
|
+
},
|
|
879
|
+
async onSubscriptionPlanChanged(subscription, previousPriceId) {
|
|
880
|
+
if (grantTo === "manual") return;
|
|
881
|
+
if (subscription.metadata?.pending_credit_downgrade === "true") {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const newPlan = resolvePlan(subscription);
|
|
885
|
+
const oldPlan = resolvePlanByPriceId(previousPriceId);
|
|
886
|
+
if (oldPlan?.id === newPlan?.id) return;
|
|
887
|
+
if (!newPlan?.credits && !oldPlan?.credits) return;
|
|
888
|
+
const newPriceId = subscription.items.data[0]?.price?.id ?? "unknown";
|
|
889
|
+
const idempotencyKey = `plan_change_${subscription.id}_${previousPriceId}_to_${newPriceId}`;
|
|
890
|
+
const upgradeFromAmount = subscription.metadata?.upgrade_from_price_amount;
|
|
891
|
+
const isUpgradeViaMetadata = upgradeFromAmount !== void 0;
|
|
892
|
+
const isFreeUpgrade = upgradeFromAmount === "0";
|
|
893
|
+
const bothHaveCredits = planHasCredits(oldPlan) && planHasCredits(newPlan);
|
|
894
|
+
const shouldRevokeOnUpgrade = isFreeUpgrade || !bothHaveCredits;
|
|
895
|
+
if (grantTo === "seat-users") {
|
|
896
|
+
const seatUsers = await getActiveSeatUsers(subscription.id);
|
|
897
|
+
for (const seatUserId of seatUsers) {
|
|
898
|
+
if (oldPlan?.credits && (!isUpgradeViaMetadata || shouldRevokeOnUpgrade)) {
|
|
899
|
+
await revokeSubscriptionCredits(seatUserId, subscription.id, "plan_change");
|
|
900
|
+
}
|
|
901
|
+
if (newPlan?.credits) {
|
|
902
|
+
await grantOrResetCreditsForUser(
|
|
903
|
+
seatUserId,
|
|
904
|
+
newPlan,
|
|
905
|
+
subscription.id,
|
|
906
|
+
"subscription",
|
|
907
|
+
`${idempotencyKey}:${seatUserId}`
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const userId = await resolveUserId(subscription);
|
|
914
|
+
if (!userId) return;
|
|
915
|
+
if (oldPlan?.credits && (!isUpgradeViaMetadata || shouldRevokeOnUpgrade)) {
|
|
916
|
+
await revokeSubscriptionCredits(userId, subscription.id, "plan_change");
|
|
917
|
+
}
|
|
918
|
+
if (newPlan?.credits) {
|
|
919
|
+
await grantPlanCredits(userId, newPlan, subscription.id, "subscription", idempotencyKey);
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
async onDowngradeApplied(subscription, newPriceId) {
|
|
923
|
+
if (grantTo === "manual") return;
|
|
924
|
+
const newPlan = resolvePlanByPriceId(newPriceId);
|
|
925
|
+
const idempotencyKey = `downgrade_${subscription.id}_to_${newPriceId}`;
|
|
926
|
+
if (grantTo === "seat-users") {
|
|
927
|
+
const seatUsers = await getActiveSeatUsers(subscription.id);
|
|
928
|
+
for (const seatUserId of seatUsers) {
|
|
929
|
+
await applyDowngradeCredits(seatUserId, newPlan, subscription.id, `${idempotencyKey}:${seatUserId}`);
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
158
932
|
}
|
|
159
|
-
|
|
933
|
+
const userId = await resolveUserId(subscription);
|
|
934
|
+
if (!userId) return;
|
|
935
|
+
await applyDowngradeCredits(userId, newPlan, subscription.id, idempotencyKey);
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// src/credits/topup.ts
|
|
941
|
+
function createTopUpHandler(deps) {
|
|
942
|
+
const {
|
|
943
|
+
stripe,
|
|
944
|
+
pool: pool2,
|
|
945
|
+
schema: schema2,
|
|
946
|
+
billingConfig,
|
|
947
|
+
mode,
|
|
948
|
+
successUrl,
|
|
949
|
+
cancelUrl,
|
|
950
|
+
onCreditsGranted,
|
|
951
|
+
onTopUpCompleted,
|
|
952
|
+
onAutoTopUpFailed,
|
|
953
|
+
onCreditsLow
|
|
954
|
+
} = deps;
|
|
955
|
+
async function getCustomerByUserId(userId) {
|
|
956
|
+
if (!pool2) return null;
|
|
957
|
+
const result = await pool2.query(
|
|
958
|
+
`SELECT c.id, c.deleted, c.invoice_settings->>'default_payment_method' as default_payment_method
|
|
959
|
+
FROM ${schema2}.user_stripe_customer_map m
|
|
960
|
+
JOIN ${schema2}.customers c ON c.id = m.stripe_customer_id
|
|
961
|
+
WHERE m.user_id = $1`,
|
|
962
|
+
[userId]
|
|
963
|
+
);
|
|
964
|
+
if (!result.rows[0]) return null;
|
|
965
|
+
return {
|
|
966
|
+
id: result.rows[0].id,
|
|
967
|
+
deleted: result.rows[0].deleted ?? false,
|
|
968
|
+
defaultPaymentMethod: result.rows[0].default_payment_method
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
async function getActiveSubscription2(customerId) {
|
|
972
|
+
if (!pool2) return null;
|
|
973
|
+
const result = await pool2.query(
|
|
974
|
+
`SELECT s.id, si.price, p.currency
|
|
975
|
+
FROM ${schema2}.subscriptions s
|
|
976
|
+
JOIN ${schema2}.subscription_items si ON si.subscription = s.id
|
|
977
|
+
JOIN ${schema2}.prices p ON p.id = si.price
|
|
978
|
+
WHERE s.customer = $1 AND s.status IN ('active', 'trialing', 'past_due')
|
|
979
|
+
ORDER BY s.created DESC
|
|
980
|
+
LIMIT 1`,
|
|
981
|
+
[customerId]
|
|
982
|
+
);
|
|
983
|
+
if (!result.rows[0]) return null;
|
|
984
|
+
return {
|
|
985
|
+
id: result.rows[0].id,
|
|
986
|
+
priceId: result.rows[0].price,
|
|
987
|
+
currency: result.rows[0].currency
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
async function createRecoveryCheckout(customerId, creditType, amount, totalCents, currency) {
|
|
991
|
+
if (!successUrl || !cancelUrl) {
|
|
992
|
+
throw new CreditError(
|
|
993
|
+
"MISSING_CONFIG",
|
|
994
|
+
"successUrl and cancelUrl are required for recovery checkout. Configure them in createStripeHandler."
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
const displayName = creditType.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
998
|
+
const session = await stripe.checkout.sessions.create({
|
|
999
|
+
customer: customerId,
|
|
1000
|
+
mode: "payment",
|
|
1001
|
+
payment_method_types: ["card"],
|
|
1002
|
+
line_items: [
|
|
1003
|
+
{
|
|
1004
|
+
price_data: {
|
|
1005
|
+
currency,
|
|
1006
|
+
unit_amount: totalCents,
|
|
1007
|
+
product_data: {
|
|
1008
|
+
name: `${amount} ${displayName}`,
|
|
1009
|
+
description: "Credit top-up"
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
quantity: 1
|
|
1013
|
+
}
|
|
1014
|
+
],
|
|
1015
|
+
metadata: {
|
|
1016
|
+
top_up_credit_type: creditType,
|
|
1017
|
+
top_up_amount: String(amount)
|
|
1018
|
+
},
|
|
1019
|
+
success_url: successUrl,
|
|
1020
|
+
cancel_url: cancelUrl
|
|
1021
|
+
});
|
|
1022
|
+
if (!session.url) {
|
|
1023
|
+
throw new CreditError(
|
|
1024
|
+
"CHECKOUT_ERROR",
|
|
1025
|
+
"Failed to create checkout session URL"
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
return session.url;
|
|
1029
|
+
}
|
|
1030
|
+
async function tryCreateRecoveryCheckout(customerId, creditType, amount, totalCents, currency) {
|
|
1031
|
+
try {
|
|
1032
|
+
return await createRecoveryCheckout(
|
|
1033
|
+
customerId,
|
|
1034
|
+
creditType,
|
|
1035
|
+
amount,
|
|
1036
|
+
totalCents,
|
|
1037
|
+
currency
|
|
1038
|
+
);
|
|
160
1039
|
} catch (err) {
|
|
161
|
-
console.error("
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
1040
|
+
console.error("Failed to create recovery checkout:", err);
|
|
1041
|
+
return void 0;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
async function topUp(params) {
|
|
1045
|
+
const { userId, creditType, amount, idempotencyKey } = params;
|
|
1046
|
+
const customer = await getCustomerByUserId(userId);
|
|
1047
|
+
if (!customer) {
|
|
1048
|
+
return {
|
|
1049
|
+
success: false,
|
|
1050
|
+
error: {
|
|
1051
|
+
code: "USER_NOT_FOUND",
|
|
1052
|
+
message: "No Stripe customer found for user"
|
|
1053
|
+
}
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
if (customer.deleted) {
|
|
1057
|
+
return {
|
|
1058
|
+
success: false,
|
|
1059
|
+
error: { code: "USER_NOT_FOUND", message: "Customer has been deleted" }
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
const subscription = await getActiveSubscription2(customer.id);
|
|
1063
|
+
if (!subscription) {
|
|
1064
|
+
return {
|
|
1065
|
+
success: false,
|
|
1066
|
+
error: {
|
|
1067
|
+
code: "NO_SUBSCRIPTION",
|
|
1068
|
+
message: "No active subscription found"
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
const plan = billingConfig?.[mode]?.plans?.find(
|
|
1073
|
+
(p) => p.price.some((pr) => pr.id === subscription.priceId)
|
|
1074
|
+
);
|
|
1075
|
+
if (!plan) {
|
|
1076
|
+
return {
|
|
1077
|
+
success: false,
|
|
1078
|
+
error: {
|
|
1079
|
+
code: "TOPUP_NOT_CONFIGURED",
|
|
1080
|
+
message: "Could not determine plan from subscription"
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
const topUpConfig = plan.credits?.[creditType]?.topUp;
|
|
1085
|
+
if (!topUpConfig || topUpConfig.mode !== "on_demand") {
|
|
1086
|
+
return {
|
|
1087
|
+
success: false,
|
|
1088
|
+
error: {
|
|
1089
|
+
code: "TOPUP_NOT_CONFIGURED",
|
|
1090
|
+
message: `On-demand top-up not configured for ${creditType}`
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
const {
|
|
1095
|
+
pricePerCreditCents,
|
|
1096
|
+
minPerPurchase = 1,
|
|
1097
|
+
maxPerPurchase
|
|
1098
|
+
} = topUpConfig;
|
|
1099
|
+
if (amount < minPerPurchase) {
|
|
1100
|
+
return {
|
|
1101
|
+
success: false,
|
|
1102
|
+
error: {
|
|
1103
|
+
code: "INVALID_AMOUNT",
|
|
1104
|
+
message: `Minimum purchase is ${minPerPurchase} credits`
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
if (maxPerPurchase !== void 0 && amount > maxPerPurchase) {
|
|
1109
|
+
return {
|
|
1110
|
+
success: false,
|
|
1111
|
+
error: {
|
|
1112
|
+
code: "INVALID_AMOUNT",
|
|
1113
|
+
message: `Maximum purchase is ${maxPerPurchase} credits`
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const totalCents = amount * pricePerCreditCents;
|
|
1118
|
+
const STRIPE_MIN_CENTS = 60;
|
|
1119
|
+
if (totalCents < STRIPE_MIN_CENTS) {
|
|
1120
|
+
return {
|
|
1121
|
+
success: false,
|
|
1122
|
+
error: {
|
|
1123
|
+
code: "INVALID_AMOUNT",
|
|
1124
|
+
message: `Minimum purchase amount is ${STRIPE_MIN_CENTS} cents (${Math.ceil(STRIPE_MIN_CENTS / pricePerCreditCents)} credits at current price)`
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
const currency = subscription.currency;
|
|
1129
|
+
if (!customer.defaultPaymentMethod) {
|
|
1130
|
+
const recoveryUrl = await tryCreateRecoveryCheckout(
|
|
1131
|
+
customer.id,
|
|
1132
|
+
creditType,
|
|
1133
|
+
amount,
|
|
1134
|
+
totalCents,
|
|
1135
|
+
currency
|
|
1136
|
+
);
|
|
1137
|
+
return {
|
|
1138
|
+
success: false,
|
|
1139
|
+
error: {
|
|
1140
|
+
code: "NO_PAYMENT_METHOD",
|
|
1141
|
+
message: "No payment method on file",
|
|
1142
|
+
recoveryUrl
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
try {
|
|
1147
|
+
const paymentIntent = await stripe.paymentIntents.create(
|
|
1148
|
+
{
|
|
1149
|
+
amount: totalCents,
|
|
1150
|
+
currency,
|
|
1151
|
+
customer: customer.id,
|
|
1152
|
+
payment_method: customer.defaultPaymentMethod,
|
|
1153
|
+
confirm: true,
|
|
1154
|
+
off_session: true,
|
|
1155
|
+
metadata: {
|
|
1156
|
+
top_up_credit_type: creditType,
|
|
1157
|
+
top_up_amount: String(amount),
|
|
1158
|
+
user_id: userId
|
|
1159
|
+
}
|
|
1160
|
+
},
|
|
1161
|
+
idempotencyKey ? { idempotencyKey } : void 0
|
|
1162
|
+
);
|
|
1163
|
+
if (paymentIntent.status === "succeeded") {
|
|
1164
|
+
const newBalance = await grantCreditsFromPayment(paymentIntent);
|
|
1165
|
+
return {
|
|
1166
|
+
success: true,
|
|
1167
|
+
balance: newBalance,
|
|
1168
|
+
charged: { amountCents: totalCents, currency },
|
|
1169
|
+
paymentIntentId: paymentIntent.id
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (paymentIntent.status === "processing") {
|
|
1173
|
+
return {
|
|
1174
|
+
success: true,
|
|
1175
|
+
status: "pending",
|
|
1176
|
+
paymentIntentId: paymentIntent.id,
|
|
1177
|
+
message: "Payment is processing. Credits will be added once payment completes."
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const recoveryUrl = await tryCreateRecoveryCheckout(
|
|
1181
|
+
customer.id,
|
|
1182
|
+
creditType,
|
|
1183
|
+
amount,
|
|
1184
|
+
totalCents,
|
|
1185
|
+
currency
|
|
1186
|
+
);
|
|
1187
|
+
return {
|
|
1188
|
+
success: false,
|
|
1189
|
+
error: {
|
|
1190
|
+
code: "PAYMENT_FAILED",
|
|
1191
|
+
message: `Payment status: ${paymentIntent.status}`,
|
|
1192
|
+
recoveryUrl
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
const stripeError = err;
|
|
1197
|
+
const isCardError = stripeError.type === "card_error";
|
|
1198
|
+
const isInvalidRequest = stripeError.type === "invalid_request_error";
|
|
1199
|
+
const errorCode = stripeError.code;
|
|
1200
|
+
const message = stripeError.message || "Payment failed";
|
|
1201
|
+
if (isCardError) {
|
|
1202
|
+
const recoveryUrl = await tryCreateRecoveryCheckout(
|
|
1203
|
+
customer.id,
|
|
1204
|
+
creditType,
|
|
1205
|
+
amount,
|
|
1206
|
+
totalCents,
|
|
1207
|
+
currency
|
|
1208
|
+
);
|
|
1209
|
+
return {
|
|
1210
|
+
success: false,
|
|
1211
|
+
error: {
|
|
1212
|
+
code: "PAYMENT_FAILED",
|
|
1213
|
+
message,
|
|
1214
|
+
recoveryUrl
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
success: false,
|
|
1220
|
+
error: {
|
|
1221
|
+
code: isInvalidRequest ? "INVALID_AMOUNT" : "PAYMENT_FAILED",
|
|
1222
|
+
message: errorCode ? `${errorCode}: ${message}` : message
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
168
1225
|
}
|
|
169
1226
|
}
|
|
170
|
-
async function
|
|
1227
|
+
async function grantCreditsFromPayment(paymentIntent) {
|
|
1228
|
+
const creditType = paymentIntent.metadata?.top_up_credit_type;
|
|
1229
|
+
const amountStr = paymentIntent.metadata?.top_up_amount;
|
|
1230
|
+
const userId = paymentIntent.metadata?.user_id;
|
|
1231
|
+
const isAuto = paymentIntent.metadata?.top_up_auto === "true";
|
|
1232
|
+
if (!creditType || !amountStr || !userId) {
|
|
1233
|
+
throw new CreditError(
|
|
1234
|
+
"INVALID_METADATA",
|
|
1235
|
+
"Missing top-up metadata on PaymentIntent"
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
const amount = parseInt(amountStr, 10);
|
|
1239
|
+
if (isNaN(amount) || amount <= 0) {
|
|
1240
|
+
throw new CreditError(
|
|
1241
|
+
"INVALID_METADATA",
|
|
1242
|
+
"Invalid top_up_amount in PaymentIntent metadata"
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
const source = isAuto ? "auto_topup" : "topup";
|
|
1246
|
+
let newBalance;
|
|
1247
|
+
let alreadyGranted = false;
|
|
171
1248
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
1249
|
+
newBalance = await credits.grant({
|
|
1250
|
+
userId,
|
|
1251
|
+
creditType,
|
|
1252
|
+
amount,
|
|
1253
|
+
source,
|
|
1254
|
+
sourceId: paymentIntent.id,
|
|
1255
|
+
idempotencyKey: `topup_${paymentIntent.id}`
|
|
1256
|
+
});
|
|
1257
|
+
} catch (grantErr) {
|
|
1258
|
+
if (grantErr instanceof CreditError && grantErr.code === "IDEMPOTENCY_CONFLICT") {
|
|
1259
|
+
newBalance = await credits.getBalance(userId, creditType);
|
|
1260
|
+
alreadyGranted = true;
|
|
179
1261
|
} else {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
1262
|
+
throw grantErr;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (!alreadyGranted) {
|
|
1266
|
+
await onCreditsGranted?.({
|
|
1267
|
+
userId,
|
|
1268
|
+
creditType,
|
|
1269
|
+
amount,
|
|
1270
|
+
newBalance,
|
|
1271
|
+
source,
|
|
1272
|
+
sourceId: paymentIntent.id
|
|
1273
|
+
});
|
|
1274
|
+
await onTopUpCompleted?.({
|
|
1275
|
+
userId,
|
|
1276
|
+
creditType,
|
|
1277
|
+
creditsAdded: amount,
|
|
1278
|
+
amountCharged: paymentIntent.amount,
|
|
1279
|
+
currency: paymentIntent.currency,
|
|
1280
|
+
newBalance,
|
|
1281
|
+
paymentIntentId: paymentIntent.id
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
return newBalance;
|
|
1285
|
+
}
|
|
1286
|
+
async function handlePaymentIntentSucceeded(paymentIntent) {
|
|
1287
|
+
if (!paymentIntent.metadata?.top_up_credit_type) {
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
await grantCreditsFromPayment(paymentIntent);
|
|
1291
|
+
}
|
|
1292
|
+
async function handleTopUpCheckoutCompleted(session) {
|
|
1293
|
+
const creditType = session.metadata?.top_up_credit_type;
|
|
1294
|
+
const amountStr = session.metadata?.top_up_amount;
|
|
1295
|
+
if (!creditType || !amountStr) return;
|
|
1296
|
+
if (session.payment_status !== "paid") {
|
|
1297
|
+
console.warn(
|
|
1298
|
+
`Top-up checkout ${session.id} has payment_status '${session.payment_status}', skipping`
|
|
1299
|
+
);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const amount = parseInt(amountStr, 10);
|
|
1303
|
+
if (isNaN(amount) || amount <= 0) {
|
|
1304
|
+
throw new CreditError(
|
|
1305
|
+
"INVALID_METADATA",
|
|
1306
|
+
"Invalid top_up_amount in checkout session metadata"
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id;
|
|
1310
|
+
if (!customerId) {
|
|
1311
|
+
throw new CreditError(
|
|
1312
|
+
"MISSING_CUSTOMER",
|
|
1313
|
+
"No customer ID in checkout session"
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
if (!pool2) {
|
|
1317
|
+
throw new CreditError("NO_DATABASE", "Database connection required");
|
|
1318
|
+
}
|
|
1319
|
+
const mappingResult = await pool2.query(
|
|
1320
|
+
`SELECT user_id FROM ${schema2}.user_stripe_customer_map WHERE stripe_customer_id = $1`,
|
|
1321
|
+
[customerId]
|
|
1322
|
+
);
|
|
1323
|
+
const userId = mappingResult.rows[0]?.user_id;
|
|
1324
|
+
if (!userId) {
|
|
1325
|
+
throw new CreditError(
|
|
1326
|
+
"MISSING_USER_ID",
|
|
1327
|
+
"No user mapping found for customer"
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
const paymentIntentId = typeof session.payment_intent === "string" ? session.payment_intent : session.payment_intent?.id ?? session.id;
|
|
1331
|
+
let newBalance;
|
|
1332
|
+
try {
|
|
1333
|
+
newBalance = await credits.grant({
|
|
1334
|
+
userId,
|
|
1335
|
+
creditType,
|
|
1336
|
+
amount,
|
|
1337
|
+
source: "topup",
|
|
1338
|
+
sourceId: paymentIntentId,
|
|
1339
|
+
idempotencyKey: `topup_${paymentIntentId}`
|
|
1340
|
+
});
|
|
1341
|
+
} catch (grantErr) {
|
|
1342
|
+
if (grantErr instanceof CreditError && grantErr.code === "IDEMPOTENCY_CONFLICT") {
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
throw grantErr;
|
|
1346
|
+
}
|
|
1347
|
+
await onCreditsGranted?.({
|
|
1348
|
+
userId,
|
|
1349
|
+
creditType,
|
|
1350
|
+
amount,
|
|
1351
|
+
newBalance,
|
|
1352
|
+
source: "topup",
|
|
1353
|
+
sourceId: paymentIntentId
|
|
1354
|
+
});
|
|
1355
|
+
await onTopUpCompleted?.({
|
|
1356
|
+
userId,
|
|
1357
|
+
creditType,
|
|
1358
|
+
creditsAdded: amount,
|
|
1359
|
+
amountCharged: session.amount_total ?? 0,
|
|
1360
|
+
currency: session.currency ?? "usd",
|
|
1361
|
+
newBalance,
|
|
1362
|
+
paymentIntentId
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
async function hasPaymentMethod2(userId) {
|
|
1366
|
+
const customer = await getCustomerByUserId(userId);
|
|
1367
|
+
return !!customer?.defaultPaymentMethod;
|
|
1368
|
+
}
|
|
1369
|
+
async function triggerAutoTopUpIfNeeded(params) {
|
|
1370
|
+
const { userId, creditType, currentBalance } = params;
|
|
1371
|
+
const customer = await getCustomerByUserId(userId);
|
|
1372
|
+
if (!customer || customer.deleted) {
|
|
1373
|
+
return { triggered: false, reason: "user_not_found" };
|
|
1374
|
+
}
|
|
1375
|
+
const subscription = await getActiveSubscription2(customer.id);
|
|
1376
|
+
if (!subscription) {
|
|
1377
|
+
return { triggered: false, reason: "no_subscription" };
|
|
1378
|
+
}
|
|
1379
|
+
const plan = billingConfig?.[mode]?.plans?.find(
|
|
1380
|
+
(p) => p.price.some((pr) => pr.id === subscription.priceId)
|
|
1381
|
+
);
|
|
1382
|
+
if (!plan) {
|
|
1383
|
+
return { triggered: false, reason: "not_configured" };
|
|
1384
|
+
}
|
|
1385
|
+
const topUpConfig = plan.credits?.[creditType]?.topUp;
|
|
1386
|
+
if (!topUpConfig || topUpConfig.mode !== "auto") {
|
|
1387
|
+
return { triggered: false, reason: "not_configured" };
|
|
1388
|
+
}
|
|
1389
|
+
const {
|
|
1390
|
+
pricePerCreditCents,
|
|
1391
|
+
balanceThreshold,
|
|
1392
|
+
purchaseAmount,
|
|
1393
|
+
maxPerMonth = 10
|
|
1394
|
+
} = topUpConfig;
|
|
1395
|
+
if (purchaseAmount <= 0 || balanceThreshold <= 0 || pricePerCreditCents <= 0) {
|
|
1396
|
+
console.error(
|
|
1397
|
+
`Invalid auto top-up config for ${creditType}: purchaseAmount=${purchaseAmount}, balanceThreshold=${balanceThreshold}, pricePerCreditCents=${pricePerCreditCents}`
|
|
1398
|
+
);
|
|
1399
|
+
return { triggered: false, reason: "not_configured" };
|
|
1400
|
+
}
|
|
1401
|
+
if (currentBalance >= balanceThreshold) {
|
|
1402
|
+
return { triggered: false, reason: "balance_above_threshold" };
|
|
1403
|
+
}
|
|
1404
|
+
await onCreditsLow?.({
|
|
1405
|
+
userId,
|
|
1406
|
+
creditType,
|
|
1407
|
+
balance: currentBalance,
|
|
1408
|
+
threshold: balanceThreshold
|
|
1409
|
+
});
|
|
1410
|
+
if (!customer.defaultPaymentMethod) {
|
|
1411
|
+
await onAutoTopUpFailed?.({
|
|
1412
|
+
userId,
|
|
1413
|
+
creditType,
|
|
1414
|
+
reason: "no_payment_method"
|
|
1415
|
+
});
|
|
1416
|
+
return { triggered: false, reason: "no_payment_method" };
|
|
1417
|
+
}
|
|
1418
|
+
const autoTopUpsThisMonth = await countAutoTopUpsThisMonth(
|
|
1419
|
+
userId,
|
|
1420
|
+
creditType
|
|
1421
|
+
);
|
|
1422
|
+
if (autoTopUpsThisMonth >= maxPerMonth) {
|
|
1423
|
+
await onAutoTopUpFailed?.({
|
|
1424
|
+
userId,
|
|
1425
|
+
creditType,
|
|
1426
|
+
reason: "max_per_month_reached"
|
|
1427
|
+
});
|
|
1428
|
+
return { triggered: false, reason: "max_per_month_reached" };
|
|
1429
|
+
}
|
|
1430
|
+
const totalCents = purchaseAmount * pricePerCreditCents;
|
|
1431
|
+
const currency = subscription.currency;
|
|
1432
|
+
const now = /* @__PURE__ */ new Date();
|
|
1433
|
+
const yearMonth = `${now.getUTCFullYear()}-${String(
|
|
1434
|
+
now.getUTCMonth() + 1
|
|
1435
|
+
).padStart(2, "0")}`;
|
|
1436
|
+
const idempotencyKey = `auto_topup_${userId}_${creditType}_${yearMonth}_${autoTopUpsThisMonth + 1}`;
|
|
1437
|
+
try {
|
|
1438
|
+
const paymentIntent = await stripe.paymentIntents.create(
|
|
1439
|
+
{
|
|
1440
|
+
amount: totalCents,
|
|
1441
|
+
currency,
|
|
1442
|
+
customer: customer.id,
|
|
1443
|
+
payment_method: customer.defaultPaymentMethod,
|
|
1444
|
+
confirm: true,
|
|
1445
|
+
off_session: true,
|
|
1446
|
+
metadata: {
|
|
1447
|
+
top_up_credit_type: creditType,
|
|
1448
|
+
top_up_amount: String(purchaseAmount),
|
|
1449
|
+
user_id: userId,
|
|
1450
|
+
top_up_auto: "true"
|
|
1451
|
+
}
|
|
1452
|
+
},
|
|
1453
|
+
{ idempotencyKey }
|
|
1454
|
+
);
|
|
1455
|
+
if (paymentIntent.status === "succeeded") {
|
|
1456
|
+
await grantCreditsFromPayment(paymentIntent);
|
|
1457
|
+
return {
|
|
1458
|
+
triggered: true,
|
|
1459
|
+
status: "succeeded",
|
|
1460
|
+
paymentIntentId: paymentIntent.id
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
if (paymentIntent.status === "processing") {
|
|
1464
|
+
return {
|
|
1465
|
+
triggered: true,
|
|
1466
|
+
status: "pending",
|
|
1467
|
+
paymentIntentId: paymentIntent.id
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
const message = `Payment requires action: ${paymentIntent.status}`;
|
|
1471
|
+
await onAutoTopUpFailed?.({
|
|
1472
|
+
userId,
|
|
1473
|
+
creditType,
|
|
1474
|
+
reason: "payment_requires_action",
|
|
1475
|
+
error: message
|
|
1476
|
+
});
|
|
1477
|
+
return {
|
|
1478
|
+
triggered: false,
|
|
1479
|
+
reason: "payment_requires_action",
|
|
1480
|
+
error: message
|
|
1481
|
+
};
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
const message = err instanceof Error ? err.message : "Payment failed";
|
|
1484
|
+
await onAutoTopUpFailed?.({
|
|
1485
|
+
userId,
|
|
1486
|
+
creditType,
|
|
1487
|
+
reason: "payment_failed",
|
|
1488
|
+
error: message
|
|
1489
|
+
});
|
|
1490
|
+
return {
|
|
1491
|
+
triggered: false,
|
|
1492
|
+
reason: "payment_failed",
|
|
1493
|
+
error: message
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return {
|
|
1498
|
+
topUp,
|
|
1499
|
+
hasPaymentMethod: hasPaymentMethod2,
|
|
1500
|
+
triggerAutoTopUpIfNeeded,
|
|
1501
|
+
handlePaymentIntentSucceeded,
|
|
1502
|
+
handleTopUpCheckoutCompleted
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// src/credits/seats.ts
|
|
1507
|
+
function createSeatsApi(config) {
|
|
1508
|
+
const { stripe, pool: pool2, schema: schema2, billingConfig, mode, grantTo, callbacks } = config;
|
|
1509
|
+
async function resolveStripeCustomerId(entityId) {
|
|
1510
|
+
if (!pool2) return null;
|
|
1511
|
+
return getStripeCustomerId(pool2, schema2, entityId);
|
|
1512
|
+
}
|
|
1513
|
+
async function resolveActiveSubscription(customerId) {
|
|
1514
|
+
return getActiveSubscription(stripe, customerId);
|
|
1515
|
+
}
|
|
1516
|
+
function resolvePlan(subscription) {
|
|
1517
|
+
return getPlanFromSubscription(subscription, billingConfig, mode);
|
|
1518
|
+
}
|
|
1519
|
+
async function grantSeatCredits(userId, plan, subscriptionId, idempotencyPrefix) {
|
|
1520
|
+
const creditsGranted = {};
|
|
1521
|
+
if (!plan.credits) return creditsGranted;
|
|
1522
|
+
for (const [creditType, creditConfig] of Object.entries(plan.credits)) {
|
|
1523
|
+
const idempotencyKey = idempotencyPrefix ? `${idempotencyPrefix}:${creditType}` : void 0;
|
|
1524
|
+
const newBalance = await credits.grant({
|
|
1525
|
+
userId,
|
|
1526
|
+
creditType,
|
|
1527
|
+
amount: creditConfig.allocation,
|
|
1528
|
+
source: "seat_grant",
|
|
1529
|
+
sourceId: subscriptionId,
|
|
1530
|
+
idempotencyKey
|
|
1531
|
+
});
|
|
1532
|
+
creditsGranted[creditType] = creditConfig.allocation;
|
|
1533
|
+
await callbacks?.onCreditsGranted?.({
|
|
1534
|
+
userId,
|
|
1535
|
+
creditType,
|
|
1536
|
+
amount: creditConfig.allocation,
|
|
1537
|
+
newBalance,
|
|
1538
|
+
source: "seat_grant",
|
|
1539
|
+
sourceId: subscriptionId
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
return creditsGranted;
|
|
1543
|
+
}
|
|
1544
|
+
async function add(params) {
|
|
1545
|
+
const { userId, orgId } = params;
|
|
1546
|
+
const customerId = await resolveStripeCustomerId(orgId);
|
|
1547
|
+
if (!customerId) {
|
|
1548
|
+
return { success: false, error: "Org has no Stripe customer" };
|
|
1549
|
+
}
|
|
1550
|
+
const subscription = await resolveActiveSubscription(customerId);
|
|
1551
|
+
if (!subscription) {
|
|
1552
|
+
return { success: false, error: "No active subscription found for org" };
|
|
1553
|
+
}
|
|
1554
|
+
const plan = resolvePlan(subscription);
|
|
1555
|
+
if (!plan) {
|
|
1556
|
+
return { success: false, error: "Could not resolve plan from subscription" };
|
|
1557
|
+
}
|
|
1558
|
+
let creditsGranted = {};
|
|
1559
|
+
let alreadyProcessed = false;
|
|
1560
|
+
if (grantTo !== "manual" && plan.credits) {
|
|
1561
|
+
const creditRecipient = grantTo === "seat-users" ? userId : orgId;
|
|
1562
|
+
const idempotencyPrefix = `seat_${orgId}_${userId}_${subscription.id}`;
|
|
1563
|
+
if (grantTo === "seat-users") {
|
|
1564
|
+
const existingSubscription = await getUserSeatSubscription(userId);
|
|
1565
|
+
if (existingSubscription && existingSubscription !== subscription.id) {
|
|
1566
|
+
return {
|
|
1567
|
+
success: false,
|
|
1568
|
+
error: "User is already a seat of another subscription"
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
if (existingSubscription === subscription.id) {
|
|
1572
|
+
alreadyProcessed = true;
|
|
184
1573
|
}
|
|
1574
|
+
}
|
|
1575
|
+
if (!alreadyProcessed) {
|
|
185
1576
|
try {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
1577
|
+
creditsGranted = await grantSeatCredits(
|
|
1578
|
+
creditRecipient,
|
|
1579
|
+
plan,
|
|
1580
|
+
subscription.id,
|
|
1581
|
+
idempotencyPrefix
|
|
190
1582
|
);
|
|
191
1583
|
} catch (err) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1584
|
+
if (err instanceof CreditError && err.code === "IDEMPOTENCY_CONFLICT") {
|
|
1585
|
+
alreadyProcessed = true;
|
|
1586
|
+
} else {
|
|
1587
|
+
throw err;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (plan.perSeat) {
|
|
1593
|
+
const item = subscription.items.data[0];
|
|
1594
|
+
if (item) {
|
|
1595
|
+
await stripe.subscriptions.update(
|
|
1596
|
+
subscription.id,
|
|
1597
|
+
{
|
|
1598
|
+
items: [{ id: item.id, quantity: (item.quantity ?? 1) + 1 }],
|
|
1599
|
+
proration_behavior: "create_prorations"
|
|
1600
|
+
},
|
|
1601
|
+
{ idempotencyKey: `add_seat_${orgId}_${userId}_${subscription.id}` }
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return { success: true, creditsGranted };
|
|
1606
|
+
}
|
|
1607
|
+
async function remove(params) {
|
|
1608
|
+
const { userId, orgId } = params;
|
|
1609
|
+
const customerId = await resolveStripeCustomerId(orgId);
|
|
1610
|
+
if (!customerId) {
|
|
1611
|
+
return { success: false, error: "Org has no Stripe customer" };
|
|
1612
|
+
}
|
|
1613
|
+
const subscription = await resolveActiveSubscription(customerId);
|
|
1614
|
+
if (!subscription) {
|
|
1615
|
+
return { success: false, error: "No active subscription found for org" };
|
|
1616
|
+
}
|
|
1617
|
+
const plan = resolvePlan(subscription);
|
|
1618
|
+
if (!plan) {
|
|
1619
|
+
return { success: false, error: "Could not resolve plan from subscription" };
|
|
1620
|
+
}
|
|
1621
|
+
const creditsRevoked = {};
|
|
1622
|
+
if (grantTo !== "manual" && plan.credits) {
|
|
1623
|
+
const creditHolder = grantTo === "seat-users" ? userId : orgId;
|
|
1624
|
+
const grantsFromSeat = await getCreditsGrantedBySource(creditHolder, subscription.id);
|
|
1625
|
+
for (const [creditType, grantedAmount] of Object.entries(grantsFromSeat)) {
|
|
1626
|
+
if (grantedAmount > 0) {
|
|
1627
|
+
const currentBalance = await credits.getBalance(creditHolder, creditType);
|
|
1628
|
+
const amountToRevoke = Math.min(grantedAmount, currentBalance);
|
|
1629
|
+
if (amountToRevoke > 0) {
|
|
1630
|
+
const result = await credits.revoke({
|
|
1631
|
+
userId: creditHolder,
|
|
1632
|
+
creditType,
|
|
1633
|
+
amount: amountToRevoke,
|
|
1634
|
+
source: "seat_revoke",
|
|
1635
|
+
sourceId: subscription.id
|
|
1636
|
+
});
|
|
1637
|
+
creditsRevoked[creditType] = result.amountRevoked;
|
|
1638
|
+
await callbacks?.onCreditsRevoked?.({
|
|
1639
|
+
userId: creditHolder,
|
|
1640
|
+
creditType,
|
|
1641
|
+
amount: result.amountRevoked,
|
|
1642
|
+
previousBalance: currentBalance,
|
|
1643
|
+
newBalance: result.balance,
|
|
1644
|
+
source: "seat_revoke"
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
if (plan.perSeat) {
|
|
1651
|
+
const item = subscription.items.data[0];
|
|
1652
|
+
if (item) {
|
|
1653
|
+
const currentQuantity = item.quantity ?? 1;
|
|
1654
|
+
const newQuantity = Math.max(1, currentQuantity - 1);
|
|
1655
|
+
if (newQuantity !== currentQuantity) {
|
|
1656
|
+
await stripe.subscriptions.update(
|
|
1657
|
+
subscription.id,
|
|
1658
|
+
{
|
|
1659
|
+
items: [{ id: item.id, quantity: newQuantity }],
|
|
1660
|
+
proration_behavior: "create_prorations"
|
|
1661
|
+
},
|
|
1662
|
+
{ idempotencyKey: `remove_seat_${orgId}_${userId}_${subscription.id}` }
|
|
196
1663
|
);
|
|
197
1664
|
}
|
|
198
1665
|
}
|
|
199
|
-
|
|
200
|
-
|
|
1666
|
+
}
|
|
1667
|
+
return { success: true, creditsRevoked };
|
|
1668
|
+
}
|
|
1669
|
+
return { add, remove };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// src/subscriptions.ts
|
|
1673
|
+
function createSubscriptionsApi(deps) {
|
|
1674
|
+
const { pool: pool2, schema: schema2, billingConfig, mode } = deps;
|
|
1675
|
+
function resolvePlanFromPriceId(priceId) {
|
|
1676
|
+
const plans = billingConfig?.[mode]?.plans;
|
|
1677
|
+
if (!plans) return null;
|
|
1678
|
+
for (const plan of plans) {
|
|
1679
|
+
const matchingPrice = plan.price.find((p) => p.id === priceId);
|
|
1680
|
+
if (matchingPrice) {
|
|
1681
|
+
return { id: plan.id || plan.name, name: plan.name };
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
function rowToSubscription(row) {
|
|
1687
|
+
const priceId = row.items?.data?.[0]?.price?.id;
|
|
1688
|
+
const plan = priceId ? resolvePlanFromPriceId(priceId) : null;
|
|
1689
|
+
return {
|
|
1690
|
+
id: row.id,
|
|
1691
|
+
status: row.status,
|
|
1692
|
+
plan: plan ? { ...plan, priceId } : null,
|
|
1693
|
+
currentPeriodStart: new Date(row.current_period_start * 1e3),
|
|
1694
|
+
currentPeriodEnd: new Date(row.current_period_end * 1e3),
|
|
1695
|
+
cancelAtPeriodEnd: row.cancel_at_period_end
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
async function isActive(userId) {
|
|
1699
|
+
if (!pool2) return false;
|
|
1700
|
+
const customerId = await getStripeCustomerId(pool2, schema2, userId);
|
|
1701
|
+
if (!customerId) return false;
|
|
1702
|
+
const result = await pool2.query(
|
|
1703
|
+
`SELECT 1 FROM ${schema2}.subscriptions
|
|
1704
|
+
WHERE customer = $1 AND status IN ('active', 'trialing')
|
|
1705
|
+
LIMIT 1`,
|
|
1706
|
+
[customerId]
|
|
1707
|
+
);
|
|
1708
|
+
return result.rows.length > 0;
|
|
1709
|
+
}
|
|
1710
|
+
async function get(userId) {
|
|
1711
|
+
if (!pool2) return null;
|
|
1712
|
+
const customerId = await getStripeCustomerId(pool2, schema2, userId);
|
|
1713
|
+
if (!customerId) return null;
|
|
1714
|
+
let result = await pool2.query(
|
|
1715
|
+
`SELECT id, status, customer, current_period_start, current_period_end,
|
|
1716
|
+
cancel_at_period_end, items
|
|
1717
|
+
FROM ${schema2}.subscriptions
|
|
1718
|
+
WHERE customer = $1 AND status IN ('active', 'trialing')
|
|
1719
|
+
ORDER BY current_period_end DESC
|
|
1720
|
+
LIMIT 1`,
|
|
1721
|
+
[customerId]
|
|
1722
|
+
);
|
|
1723
|
+
if (result.rows.length === 0) {
|
|
1724
|
+
result = await pool2.query(
|
|
1725
|
+
`SELECT id, status, customer, current_period_start, current_period_end,
|
|
1726
|
+
cancel_at_period_end, items
|
|
1727
|
+
FROM ${schema2}.subscriptions
|
|
1728
|
+
WHERE customer = $1
|
|
1729
|
+
ORDER BY current_period_end DESC
|
|
1730
|
+
LIMIT 1`,
|
|
1731
|
+
[customerId]
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
if (result.rows.length === 0) return null;
|
|
1735
|
+
return rowToSubscription(result.rows[0]);
|
|
1736
|
+
}
|
|
1737
|
+
async function list(userId) {
|
|
1738
|
+
if (!pool2) return [];
|
|
1739
|
+
const customerId = await getStripeCustomerId(pool2, schema2, userId);
|
|
1740
|
+
if (!customerId) return [];
|
|
1741
|
+
const result = await pool2.query(
|
|
1742
|
+
`SELECT id, status, customer, current_period_start, current_period_end,
|
|
1743
|
+
cancel_at_period_end, items
|
|
1744
|
+
FROM ${schema2}.subscriptions
|
|
1745
|
+
WHERE customer = $1
|
|
1746
|
+
ORDER BY current_period_end DESC`,
|
|
1747
|
+
[customerId]
|
|
1748
|
+
);
|
|
1749
|
+
return result.rows.map(rowToSubscription);
|
|
1750
|
+
}
|
|
1751
|
+
return { isActive, get, list };
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// src/handlers/utils.ts
|
|
1755
|
+
function jsonResponse(data, status = 200) {
|
|
1756
|
+
return new Response(JSON.stringify(data), {
|
|
1757
|
+
status,
|
|
1758
|
+
headers: { "Content-Type": "application/json" }
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
function errorResponse(message, status = 500) {
|
|
1762
|
+
return jsonResponse({ error: message }, status);
|
|
1763
|
+
}
|
|
1764
|
+
function successResponse(request, data, redirectUrl) {
|
|
1765
|
+
const acceptHeader = request.headers.get("accept") || "";
|
|
1766
|
+
if (acceptHeader.includes("application/json")) {
|
|
1767
|
+
return jsonResponse({ ...data, redirectUrl });
|
|
1768
|
+
}
|
|
1769
|
+
return Response.redirect(redirectUrl, 303);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/handlers/checkout.ts
|
|
1773
|
+
async function hasPaymentMethod(stripe, customerId) {
|
|
1774
|
+
const customer = await stripe.customers.retrieve(customerId);
|
|
1775
|
+
if ("deleted" in customer && customer.deleted) return false;
|
|
1776
|
+
return !!customer.invoice_settings?.default_payment_method;
|
|
1777
|
+
}
|
|
1778
|
+
function resolvePriceId(body, billingConfig, mode) {
|
|
1779
|
+
if (body.priceId) {
|
|
1780
|
+
return body.priceId;
|
|
1781
|
+
}
|
|
1782
|
+
if (!body.interval) {
|
|
1783
|
+
throw new Error("interval is required when using planName or planId");
|
|
1784
|
+
}
|
|
1785
|
+
if (!billingConfig?.[mode]?.plans) {
|
|
1786
|
+
throw new Error(
|
|
1787
|
+
"billingConfig with plans is required when using planName or planId"
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
const plan = body.planName ? billingConfig[mode]?.plans?.find((p) => p.name === body.planName) : body.planId ? billingConfig[mode]?.plans?.find((p) => p.id === body.planId) : null;
|
|
1791
|
+
if (!plan) {
|
|
1792
|
+
const identifier = body.planName || body.planId;
|
|
1793
|
+
throw new Error(`Plan not found: ${identifier}`);
|
|
1794
|
+
}
|
|
1795
|
+
const price = plan.price.find((p) => p.interval === body.interval);
|
|
1796
|
+
if (!price) {
|
|
1797
|
+
throw new Error(
|
|
1798
|
+
`Price with interval "${body.interval}" not found for plan "${plan.name}"`
|
|
1799
|
+
);
|
|
1800
|
+
}
|
|
1801
|
+
if (!price.id) {
|
|
1802
|
+
throw new Error(
|
|
1803
|
+
`Price ID not set for plan "${plan.name}" with interval "${body.interval}". Run stripe-sync to sync price IDs.`
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
return price.id;
|
|
1807
|
+
}
|
|
1808
|
+
async function getPriceMode(stripe, priceId) {
|
|
1809
|
+
const price = await stripe.prices.retrieve(priceId);
|
|
1810
|
+
return price.type === "recurring" ? "subscription" : "payment";
|
|
1811
|
+
}
|
|
1812
|
+
function shouldDisableProration(billingConfig, mode, oldPriceId, newPriceId) {
|
|
1813
|
+
const oldPlan = findPlanByPriceId(billingConfig, mode, oldPriceId);
|
|
1814
|
+
const newPlan = findPlanByPriceId(billingConfig, mode, newPriceId);
|
|
1815
|
+
return planHasCredits(oldPlan) && planHasCredits(newPlan);
|
|
1816
|
+
}
|
|
1817
|
+
async function handleCheckout(request, ctx) {
|
|
1818
|
+
try {
|
|
1819
|
+
const body = await request.json().catch(() => ({}));
|
|
1820
|
+
if (!body.priceId && !body.planName && !body.planId) {
|
|
1821
|
+
return errorResponse(
|
|
1822
|
+
"Provide either priceId, planName+interval, or planId+interval",
|
|
1823
|
+
400
|
|
1824
|
+
);
|
|
1825
|
+
}
|
|
1826
|
+
const origin = request.headers.get("origin") || "";
|
|
1827
|
+
const successUrl = body.successUrl || ctx.defaultSuccessUrl || `${origin}/success?session_id={CHECKOUT_SESSION_ID}`;
|
|
1828
|
+
const cancelUrl = body.cancelUrl || ctx.defaultCancelUrl || `${origin}/`;
|
|
1829
|
+
const priceId = resolvePriceId(body, ctx.billingConfig, ctx.mode);
|
|
1830
|
+
const priceMode = await getPriceMode(ctx.stripe, priceId);
|
|
1831
|
+
const user = ctx.resolveUser ? await ctx.resolveUser(request) : null;
|
|
1832
|
+
if (!user) {
|
|
1833
|
+
return errorResponse(
|
|
1834
|
+
"Unauthorized. Configure resolveUser to extract authenticated user.",
|
|
1835
|
+
401
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
const orgId = ctx.resolveOrg ? await ctx.resolveOrg(request) : null;
|
|
1839
|
+
const customerId = await ctx.resolveStripeCustomerId({
|
|
1840
|
+
user: orgId ? { id: orgId } : user,
|
|
1841
|
+
createIfNotFound: true
|
|
1842
|
+
});
|
|
1843
|
+
if (customerId && priceMode === "subscription") {
|
|
1844
|
+
const currentSub = await getActiveSubscription(ctx.stripe, customerId);
|
|
1845
|
+
if (currentSub) {
|
|
1846
|
+
const currentPriceId = currentSub.items.data[0]?.price?.id;
|
|
1847
|
+
const currentAmount = currentSub.items.data[0]?.price?.unit_amount ?? 0;
|
|
1848
|
+
if (currentPriceId === priceId) {
|
|
1849
|
+
return jsonResponse({
|
|
1850
|
+
success: true,
|
|
1851
|
+
alreadySubscribed: true,
|
|
1852
|
+
message: "Already on this plan"
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
const targetPrice = await ctx.stripe.prices.retrieve(priceId);
|
|
1856
|
+
const targetAmount = targetPrice.unit_amount ?? 0;
|
|
1857
|
+
const customerHasPaymentMethod = await hasPaymentMethod(
|
|
1858
|
+
ctx.stripe,
|
|
1859
|
+
customerId
|
|
1860
|
+
);
|
|
1861
|
+
const isUpgrade = targetAmount > currentAmount;
|
|
1862
|
+
const isDowngrade = targetAmount < currentAmount;
|
|
1863
|
+
if (isDowngrade) {
|
|
1864
|
+
await ctx.stripe.subscriptions.update(currentSub.id, {
|
|
1865
|
+
items: [{ id: currentSub.items.data[0].id, price: priceId }],
|
|
1866
|
+
proration_behavior: "none",
|
|
1867
|
+
metadata: {
|
|
1868
|
+
pending_credit_downgrade: "true",
|
|
1869
|
+
downgrade_from_price: currentPriceId
|
|
1870
|
+
}
|
|
1871
|
+
});
|
|
1872
|
+
const periodEnd = currentSub.current_period_end;
|
|
1873
|
+
return successResponse(
|
|
1874
|
+
request,
|
|
1875
|
+
{
|
|
1876
|
+
success: true,
|
|
1877
|
+
scheduled: true,
|
|
1878
|
+
message: "Downgrade scheduled for end of current billing period",
|
|
1879
|
+
...periodEnd && {
|
|
1880
|
+
effectiveAt: new Date(periodEnd * 1e3).toISOString()
|
|
1881
|
+
},
|
|
1882
|
+
url: successUrl
|
|
1883
|
+
},
|
|
1884
|
+
successUrl
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
const disableProration = shouldDisableProration(
|
|
1888
|
+
ctx.billingConfig,
|
|
1889
|
+
ctx.mode,
|
|
1890
|
+
currentPriceId,
|
|
1891
|
+
priceId
|
|
1892
|
+
);
|
|
1893
|
+
if (customerHasPaymentMethod && isUpgrade) {
|
|
1894
|
+
try {
|
|
1895
|
+
await ctx.stripe.subscriptions.update(currentSub.id, {
|
|
1896
|
+
items: [{ id: currentSub.items.data[0].id, price: priceId }],
|
|
1897
|
+
proration_behavior: disableProration ? "none" : "create_prorations",
|
|
1898
|
+
...disableProration && { billing_cycle_anchor: "now" },
|
|
1899
|
+
metadata: {
|
|
1900
|
+
upgrade_from_price_id: currentPriceId,
|
|
1901
|
+
upgrade_from_price_amount: currentAmount.toString()
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
} catch (err) {
|
|
1905
|
+
console.error("Direct subscription update failed:", err);
|
|
1906
|
+
const portal = await ctx.stripe.billingPortal.sessions.create({
|
|
1907
|
+
customer: customerId,
|
|
1908
|
+
return_url: cancelUrl
|
|
1909
|
+
});
|
|
1910
|
+
return successResponse(
|
|
1911
|
+
request,
|
|
1912
|
+
{
|
|
1913
|
+
error: "Payment issue",
|
|
1914
|
+
portalUrl: portal.url
|
|
1915
|
+
},
|
|
1916
|
+
portal.url
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
return successResponse(
|
|
1920
|
+
request,
|
|
1921
|
+
{
|
|
1922
|
+
success: true,
|
|
1923
|
+
upgraded: true,
|
|
1924
|
+
url: successUrl
|
|
1925
|
+
},
|
|
1926
|
+
successUrl
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
const session2 = await ctx.stripe.checkout.sessions.create({
|
|
1930
|
+
customer: customerId,
|
|
1931
|
+
mode: "setup",
|
|
1932
|
+
currency: targetPrice.currency,
|
|
1933
|
+
success_url: successUrl,
|
|
1934
|
+
cancel_url: cancelUrl,
|
|
1935
|
+
metadata: {
|
|
1936
|
+
...body.metadata,
|
|
1937
|
+
upgrade_subscription_id: currentSub.id,
|
|
1938
|
+
upgrade_subscription_item_id: currentSub.items.data[0].id,
|
|
1939
|
+
upgrade_to_price_id: priceId,
|
|
1940
|
+
upgrade_from_price_id: currentPriceId,
|
|
1941
|
+
upgrade_from_price_amount: currentAmount.toString(),
|
|
1942
|
+
upgrade_disable_proration: disableProration ? "true" : "false"
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
if (!session2.url) {
|
|
1946
|
+
return errorResponse("Failed to create checkout session", 500);
|
|
1947
|
+
}
|
|
1948
|
+
return successResponse(request, { url: session2.url }, session2.url);
|
|
201
1949
|
}
|
|
202
|
-
|
|
203
|
-
|
|
1950
|
+
}
|
|
1951
|
+
const sessionParams = {
|
|
1952
|
+
line_items: [{ price: priceId, quantity: body.quantity ?? 1 }],
|
|
1953
|
+
mode: priceMode,
|
|
1954
|
+
success_url: successUrl,
|
|
1955
|
+
cancel_url: cancelUrl,
|
|
1956
|
+
automatic_tax: { enabled: ctx.automaticTax },
|
|
1957
|
+
payment_method_collection: "if_required"
|
|
1958
|
+
};
|
|
1959
|
+
if (customerId) {
|
|
1960
|
+
sessionParams.customer = customerId;
|
|
1961
|
+
}
|
|
1962
|
+
const sessionMetadata = { ...body.metadata };
|
|
1963
|
+
if (orgId) {
|
|
1964
|
+
sessionMetadata.org_id = orgId;
|
|
1965
|
+
}
|
|
1966
|
+
if (ctx.grantTo === "seat-users" && orgId) {
|
|
1967
|
+
sessionMetadata.first_seat_user_id = user.id;
|
|
1968
|
+
}
|
|
1969
|
+
if (Object.keys(sessionMetadata).length > 0) {
|
|
1970
|
+
sessionParams.metadata = sessionMetadata;
|
|
1971
|
+
if (priceMode === "subscription") {
|
|
1972
|
+
sessionParams.subscription_data = { metadata: sessionMetadata };
|
|
204
1973
|
}
|
|
205
|
-
return new Response(JSON.stringify({ received: true }), {
|
|
206
|
-
status: 200,
|
|
207
|
-
headers: { "Content-Type": "application/json" }
|
|
208
|
-
});
|
|
209
|
-
} catch (error) {
|
|
210
|
-
console.error("Stripe webhook error:", error);
|
|
211
|
-
const message = error instanceof Error ? error.message : "Internal server error";
|
|
212
|
-
return new Response(message, { status: 500 });
|
|
213
1974
|
}
|
|
1975
|
+
const session = await ctx.stripe.checkout.sessions.create(sessionParams);
|
|
1976
|
+
if (!session.url) {
|
|
1977
|
+
return errorResponse("Failed to create checkout session", 500);
|
|
1978
|
+
}
|
|
1979
|
+
return successResponse(request, { url: session.url }, session.url);
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
console.error("Checkout error:", err);
|
|
1982
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
1983
|
+
const status = err && typeof err === "object" && "statusCode" in err ? err.statusCode : 500;
|
|
1984
|
+
return errorResponse(message, status);
|
|
214
1985
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
// src/handlers/customer-portal.ts
|
|
1989
|
+
async function handleCustomerPortal(request, ctx) {
|
|
1990
|
+
try {
|
|
1991
|
+
const body = await request.json().catch(() => ({}));
|
|
1992
|
+
const user = ctx.resolveUser ? await ctx.resolveUser(request) : null;
|
|
1993
|
+
if (!user) {
|
|
1994
|
+
return errorResponse(
|
|
1995
|
+
"Unauthorized. Configure resolveUser to extract authenticated user.",
|
|
1996
|
+
401
|
|
1997
|
+
);
|
|
224
1998
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
1999
|
+
const orgId = ctx.resolveOrg ? await ctx.resolveOrg(request) : null;
|
|
2000
|
+
const customerId = await ctx.resolveStripeCustomerId({
|
|
2001
|
+
user: orgId ? { id: orgId } : user,
|
|
2002
|
+
createIfNotFound: false
|
|
2003
|
+
});
|
|
2004
|
+
if (!customerId) {
|
|
2005
|
+
return errorResponse("No billing account found for this user.", 404);
|
|
2006
|
+
}
|
|
2007
|
+
const origin = request.headers.get("origin") || "";
|
|
2008
|
+
const returnUrl = body.returnUrl || `${origin}/`;
|
|
2009
|
+
const session = await ctx.stripe.billingPortal.sessions.create({
|
|
2010
|
+
customer: customerId,
|
|
2011
|
+
return_url: returnUrl
|
|
2012
|
+
});
|
|
2013
|
+
return successResponse(request, { url: session.url }, session.url);
|
|
2014
|
+
} catch (err) {
|
|
2015
|
+
console.error("Customer portal error:", err);
|
|
2016
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
2017
|
+
const status = err && typeof err === "object" && "statusCode" in err ? err.statusCode : 500;
|
|
2018
|
+
return errorResponse(message, status);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// src/handlers/webhook.ts
|
|
2023
|
+
async function handleWebhook(request, ctx) {
|
|
2024
|
+
try {
|
|
2025
|
+
const body = await request.text();
|
|
2026
|
+
const url = new URL(request.url);
|
|
2027
|
+
const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
2028
|
+
const signature = request.headers.get("stripe-signature");
|
|
2029
|
+
let event;
|
|
2030
|
+
if (isLocalhost) {
|
|
2031
|
+
event = JSON.parse(body);
|
|
2032
|
+
} else {
|
|
2033
|
+
if (!signature) {
|
|
2034
|
+
return new Response("Missing stripe-signature header", { status: 400 });
|
|
2035
|
+
}
|
|
2036
|
+
try {
|
|
2037
|
+
event = ctx.stripe.webhooks.constructEvent(
|
|
2038
|
+
body,
|
|
2039
|
+
signature,
|
|
2040
|
+
ctx.stripeWebhookSecret
|
|
2041
|
+
);
|
|
2042
|
+
} catch (err) {
|
|
2043
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
231
2044
|
return new Response(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}),
|
|
235
|
-
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
2045
|
+
`Webhook signature verification failed: ${message}`,
|
|
2046
|
+
{ status: 400 }
|
|
236
2047
|
);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
if (ctx.sync) {
|
|
2051
|
+
const shouldSkipSync = (
|
|
2052
|
+
// Setup mode checkouts have no line items
|
|
2053
|
+
event.type === "checkout.session.completed" && event.data.object.mode === "setup" || // Upcoming invoices have null IDs
|
|
2054
|
+
event.type === "invoice.upcoming"
|
|
2055
|
+
);
|
|
2056
|
+
if (!shouldSkipSync) {
|
|
2057
|
+
try {
|
|
2058
|
+
await ctx.sync.processEvent(event);
|
|
2059
|
+
} catch (err) {
|
|
2060
|
+
console.error("Stripe sync error (non-fatal):", err);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
await handleEvent(event, ctx);
|
|
2065
|
+
return new Response(JSON.stringify({ received: true }), {
|
|
2066
|
+
status: 200,
|
|
2067
|
+
headers: { "Content-Type": "application/json" }
|
|
2068
|
+
});
|
|
2069
|
+
} catch (error) {
|
|
2070
|
+
console.error("Stripe webhook error:", error);
|
|
2071
|
+
const message = error instanceof Error ? error.message : "Internal server error";
|
|
2072
|
+
return new Response(message, { status: 500 });
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
async function handleDuplicateSubscriptions(ctx, subscription) {
|
|
2076
|
+
const customerId = getCustomerIdFromSubscription(subscription);
|
|
2077
|
+
const allSubs = await ctx.stripe.subscriptions.list({
|
|
2078
|
+
customer: customerId,
|
|
2079
|
+
status: "active",
|
|
2080
|
+
limit: 10
|
|
2081
|
+
});
|
|
2082
|
+
const activeSubs = allSubs.data.filter(
|
|
2083
|
+
(s) => s.status === "active" || s.status === "trialing"
|
|
2084
|
+
);
|
|
2085
|
+
if (activeSubs.length <= 1) return false;
|
|
2086
|
+
const sorted = activeSubs.sort((a, b) => {
|
|
2087
|
+
const aAmount = a.items.data[0]?.price?.unit_amount ?? 0;
|
|
2088
|
+
const bAmount = b.items.data[0]?.price?.unit_amount ?? 0;
|
|
2089
|
+
return bAmount - aAmount;
|
|
2090
|
+
});
|
|
2091
|
+
const toKeep = sorted[0];
|
|
2092
|
+
for (const sub of sorted.slice(1)) {
|
|
2093
|
+
try {
|
|
2094
|
+
await ctx.stripe.subscriptions.update(sub.id, {
|
|
2095
|
+
metadata: { cancelled_as_duplicate: "true" }
|
|
2096
|
+
});
|
|
2097
|
+
await ctx.stripe.subscriptions.cancel(sub.id);
|
|
2098
|
+
} catch (err) {
|
|
2099
|
+
console.error(`Failed to cancel duplicate subscription ${sub.id}:`, err);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
return subscription.id !== toKeep.id;
|
|
2103
|
+
}
|
|
2104
|
+
async function handleSetupModeUpgrade(ctx, session) {
|
|
2105
|
+
const { metadata } = session;
|
|
2106
|
+
if (!metadata) return;
|
|
2107
|
+
const subId = metadata.upgrade_subscription_id;
|
|
2108
|
+
const itemId = metadata.upgrade_subscription_item_id;
|
|
2109
|
+
const newPriceId = metadata.upgrade_to_price_id;
|
|
2110
|
+
const disableProration = metadata.upgrade_disable_proration === "true";
|
|
2111
|
+
if (!subId || !itemId || !newPriceId) {
|
|
2112
|
+
console.error("Missing upgrade metadata in setup session:", metadata);
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
const paymentMethodId = await getPaymentMethodFromSetupIntent(ctx, session);
|
|
2116
|
+
const updateParams = {
|
|
2117
|
+
items: [{ id: itemId, price: newPriceId }],
|
|
2118
|
+
proration_behavior: disableProration ? "none" : "create_prorations",
|
|
2119
|
+
...disableProration && { billing_cycle_anchor: "now" },
|
|
2120
|
+
metadata: {
|
|
2121
|
+
upgrade_from_price_id: metadata.upgrade_from_price_id || "",
|
|
2122
|
+
upgrade_from_price_amount: metadata.upgrade_from_price_amount || ""
|
|
237
2123
|
}
|
|
238
2124
|
};
|
|
2125
|
+
if (paymentMethodId) {
|
|
2126
|
+
updateParams.default_payment_method = paymentMethodId;
|
|
2127
|
+
}
|
|
2128
|
+
await ctx.stripe.subscriptions.update(subId, updateParams);
|
|
2129
|
+
if (paymentMethodId && session.customer) {
|
|
2130
|
+
const customerId = typeof session.customer === "string" ? session.customer : session.customer.id;
|
|
2131
|
+
await ctx.stripe.customers.update(customerId, {
|
|
2132
|
+
invoice_settings: { default_payment_method: paymentMethodId }
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
async function getPaymentMethodFromSetupIntent(ctx, session) {
|
|
2137
|
+
const setupIntentId = typeof session.setup_intent === "string" ? session.setup_intent : session.setup_intent?.id;
|
|
2138
|
+
if (!setupIntentId) return void 0;
|
|
2139
|
+
const setupIntent = await ctx.stripe.setupIntents.retrieve(setupIntentId);
|
|
2140
|
+
return typeof setupIntent.payment_method === "string" ? setupIntent.payment_method : setupIntent.payment_method?.id;
|
|
2141
|
+
}
|
|
2142
|
+
async function saveDefaultPaymentMethod(ctx, session) {
|
|
2143
|
+
try {
|
|
2144
|
+
const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
|
|
2145
|
+
if (!subscriptionId) return;
|
|
2146
|
+
const subscription = await ctx.stripe.subscriptions.retrieve(
|
|
2147
|
+
subscriptionId
|
|
2148
|
+
);
|
|
2149
|
+
const paymentMethod = subscription.default_payment_method;
|
|
2150
|
+
const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id;
|
|
2151
|
+
if (paymentMethod && customerId) {
|
|
2152
|
+
const paymentMethodId = typeof paymentMethod === "string" ? paymentMethod : paymentMethod.id;
|
|
2153
|
+
await ctx.stripe.customers.update(customerId, {
|
|
2154
|
+
invoice_settings: { default_payment_method: paymentMethodId }
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
} catch (err) {
|
|
2158
|
+
console.error("Failed to set default payment method:", err);
|
|
2159
|
+
}
|
|
239
2160
|
}
|
|
240
|
-
async function
|
|
241
|
-
const { onSubscriptionCreated, onSubscriptionCancelled } = callbacks;
|
|
2161
|
+
async function handleEvent(event, ctx) {
|
|
242
2162
|
switch (event.type) {
|
|
243
|
-
case "customer.subscription.created":
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
2163
|
+
case "customer.subscription.created": {
|
|
2164
|
+
const subscription = event.data.object;
|
|
2165
|
+
const shouldSkipCredits = await handleDuplicateSubscriptions(
|
|
2166
|
+
ctx,
|
|
2167
|
+
subscription
|
|
2168
|
+
);
|
|
2169
|
+
if (shouldSkipCredits) break;
|
|
2170
|
+
await ctx.creditLifecycle.onSubscriptionCreated(subscription);
|
|
2171
|
+
await ctx.callbacks?.onSubscriptionCreated?.(subscription);
|
|
247
2172
|
break;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
2173
|
+
}
|
|
2174
|
+
case "customer.subscription.deleted": {
|
|
2175
|
+
const subscription = event.data.object;
|
|
2176
|
+
if (subscription.metadata?.cancelled_as_duplicate) {
|
|
2177
|
+
break;
|
|
251
2178
|
}
|
|
2179
|
+
await ctx.creditLifecycle.onSubscriptionCancelled(subscription);
|
|
2180
|
+
await ctx.callbacks?.onSubscriptionCancelled?.(subscription);
|
|
252
2181
|
break;
|
|
253
|
-
|
|
2182
|
+
}
|
|
2183
|
+
case "customer.subscription.updated": {
|
|
254
2184
|
const subscription = event.data.object;
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
2185
|
+
const prev = event.data.previous_attributes;
|
|
2186
|
+
const statusChangedToCanceled = subscription.status === "canceled" && prev?.status && prev.status !== "canceled";
|
|
2187
|
+
if (statusChangedToCanceled) {
|
|
2188
|
+
await ctx.creditLifecycle.onSubscriptionCancelled(subscription);
|
|
2189
|
+
await ctx.callbacks?.onSubscriptionCancelled?.(subscription);
|
|
2190
|
+
break;
|
|
2191
|
+
}
|
|
2192
|
+
if (prev?.items) {
|
|
2193
|
+
const oldPriceId = prev.items.data?.[0]?.price?.id;
|
|
2194
|
+
const newPriceId = subscription.items.data[0]?.price?.id;
|
|
2195
|
+
if (oldPriceId && newPriceId && oldPriceId !== newPriceId) {
|
|
2196
|
+
await ctx.creditLifecycle.onSubscriptionPlanChanged(
|
|
2197
|
+
subscription,
|
|
2198
|
+
oldPriceId
|
|
2199
|
+
);
|
|
2200
|
+
await ctx.callbacks?.onSubscriptionPlanChanged?.(
|
|
2201
|
+
subscription,
|
|
2202
|
+
oldPriceId
|
|
2203
|
+
);
|
|
2204
|
+
}
|
|
258
2205
|
}
|
|
259
2206
|
break;
|
|
2207
|
+
}
|
|
2208
|
+
case "invoice.paid": {
|
|
2209
|
+
const invoice = event.data.object;
|
|
2210
|
+
const subscriptionId = invoice.subscription ?? invoice.parent?.subscription_details?.subscription;
|
|
2211
|
+
if (invoice.billing_reason === "subscription_cycle" && subscriptionId) {
|
|
2212
|
+
const subId = typeof subscriptionId === "string" ? subscriptionId : subscriptionId.id;
|
|
2213
|
+
const subscription = await ctx.stripe.subscriptions.retrieve(subId);
|
|
2214
|
+
const pendingCreditDowngrade = subscription.metadata?.pending_credit_downgrade;
|
|
2215
|
+
if (pendingCreditDowngrade === "true") {
|
|
2216
|
+
const currentPriceId = subscription.items.data[0]?.price?.id;
|
|
2217
|
+
await ctx.stripe.subscriptions.update(subscription.id, {
|
|
2218
|
+
metadata: {
|
|
2219
|
+
pending_credit_downgrade: "",
|
|
2220
|
+
downgrade_from_price: ""
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
if (currentPriceId) {
|
|
2224
|
+
await ctx.creditLifecycle.onDowngradeApplied(
|
|
2225
|
+
subscription,
|
|
2226
|
+
currentPriceId
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
break;
|
|
2230
|
+
}
|
|
2231
|
+
await ctx.creditLifecycle.onSubscriptionRenewed(
|
|
2232
|
+
subscription,
|
|
2233
|
+
invoice.id
|
|
2234
|
+
);
|
|
2235
|
+
await ctx.callbacks?.onSubscriptionRenewed?.(subscription);
|
|
2236
|
+
}
|
|
2237
|
+
break;
|
|
2238
|
+
}
|
|
2239
|
+
case "checkout.session.completed": {
|
|
2240
|
+
const session = event.data.object;
|
|
2241
|
+
if (session.metadata?.top_up_credit_type) {
|
|
2242
|
+
await ctx.topUpHandler.handleTopUpCheckoutCompleted(session);
|
|
2243
|
+
}
|
|
2244
|
+
if (session.mode === "setup" && session.metadata?.upgrade_subscription_id) {
|
|
2245
|
+
await handleSetupModeUpgrade(ctx, session);
|
|
2246
|
+
break;
|
|
2247
|
+
}
|
|
2248
|
+
if (session.mode === "subscription" && session.subscription) {
|
|
2249
|
+
await saveDefaultPaymentMethod(ctx, session);
|
|
2250
|
+
}
|
|
2251
|
+
break;
|
|
2252
|
+
}
|
|
2253
|
+
case "payment_intent.succeeded": {
|
|
2254
|
+
const paymentIntent = event.data.object;
|
|
2255
|
+
await ctx.topUpHandler.handlePaymentIntentSucceeded(paymentIntent);
|
|
2256
|
+
break;
|
|
2257
|
+
}
|
|
260
2258
|
}
|
|
261
2259
|
}
|
|
2260
|
+
|
|
2261
|
+
// src/Billing.ts
|
|
2262
|
+
var Billing = class {
|
|
2263
|
+
constructor(config = {}) {
|
|
2264
|
+
this.resolveStripeCustomerId = async (options) => {
|
|
2265
|
+
const { user, createIfNotFound } = options;
|
|
2266
|
+
const { id: userId, name, email } = user;
|
|
2267
|
+
if (this.pool) {
|
|
2268
|
+
const result = await this.pool.query(
|
|
2269
|
+
`SELECT stripe_customer_id FROM ${this.schema}.user_stripe_customer_map WHERE user_id = $1`,
|
|
2270
|
+
[userId]
|
|
2271
|
+
);
|
|
2272
|
+
if (result.rows.length > 0) {
|
|
2273
|
+
return result.rows[0].stripe_customer_id;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
if (this.mapUserIdToStripeCustomerId) {
|
|
2277
|
+
const customerId = await this.mapUserIdToStripeCustomerId(userId);
|
|
2278
|
+
if (customerId) {
|
|
2279
|
+
return customerId;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
if (createIfNotFound) {
|
|
2283
|
+
const customerParams = {
|
|
2284
|
+
metadata: { user_id: userId }
|
|
2285
|
+
};
|
|
2286
|
+
if (name) customerParams.name = name;
|
|
2287
|
+
if (email) customerParams.email = email;
|
|
2288
|
+
const customer = await this.stripe.customers.create(customerParams);
|
|
2289
|
+
if (this.pool) {
|
|
2290
|
+
await this.pool.query(
|
|
2291
|
+
`INSERT INTO ${this.schema}.user_stripe_customer_map (user_id, stripe_customer_id)
|
|
2292
|
+
VALUES ($1, $2)
|
|
2293
|
+
ON CONFLICT (user_id) DO UPDATE SET stripe_customer_id = $2, updated_at = now()`,
|
|
2294
|
+
[userId, customer.id]
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
return customer.id;
|
|
2298
|
+
}
|
|
2299
|
+
return null;
|
|
2300
|
+
};
|
|
2301
|
+
const {
|
|
2302
|
+
stripeSecretKey = process.env.STRIPE_SECRET_KEY,
|
|
2303
|
+
stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET,
|
|
2304
|
+
databaseUrl = process.env.DATABASE_URL,
|
|
2305
|
+
schema: schema2 = "stripe",
|
|
2306
|
+
billingConfig,
|
|
2307
|
+
successUrl,
|
|
2308
|
+
cancelUrl,
|
|
2309
|
+
credits: creditsConfig,
|
|
2310
|
+
callbacks,
|
|
2311
|
+
mapUserIdToStripeCustomerId
|
|
2312
|
+
} = config;
|
|
2313
|
+
this.stripe = new import_stripe.default(stripeSecretKey);
|
|
2314
|
+
this.pool = databaseUrl ? new import_pg.Pool({ connectionString: databaseUrl }) : null;
|
|
2315
|
+
this.schema = schema2;
|
|
2316
|
+
this.billingConfig = billingConfig;
|
|
2317
|
+
this.mode = getMode(stripeSecretKey);
|
|
2318
|
+
this.grantTo = creditsConfig?.grantTo ?? "subscriber";
|
|
2319
|
+
this.defaultSuccessUrl = successUrl;
|
|
2320
|
+
this.defaultCancelUrl = cancelUrl;
|
|
2321
|
+
this.stripeWebhookSecret = stripeWebhookSecret;
|
|
2322
|
+
this.mapUserIdToStripeCustomerId = mapUserIdToStripeCustomerId;
|
|
2323
|
+
this.creditsConfig = creditsConfig;
|
|
2324
|
+
this.callbacks = callbacks;
|
|
2325
|
+
initCredits(this.pool, this.schema);
|
|
2326
|
+
this.sync = databaseUrl ? new import_stripe_sync_engine.StripeSync({
|
|
2327
|
+
poolConfig: { connectionString: databaseUrl },
|
|
2328
|
+
schema: this.schema,
|
|
2329
|
+
stripeSecretKey,
|
|
2330
|
+
stripeWebhookSecret
|
|
2331
|
+
}) : null;
|
|
2332
|
+
this.subscriptions = createSubscriptionsApi({
|
|
2333
|
+
pool: this.pool,
|
|
2334
|
+
schema: this.schema,
|
|
2335
|
+
billingConfig: this.billingConfig,
|
|
2336
|
+
mode: this.mode
|
|
2337
|
+
});
|
|
2338
|
+
const mergedCallbacks = {
|
|
2339
|
+
onTopUpCompleted: callbacks?.onTopUpCompleted ?? creditsConfig?.onTopUpCompleted,
|
|
2340
|
+
onAutoTopUpFailed: callbacks?.onAutoTopUpFailed ?? creditsConfig?.onAutoTopUpFailed,
|
|
2341
|
+
onCreditsLow: callbacks?.onCreditsLow ?? creditsConfig?.onCreditsLow
|
|
2342
|
+
};
|
|
2343
|
+
this.credits = this.createCreditsApi(mergedCallbacks);
|
|
2344
|
+
this.seats = createSeatsApi({
|
|
2345
|
+
stripe: this.stripe,
|
|
2346
|
+
pool: this.pool,
|
|
2347
|
+
schema: this.schema,
|
|
2348
|
+
billingConfig: this.billingConfig,
|
|
2349
|
+
mode: this.mode,
|
|
2350
|
+
grantTo: this.grantTo,
|
|
2351
|
+
callbacks: {
|
|
2352
|
+
onCreditsGranted: callbacks?.onCreditsGranted ?? creditsConfig?.onCreditsGranted,
|
|
2353
|
+
onCreditsRevoked: callbacks?.onCreditsRevoked ?? creditsConfig?.onCreditsRevoked
|
|
2354
|
+
}
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
createCreditsApi(callbacks) {
|
|
2358
|
+
const topUpHandler = createTopUpHandler({
|
|
2359
|
+
stripe: this.stripe,
|
|
2360
|
+
pool: this.pool,
|
|
2361
|
+
schema: this.schema,
|
|
2362
|
+
billingConfig: this.billingConfig,
|
|
2363
|
+
mode: this.mode,
|
|
2364
|
+
successUrl: this.defaultSuccessUrl || "",
|
|
2365
|
+
cancelUrl: this.defaultCancelUrl || "",
|
|
2366
|
+
onAutoTopUpFailed: callbacks?.onAutoTopUpFailed,
|
|
2367
|
+
onTopUpCompleted: callbacks?.onTopUpCompleted,
|
|
2368
|
+
onCreditsLow: callbacks?.onCreditsLow
|
|
2369
|
+
});
|
|
2370
|
+
const consumeCredits = async (params) => {
|
|
2371
|
+
const result = await credits.consume(params);
|
|
2372
|
+
if (result.success) {
|
|
2373
|
+
topUpHandler.triggerAutoTopUpIfNeeded({
|
|
2374
|
+
userId: params.userId,
|
|
2375
|
+
creditType: params.creditType,
|
|
2376
|
+
currentBalance: result.balance
|
|
2377
|
+
}).catch((err) => {
|
|
2378
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
2379
|
+
console.error("Auto top-up error:", err);
|
|
2380
|
+
callbacks?.onAutoTopUpFailed?.({
|
|
2381
|
+
userId: params.userId,
|
|
2382
|
+
creditType: params.creditType,
|
|
2383
|
+
reason: "unexpected_error",
|
|
2384
|
+
error: message
|
|
2385
|
+
});
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
return result;
|
|
2389
|
+
};
|
|
2390
|
+
return {
|
|
2391
|
+
consume: consumeCredits,
|
|
2392
|
+
grant: credits.grant,
|
|
2393
|
+
revoke: credits.revoke,
|
|
2394
|
+
revokeAll: credits.revokeAll,
|
|
2395
|
+
setBalance: credits.setBalance,
|
|
2396
|
+
getBalance: credits.getBalance,
|
|
2397
|
+
getAllBalances: credits.getAllBalances,
|
|
2398
|
+
hasCredits: credits.hasCredits,
|
|
2399
|
+
getHistory: credits.getHistory,
|
|
2400
|
+
topUp: topUpHandler.topUp,
|
|
2401
|
+
hasPaymentMethod: topUpHandler.hasPaymentMethod
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
createHandler(handlerConfig = {}) {
|
|
2405
|
+
const {
|
|
2406
|
+
resolveUser,
|
|
2407
|
+
resolveOrg,
|
|
2408
|
+
callbacks: handlerCallbacks,
|
|
2409
|
+
automaticTax = false
|
|
2410
|
+
} = handlerConfig;
|
|
2411
|
+
const callbacks = { ...this.callbacks, ...handlerCallbacks };
|
|
2412
|
+
const creditLifecycle = createCreditLifecycle({
|
|
2413
|
+
pool: this.pool,
|
|
2414
|
+
schema: this.schema,
|
|
2415
|
+
billingConfig: this.billingConfig,
|
|
2416
|
+
mode: this.mode,
|
|
2417
|
+
grantTo: this.grantTo,
|
|
2418
|
+
callbacks
|
|
2419
|
+
});
|
|
2420
|
+
const topUpHandler = createTopUpHandler({
|
|
2421
|
+
stripe: this.stripe,
|
|
2422
|
+
pool: this.pool,
|
|
2423
|
+
schema: this.schema,
|
|
2424
|
+
billingConfig: this.billingConfig,
|
|
2425
|
+
mode: this.mode,
|
|
2426
|
+
successUrl: this.defaultSuccessUrl || "",
|
|
2427
|
+
cancelUrl: this.defaultCancelUrl || "",
|
|
2428
|
+
onCreditsGranted: callbacks?.onCreditsGranted,
|
|
2429
|
+
onTopUpCompleted: callbacks?.onTopUpCompleted,
|
|
2430
|
+
onAutoTopUpFailed: callbacks?.onAutoTopUpFailed,
|
|
2431
|
+
onCreditsLow: callbacks?.onCreditsLow
|
|
2432
|
+
});
|
|
2433
|
+
const routeContext = {
|
|
2434
|
+
stripe: this.stripe,
|
|
2435
|
+
pool: this.pool,
|
|
2436
|
+
schema: this.schema,
|
|
2437
|
+
billingConfig: this.billingConfig,
|
|
2438
|
+
mode: this.mode,
|
|
2439
|
+
grantTo: this.grantTo,
|
|
2440
|
+
defaultSuccessUrl: this.defaultSuccessUrl,
|
|
2441
|
+
defaultCancelUrl: this.defaultCancelUrl,
|
|
2442
|
+
automaticTax,
|
|
2443
|
+
resolveUser,
|
|
2444
|
+
resolveOrg,
|
|
2445
|
+
resolveStripeCustomerId: this.resolveStripeCustomerId
|
|
2446
|
+
};
|
|
2447
|
+
const webhookContext = {
|
|
2448
|
+
stripe: this.stripe,
|
|
2449
|
+
stripeWebhookSecret: this.stripeWebhookSecret,
|
|
2450
|
+
sync: this.sync,
|
|
2451
|
+
creditLifecycle,
|
|
2452
|
+
topUpHandler,
|
|
2453
|
+
callbacks
|
|
2454
|
+
};
|
|
2455
|
+
return async (request) => {
|
|
2456
|
+
const url = new URL(request.url);
|
|
2457
|
+
const action = url.pathname.split("/").filter(Boolean).pop();
|
|
2458
|
+
if (request.method !== "POST") {
|
|
2459
|
+
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
2460
|
+
status: 405,
|
|
2461
|
+
headers: { "Content-Type": "application/json" }
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
switch (action) {
|
|
2465
|
+
case "checkout":
|
|
2466
|
+
return handleCheckout(request, routeContext);
|
|
2467
|
+
case "webhook":
|
|
2468
|
+
return handleWebhook(request, webhookContext);
|
|
2469
|
+
case "customer_portal":
|
|
2470
|
+
return handleCustomerPortal(request, routeContext);
|
|
2471
|
+
default:
|
|
2472
|
+
return new Response(
|
|
2473
|
+
JSON.stringify({
|
|
2474
|
+
error: `Unknown action: ${action}. Supported: checkout, webhook, customer_portal`
|
|
2475
|
+
}),
|
|
2476
|
+
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
};
|
|
2482
|
+
function createHandler(config) {
|
|
2483
|
+
return new Billing(config).createHandler(config);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// src/fasterStripe.ts
|
|
2487
|
+
var import_stripe2 = __toESM(require("stripe"));
|
|
2488
|
+
var import_pg2 = require("pg");
|
|
2489
|
+
function buildCreatedFilter(created, params, paramIndex) {
|
|
2490
|
+
if (created === void 0) {
|
|
2491
|
+
return { sql: "", params: [], nextIndex: paramIndex };
|
|
2492
|
+
}
|
|
2493
|
+
const conditions = [];
|
|
2494
|
+
const newParams = [];
|
|
2495
|
+
let idx = paramIndex;
|
|
2496
|
+
if (typeof created === "number") {
|
|
2497
|
+
conditions.push(`created = $${idx}`);
|
|
2498
|
+
newParams.push(created);
|
|
2499
|
+
idx++;
|
|
2500
|
+
} else {
|
|
2501
|
+
if (created.gt !== void 0) {
|
|
2502
|
+
conditions.push(`created > $${idx}`);
|
|
2503
|
+
newParams.push(created.gt);
|
|
2504
|
+
idx++;
|
|
2505
|
+
}
|
|
2506
|
+
if (created.gte !== void 0) {
|
|
2507
|
+
conditions.push(`created >= $${idx}`);
|
|
2508
|
+
newParams.push(created.gte);
|
|
2509
|
+
idx++;
|
|
2510
|
+
}
|
|
2511
|
+
if (created.lt !== void 0) {
|
|
2512
|
+
conditions.push(`created < $${idx}`);
|
|
2513
|
+
newParams.push(created.lt);
|
|
2514
|
+
idx++;
|
|
2515
|
+
}
|
|
2516
|
+
if (created.lte !== void 0) {
|
|
2517
|
+
conditions.push(`created <= $${idx}`);
|
|
2518
|
+
newParams.push(created.lte);
|
|
2519
|
+
idx++;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
return {
|
|
2523
|
+
sql: conditions.length > 0 ? conditions.join(" AND ") : "",
|
|
2524
|
+
params: newParams,
|
|
2525
|
+
nextIndex: idx
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
function buildPaginationFilter(startingAfter, endingBefore, params, paramIndex) {
|
|
2529
|
+
let idx = paramIndex;
|
|
2530
|
+
const newParams = [];
|
|
2531
|
+
let sql = "";
|
|
2532
|
+
let orderDesc = true;
|
|
2533
|
+
if (startingAfter) {
|
|
2534
|
+
sql = `id < $${idx}`;
|
|
2535
|
+
newParams.push(startingAfter);
|
|
2536
|
+
idx++;
|
|
2537
|
+
orderDesc = true;
|
|
2538
|
+
} else if (endingBefore) {
|
|
2539
|
+
sql = `id > $${idx}`;
|
|
2540
|
+
newParams.push(endingBefore);
|
|
2541
|
+
idx++;
|
|
2542
|
+
orderDesc = false;
|
|
2543
|
+
}
|
|
2544
|
+
return { sql, params: newParams, nextIndex: idx, orderDesc };
|
|
2545
|
+
}
|
|
2546
|
+
function createResourceProxy(pool2, schema2, stripeResource, config) {
|
|
2547
|
+
const { tableName, filterMappings = {}, defaultFilters = {} } = config;
|
|
2548
|
+
async function listFromDb(params) {
|
|
2549
|
+
if (!pool2) return null;
|
|
2550
|
+
try {
|
|
2551
|
+
const limit = Math.min(params?.limit ?? 10, 100);
|
|
2552
|
+
const conditions = [];
|
|
2553
|
+
const queryParams = [];
|
|
2554
|
+
let paramIdx = 1;
|
|
2555
|
+
for (const [column, value] of Object.entries(defaultFilters)) {
|
|
2556
|
+
conditions.push(`${column} = $${paramIdx}`);
|
|
2557
|
+
queryParams.push(value);
|
|
2558
|
+
paramIdx++;
|
|
2559
|
+
}
|
|
2560
|
+
const createdResult = buildCreatedFilter(
|
|
2561
|
+
params?.created,
|
|
2562
|
+
queryParams,
|
|
2563
|
+
paramIdx
|
|
2564
|
+
);
|
|
2565
|
+
if (createdResult.sql) {
|
|
2566
|
+
conditions.push(createdResult.sql);
|
|
2567
|
+
queryParams.push(...createdResult.params);
|
|
2568
|
+
paramIdx = createdResult.nextIndex;
|
|
2569
|
+
}
|
|
2570
|
+
const paginationResult = buildPaginationFilter(
|
|
2571
|
+
params?.starting_after,
|
|
2572
|
+
params?.ending_before,
|
|
2573
|
+
queryParams,
|
|
2574
|
+
paramIdx
|
|
2575
|
+
);
|
|
2576
|
+
if (paginationResult.sql) {
|
|
2577
|
+
conditions.push(paginationResult.sql);
|
|
2578
|
+
queryParams.push(...paginationResult.params);
|
|
2579
|
+
paramIdx = paginationResult.nextIndex;
|
|
2580
|
+
}
|
|
2581
|
+
if (params) {
|
|
2582
|
+
for (const [stripeParam, dbColumn] of Object.entries(filterMappings)) {
|
|
2583
|
+
const value = params[stripeParam];
|
|
2584
|
+
if (value !== void 0) {
|
|
2585
|
+
conditions.push(`${dbColumn} = $${paramIdx}`);
|
|
2586
|
+
queryParams.push(value);
|
|
2587
|
+
paramIdx++;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
2592
|
+
const orderDirection = paginationResult.orderDesc ? "DESC" : "ASC";
|
|
2593
|
+
const query = `
|
|
2594
|
+
SELECT * FROM ${schema2}.${tableName}
|
|
2595
|
+
${whereClause}
|
|
2596
|
+
ORDER BY id ${orderDirection}
|
|
2597
|
+
LIMIT $${paramIdx}
|
|
2598
|
+
`;
|
|
2599
|
+
queryParams.push(limit + 1);
|
|
2600
|
+
const result = await pool2.query(query, queryParams);
|
|
2601
|
+
const hasMore = result.rows.length > limit;
|
|
2602
|
+
const data = hasMore ? result.rows.slice(0, limit) : result.rows;
|
|
2603
|
+
if (params?.ending_before) {
|
|
2604
|
+
data.reverse();
|
|
2605
|
+
}
|
|
2606
|
+
return {
|
|
2607
|
+
object: "list",
|
|
2608
|
+
data,
|
|
2609
|
+
has_more: hasMore,
|
|
2610
|
+
url: `/v1/${tableName}`
|
|
2611
|
+
};
|
|
2612
|
+
} catch (error) {
|
|
2613
|
+
console.warn(
|
|
2614
|
+
`FasterStripe: DB query failed for ${tableName}.list, falling back to Stripe API:`,
|
|
2615
|
+
error
|
|
2616
|
+
);
|
|
2617
|
+
return null;
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
async function retrieveFromDb(id) {
|
|
2621
|
+
if (!pool2) return null;
|
|
2622
|
+
try {
|
|
2623
|
+
const query = `SELECT * FROM ${schema2}.${tableName} WHERE id = $1`;
|
|
2624
|
+
const result = await pool2.query(query, [id]);
|
|
2625
|
+
if (result.rows.length === 0) {
|
|
2626
|
+
return null;
|
|
2627
|
+
}
|
|
2628
|
+
return result.rows[0];
|
|
2629
|
+
} catch (error) {
|
|
2630
|
+
console.warn(
|
|
2631
|
+
`FasterStripe: DB query failed for ${tableName}.retrieve, falling back to Stripe API:`,
|
|
2632
|
+
error
|
|
2633
|
+
);
|
|
2634
|
+
return null;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
const proxy = {
|
|
2638
|
+
async list(params) {
|
|
2639
|
+
const dbResult = await listFromDb(params);
|
|
2640
|
+
if (dbResult) {
|
|
2641
|
+
return dbResult;
|
|
2642
|
+
}
|
|
2643
|
+
return stripeResource.list(params);
|
|
2644
|
+
},
|
|
2645
|
+
async retrieve(id, params) {
|
|
2646
|
+
const dbResult = await retrieveFromDb(id);
|
|
2647
|
+
if (dbResult) {
|
|
2648
|
+
return Object.assign(dbResult, {
|
|
2649
|
+
lastResponse: {
|
|
2650
|
+
headers: {},
|
|
2651
|
+
requestId: "db-cache",
|
|
2652
|
+
statusCode: 200
|
|
2653
|
+
}
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
return stripeResource.retrieve(id, params);
|
|
2657
|
+
}
|
|
2658
|
+
};
|
|
2659
|
+
if (stripeResource.create) {
|
|
2660
|
+
proxy.create = stripeResource.create.bind(stripeResource);
|
|
2661
|
+
}
|
|
2662
|
+
if (stripeResource.update) {
|
|
2663
|
+
proxy.update = stripeResource.update.bind(stripeResource);
|
|
2664
|
+
}
|
|
2665
|
+
if (stripeResource.del) {
|
|
2666
|
+
proxy.del = stripeResource.del.bind(stripeResource);
|
|
2667
|
+
}
|
|
2668
|
+
return proxy;
|
|
2669
|
+
}
|
|
2670
|
+
var FasterStripe = class {
|
|
2671
|
+
/**
|
|
2672
|
+
* Create a new FasterStripe instance.
|
|
2673
|
+
*
|
|
2674
|
+
* @param apiKey - Stripe secret key. If not provided, reads from STRIPE_SECRET_KEY env var.
|
|
2675
|
+
* @param config - Optional configuration (extends Stripe.StripeConfig with databaseUrl and schema)
|
|
2676
|
+
*
|
|
2677
|
+
* @example
|
|
2678
|
+
* // Exact same as official Stripe SDK
|
|
2679
|
+
* const stripe = new Stripe('sk_test_...');
|
|
2680
|
+
* const stripe = new Stripe('sk_test_...', { apiVersion: '2023-10-16' });
|
|
2681
|
+
*
|
|
2682
|
+
* // Bonus: auto-read from env
|
|
2683
|
+
* const stripe = new Stripe(); // reads STRIPE_SECRET_KEY
|
|
2684
|
+
*/
|
|
2685
|
+
constructor(apiKey, config) {
|
|
2686
|
+
this.pool = null;
|
|
2687
|
+
const stripeSecretKey = apiKey ?? process.env.STRIPE_SECRET_KEY;
|
|
2688
|
+
if (!stripeSecretKey) {
|
|
2689
|
+
throw new Error(
|
|
2690
|
+
"Stripe secret key is required. Pass it as first argument or set STRIPE_SECRET_KEY env var."
|
|
2691
|
+
);
|
|
2692
|
+
}
|
|
2693
|
+
const { databaseUrl, schema: schema2, ...stripeConfig } = config ?? {};
|
|
2694
|
+
this.stripe = new import_stripe2.default(stripeSecretKey, stripeConfig);
|
|
2695
|
+
this.schema = schema2 ?? "stripe";
|
|
2696
|
+
const dbUrl = databaseUrl ?? process.env.DATABASE_URL;
|
|
2697
|
+
if (dbUrl) {
|
|
2698
|
+
try {
|
|
2699
|
+
this.pool = typeof dbUrl === "string" ? new import_pg2.Pool({ connectionString: dbUrl }) : new import_pg2.Pool(dbUrl);
|
|
2700
|
+
} catch (error) {
|
|
2701
|
+
console.warn(
|
|
2702
|
+
"FasterStripe: Failed to initialize database pool, falling back to Stripe API only:",
|
|
2703
|
+
error
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
this.products = createResourceProxy(this.pool, this.schema, this.stripe.products, {
|
|
2708
|
+
tableName: "products",
|
|
2709
|
+
filterMappings: {
|
|
2710
|
+
active: "active"
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
this.prices = createResourceProxy(this.pool, this.schema, this.stripe.prices, {
|
|
2714
|
+
tableName: "prices",
|
|
2715
|
+
filterMappings: {
|
|
2716
|
+
active: "active",
|
|
2717
|
+
product: "product",
|
|
2718
|
+
currency: "currency",
|
|
2719
|
+
type: "type"
|
|
2720
|
+
}
|
|
2721
|
+
});
|
|
2722
|
+
this.customers = createResourceProxy(this.pool, this.schema, this.stripe.customers, {
|
|
2723
|
+
tableName: "customers",
|
|
2724
|
+
filterMappings: {
|
|
2725
|
+
email: "email"
|
|
2726
|
+
},
|
|
2727
|
+
defaultFilters: {
|
|
2728
|
+
deleted: false
|
|
2729
|
+
}
|
|
2730
|
+
});
|
|
2731
|
+
this.subscriptions = createResourceProxy(this.pool, this.schema, this.stripe.subscriptions, {
|
|
2732
|
+
tableName: "subscriptions",
|
|
2733
|
+
filterMappings: {
|
|
2734
|
+
customer: "customer",
|
|
2735
|
+
price: "plan",
|
|
2736
|
+
status: "status"
|
|
2737
|
+
}
|
|
2738
|
+
});
|
|
2739
|
+
this.invoices = createResourceProxy(this.pool, this.schema, this.stripe.invoices, {
|
|
2740
|
+
tableName: "invoices",
|
|
2741
|
+
filterMappings: {
|
|
2742
|
+
customer: "customer",
|
|
2743
|
+
subscription: "subscription",
|
|
2744
|
+
status: "status"
|
|
2745
|
+
}
|
|
2746
|
+
});
|
|
2747
|
+
this.charges = createResourceProxy(this.pool, this.schema, this.stripe.charges, {
|
|
2748
|
+
tableName: "charges",
|
|
2749
|
+
filterMappings: {
|
|
2750
|
+
customer: "customer",
|
|
2751
|
+
payment_intent: "payment_intent"
|
|
2752
|
+
}
|
|
2753
|
+
});
|
|
2754
|
+
this.paymentIntents = createResourceProxy(this.pool, this.schema, this.stripe.paymentIntents, {
|
|
2755
|
+
tableName: "payment_intents",
|
|
2756
|
+
filterMappings: {
|
|
2757
|
+
customer: "customer"
|
|
2758
|
+
}
|
|
2759
|
+
});
|
|
2760
|
+
this.paymentMethods = createResourceProxy(this.pool, this.schema, this.stripe.paymentMethods, {
|
|
2761
|
+
tableName: "payment_methods",
|
|
2762
|
+
filterMappings: {
|
|
2763
|
+
customer: "customer",
|
|
2764
|
+
type: "type"
|
|
2765
|
+
}
|
|
2766
|
+
});
|
|
2767
|
+
this.setupIntents = createResourceProxy(this.pool, this.schema, this.stripe.setupIntents, {
|
|
2768
|
+
tableName: "setup_intents",
|
|
2769
|
+
filterMappings: {
|
|
2770
|
+
customer: "customer",
|
|
2771
|
+
payment_method: "payment_method"
|
|
2772
|
+
}
|
|
2773
|
+
});
|
|
2774
|
+
this.plans = createResourceProxy(this.pool, this.schema, this.stripe.plans, {
|
|
2775
|
+
tableName: "plans",
|
|
2776
|
+
filterMappings: {
|
|
2777
|
+
active: "active",
|
|
2778
|
+
product: "product"
|
|
2779
|
+
}
|
|
2780
|
+
});
|
|
2781
|
+
this.coupons = createResourceProxy(this.pool, this.schema, this.stripe.coupons, {
|
|
2782
|
+
tableName: "coupons"
|
|
2783
|
+
});
|
|
2784
|
+
this.refunds = createResourceProxy(this.pool, this.schema, this.stripe.refunds, {
|
|
2785
|
+
tableName: "refunds",
|
|
2786
|
+
filterMappings: {
|
|
2787
|
+
charge: "charge",
|
|
2788
|
+
payment_intent: "payment_intent"
|
|
2789
|
+
}
|
|
2790
|
+
});
|
|
2791
|
+
this.disputes = createResourceProxy(this.pool, this.schema, this.stripe.disputes, {
|
|
2792
|
+
tableName: "disputes",
|
|
2793
|
+
filterMappings: {
|
|
2794
|
+
charge: "charge",
|
|
2795
|
+
payment_intent: "payment_intent"
|
|
2796
|
+
}
|
|
2797
|
+
});
|
|
2798
|
+
this.checkout = this.stripe.checkout;
|
|
2799
|
+
this.billingPortal = this.stripe.billingPortal;
|
|
2800
|
+
this.webhooks = this.stripe.webhooks;
|
|
2801
|
+
this.webhookEndpoints = this.stripe.webhookEndpoints;
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* Get the underlying Stripe instance for operations not covered by FasterStripe
|
|
2805
|
+
*/
|
|
2806
|
+
get raw() {
|
|
2807
|
+
return this.stripe;
|
|
2808
|
+
}
|
|
2809
|
+
/**
|
|
2810
|
+
* Check if database connection is available
|
|
2811
|
+
*/
|
|
2812
|
+
get hasDatabase() {
|
|
2813
|
+
return this.pool !== null;
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Close the database pool connection
|
|
2817
|
+
*/
|
|
2818
|
+
async close() {
|
|
2819
|
+
if (this.pool) {
|
|
2820
|
+
await this.pool.end();
|
|
2821
|
+
this.pool = null;
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
};
|
|
262
2825
|
// Annotate the CommonJS export names for ESM import in node:
|
|
263
2826
|
0 && (module.exports = {
|
|
264
|
-
|
|
2827
|
+
Billing,
|
|
2828
|
+
CreditError,
|
|
2829
|
+
FasterStripe,
|
|
2830
|
+
createHandler,
|
|
2831
|
+
credits,
|
|
2832
|
+
initCredits
|
|
265
2833
|
});
|