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