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/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
- createStripeHandler: () => createStripeHandler
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/handler.ts
38
- var import_stripe_sync_engine = require("@supabase/stripe-sync-engine");
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/utils.ts
42
- var getMode = (stripeKey) => {
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/handler.ts
53
- function createStripeHandler(config = {}) {
54
- const {
55
- stripeSecretKey = process.env.STRIPE_SECRET_KEY,
56
- stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET,
57
- databaseUrl = process.env.DATABASE_URL,
58
- schema = "stripe",
59
- billingConfig,
60
- successUrl: defaultSuccessUrl,
61
- cancelUrl: defaultCancelUrl,
62
- automaticTax = true,
63
- callbacks
64
- } = config;
65
- const stripe = new import_stripe.default(stripeSecretKey);
66
- const sync = databaseUrl ? new import_stripe_sync_engine.StripeSync({
67
- poolConfig: {
68
- connectionString: databaseUrl
69
- },
70
- schema,
71
- stripeSecretKey,
72
- stripeWebhookSecret
73
- }) : null;
74
- function resolvePriceId(body, mode) {
75
- if (body.priceId) {
76
- return body.priceId;
77
- }
78
- if (!body.interval) {
79
- throw new Error("interval is required when using planName or planId");
80
- }
81
- if (!billingConfig?.[mode]?.plans) {
82
- throw new Error(
83
- "billingConfig with plans is required when using planName or planId"
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
- 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;
87
- if (!plan) {
88
- const identifier = body.planName || body.planId;
89
- throw new Error(`Plan not found: ${identifier}`);
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 price = plan.price.find((p) => p.interval === body.interval);
92
- if (!price) {
93
- throw new Error(
94
- `Price with interval "${body.interval}" not found for plan "${plan.name}"`
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
- if (!price.id) {
98
- throw new Error(
99
- `Price ID not set for plan "${plan.name}" with interval "${body.interval}". Run stripe-sync to sync price IDs.`
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
- return price.id;
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
- async function getPriceMode(priceId) {
105
- const price = await stripe.prices.retrieve(priceId);
106
- return price.type === "recurring" ? "subscription" : "payment";
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
- async function handleCheckout(request) {
109
- try {
110
- const body = await request.json();
111
- if (!body.priceId && !body.planName && !body.planId) {
112
- return new Response(
113
- JSON.stringify({
114
- error: "Provide either priceId, planName+interval, or planId+interval"
115
- }),
116
- { status: 400, headers: { "Content-Type": "application/json" } }
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 origin = request.headers.get("origin") || "";
120
- const successUrl = body.successUrl || defaultSuccessUrl || `${origin}/success?session_id={CHECKOUT_SESSION_ID}`;
121
- const cancelUrl = body.cancelUrl || defaultCancelUrl || `${origin}/`;
122
- const priceId = resolvePriceId(body, getMode(stripeSecretKey));
123
- const mode = await getPriceMode(priceId);
124
- const sessionParams = {
125
- line_items: [
126
- {
127
- price: priceId,
128
- quantity: body.quantity ?? 1
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
- mode,
132
- success_url: successUrl,
133
- cancel_url: cancelUrl,
134
- automatic_tax: { enabled: automaticTax }
135
- };
136
- if (body.customerEmail) {
137
- sessionParams.customer_email = body.customerEmail;
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
- if (body.customerId) {
140
- sessionParams.customer = body.customerId;
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
- if (body.metadata) {
143
- sessionParams.metadata = body.metadata;
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 session = await stripe.checkout.sessions.create(sessionParams);
146
- if (!session.url) {
147
- return new Response(
148
- JSON.stringify({ error: "Failed to create checkout session" }),
149
- { status: 500, headers: { "Content-Type": "application/json" } }
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 acceptHeader = request.headers.get("accept") || "";
153
- if (acceptHeader.includes("application/json")) {
154
- return new Response(JSON.stringify({ url: session.url }), {
155
- status: 200,
156
- headers: { "Content-Type": "application/json" }
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
- return Response.redirect(session.url, 303);
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("Checkout error:", err);
162
- const message = err instanceof Error ? err.message : "Unknown error";
163
- const status = err && typeof err === "object" && "statusCode" in err ? err.statusCode : 500;
164
- return new Response(JSON.stringify({ error: message }), {
165
- status,
166
- headers: { "Content-Type": "application/json" }
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 handleWebhook(request) {
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
- const body = await request.text();
173
- const url = new URL(request.url);
174
- const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
175
- const signature = request.headers.get("stripe-signature");
176
- let event;
177
- if (isLocalhost) {
178
- event = JSON.parse(body);
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
- if (!signature) {
181
- return new Response("Missing stripe-signature header", {
182
- status: 400
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
- event = stripe.webhooks.constructEvent(
187
- body,
188
- signature,
189
- stripeWebhookSecret
1577
+ creditsGranted = await grantSeatCredits(
1578
+ creditRecipient,
1579
+ plan,
1580
+ subscription.id,
1581
+ idempotencyPrefix
190
1582
  );
191
1583
  } catch (err) {
192
- const message = err instanceof Error ? err.message : "Unknown error";
193
- return new Response(
194
- `Webhook signature verification failed: ${message}`,
195
- { status: 400 }
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
- if (sync) {
200
- await sync.processEvent(event);
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
- if (callbacks) {
203
- await handleCallbacks(event, callbacks);
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
- return async function handler(request) {
216
- const url = new URL(request.url);
217
- const pathSegments = url.pathname.split("/").filter(Boolean);
218
- const action = pathSegments[pathSegments.length - 1];
219
- if (request.method !== "POST") {
220
- return new Response(JSON.stringify({ error: "Method not allowed" }), {
221
- status: 405,
222
- headers: { "Content-Type": "application/json" }
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
- switch (action) {
226
- case "checkout":
227
- return handleCheckout(request);
228
- case "webhook":
229
- return handleWebhook(request);
230
- default:
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
- JSON.stringify({
233
- error: `Unknown action: ${action}. Supported: checkout, webhook`
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 handleCallbacks(event, callbacks) {
241
- const { onSubscriptionCreated, onSubscriptionCancelled } = callbacks;
2161
+ async function handleEvent(event, ctx) {
242
2162
  switch (event.type) {
243
- case "customer.subscription.created":
244
- if (onSubscriptionCreated) {
245
- await onSubscriptionCreated(event.data.object);
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
- case "customer.subscription.deleted":
249
- if (onSubscriptionCancelled) {
250
- await onSubscriptionCancelled(event.data.object);
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
- case "customer.subscription.updated":
2182
+ }
2183
+ case "customer.subscription.updated": {
254
2184
  const subscription = event.data.object;
255
- const previousAttributes = event.data.previous_attributes;
256
- if (onSubscriptionCancelled && subscription.status === "canceled" && previousAttributes?.status && previousAttributes.status !== "canceled") {
257
- await onSubscriptionCancelled(subscription);
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
- createStripeHandler
2827
+ Billing,
2828
+ CreditError,
2829
+ FasterStripe,
2830
+ createHandler,
2831
+ credits,
2832
+ initCredits
265
2833
  });