payment-kit 1.20.11 → 1.20.13

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.
Files changed (92) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +2 -0
  3. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  5. package/api/src/integrations/stripe/resource.ts +253 -2
  6. package/api/src/libs/currency.ts +31 -0
  7. package/api/src/libs/discount/coupon.ts +1061 -0
  8. package/api/src/libs/discount/discount.ts +349 -0
  9. package/api/src/libs/discount/nft.ts +239 -0
  10. package/api/src/libs/discount/redemption.ts +636 -0
  11. package/api/src/libs/discount/vc.ts +73 -0
  12. package/api/src/libs/env.ts +1 -0
  13. package/api/src/libs/invoice.ts +44 -10
  14. package/api/src/libs/math-utils.ts +6 -0
  15. package/api/src/libs/price.ts +43 -0
  16. package/api/src/libs/session.ts +242 -57
  17. package/api/src/libs/subscription.ts +2 -6
  18. package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
  19. package/api/src/libs/vendor-util/adapters/types.ts +1 -0
  20. package/api/src/libs/vendor-util/fulfillment.ts +1 -1
  21. package/api/src/queues/auto-recharge.ts +1 -1
  22. package/api/src/queues/discount-status.ts +200 -0
  23. package/api/src/queues/subscription.ts +98 -5
  24. package/api/src/queues/usage-record.ts +1 -1
  25. package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
  26. package/api/src/queues/vendors/return-processor.ts +184 -0
  27. package/api/src/queues/vendors/return-scanner.ts +119 -0
  28. package/api/src/queues/vendors/status-check.ts +1 -1
  29. package/api/src/routes/auto-recharge-configs.ts +5 -3
  30. package/api/src/routes/checkout-sessions.ts +755 -64
  31. package/api/src/routes/connect/change-payment.ts +6 -1
  32. package/api/src/routes/connect/change-plan.ts +6 -1
  33. package/api/src/routes/connect/setup.ts +6 -1
  34. package/api/src/routes/connect/shared.ts +80 -9
  35. package/api/src/routes/connect/subscribe.ts +12 -2
  36. package/api/src/routes/coupons.ts +518 -0
  37. package/api/src/routes/index.ts +4 -0
  38. package/api/src/routes/invoices.ts +44 -3
  39. package/api/src/routes/meter-events.ts +2 -1
  40. package/api/src/routes/payment-currencies.ts +1 -0
  41. package/api/src/routes/promotion-codes.ts +482 -0
  42. package/api/src/routes/subscriptions.ts +23 -2
  43. package/api/src/routes/vendor.ts +89 -2
  44. package/api/src/store/migrations/20250904-discount.ts +136 -0
  45. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  46. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  47. package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
  48. package/api/src/store/models/checkout-session.ts +17 -2
  49. package/api/src/store/models/coupon.ts +144 -4
  50. package/api/src/store/models/discount.ts +23 -10
  51. package/api/src/store/models/index.ts +13 -2
  52. package/api/src/store/models/product-vendor.ts +6 -0
  53. package/api/src/store/models/promotion-code.ts +295 -18
  54. package/api/src/store/models/types.ts +30 -1
  55. package/api/tests/libs/session.spec.ts +48 -27
  56. package/blocklet.yml +1 -1
  57. package/package.json +20 -20
  58. package/src/app.tsx +2 -0
  59. package/src/components/customer/link.tsx +1 -1
  60. package/src/components/discount/discount-info.tsx +178 -0
  61. package/src/components/invoice/table.tsx +140 -48
  62. package/src/components/invoice-pdf/styles.ts +6 -0
  63. package/src/components/invoice-pdf/template.tsx +59 -33
  64. package/src/components/metadata/form.tsx +14 -5
  65. package/src/components/payment-link/actions.tsx +42 -0
  66. package/src/components/price/form.tsx +91 -65
  67. package/src/components/product/vendor-config.tsx +5 -3
  68. package/src/components/promotion/active-redemptions.tsx +534 -0
  69. package/src/components/promotion/currency-multi-select.tsx +350 -0
  70. package/src/components/promotion/currency-restrictions.tsx +117 -0
  71. package/src/components/promotion/product-select.tsx +292 -0
  72. package/src/components/promotion/promotion-code-form.tsx +534 -0
  73. package/src/components/subscription/portal/list.tsx +6 -1
  74. package/src/components/subscription/vendor-service-list.tsx +13 -2
  75. package/src/locales/en.tsx +227 -0
  76. package/src/locales/zh.tsx +222 -1
  77. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  78. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  79. package/src/pages/admin/products/coupons/create.tsx +612 -0
  80. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  81. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  82. package/src/pages/admin/products/coupons/index.tsx +210 -3
  83. package/src/pages/admin/products/index.tsx +22 -3
  84. package/src/pages/admin/products/products/detail.tsx +12 -2
  85. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  86. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  87. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  88. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  89. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  90. package/src/pages/admin/products/vendors/index.tsx +17 -5
  91. package/src/pages/customer/subscription/detail.tsx +5 -0
  92. package/vite.config.ts +4 -3
@@ -0,0 +1,1061 @@
1
+ import { BN } from '@ocap/util';
2
+
3
+ import pick from 'lodash/pick';
4
+ import { Coupon, Customer, Discount, PromotionCode, Subscription } from '../../store/models';
5
+ import type { CheckoutSession, TLineItemExpanded } from '../../store/models';
6
+ import logger from '../logger';
7
+ import { emitAsync } from '../event';
8
+
9
+ export function validCoupon(coupon: Coupon, lineItems?: TLineItemExpanded[]) {
10
+ if (!coupon.valid) {
11
+ return { valid: false, reason: 'This coupon is no longer available' };
12
+ }
13
+ if (coupon.redeem_by && Math.floor(Date.now() / 1000) > coupon.redeem_by) {
14
+ return { valid: false, reason: 'This coupon has expired and cannot be used' };
15
+ }
16
+ if (coupon.max_redemptions && (coupon.times_redeemed ?? 0) >= coupon.max_redemptions) {
17
+ return { valid: false, reason: 'This coupon has been fully redeemed and is no longer available' };
18
+ }
19
+ if (lineItems && lineItems.length > 0 && coupon.applies_to?.products) {
20
+ const applicableProducts = coupon.applies_to?.products;
21
+ if (applicableProducts && applicableProducts.length > 0) {
22
+ const hasApplicableProduct = lineItems.some((item) => applicableProducts.includes(item.price?.product_id));
23
+ if (!hasApplicableProduct) {
24
+ return { valid: false, reason: 'This coupon cannot be applied to the items in your cart' };
25
+ }
26
+ }
27
+ }
28
+ return { valid: true };
29
+ }
30
+
31
+ export async function validPromotionCode(
32
+ promotionCode: PromotionCode,
33
+ {
34
+ customerId,
35
+ amount,
36
+ currencyId,
37
+ }: {
38
+ customerId?: string;
39
+ amount?: string;
40
+ currencyId?: string;
41
+ }
42
+ ) {
43
+ if (!promotionCode.active) {
44
+ return { valid: false, reason: 'This promotion code is no longer available' };
45
+ }
46
+ if (promotionCode.expires_at && Math.floor(Date.now() / 1000) > promotionCode.expires_at) {
47
+ return { valid: false, reason: 'This promotion code has expired and cannot be used' };
48
+ }
49
+ if (promotionCode.max_redemptions && (promotionCode.times_redeemed ?? 0) >= promotionCode.max_redemptions) {
50
+ return { valid: false, reason: 'This promotion code has been fully redeemed and is no longer available' };
51
+ }
52
+ if (customerId) {
53
+ const customer = await Customer.findByPkOrDid(customerId);
54
+ if (promotionCode.verification_type === 'user_restricted' && customer?.did) {
55
+ if (!promotionCode.customer_dids?.includes(customer.did)) {
56
+ return { valid: false, reason: 'This promotion code is not available for your account' };
57
+ }
58
+ }
59
+ if (promotionCode.restrictions?.first_time_transaction) {
60
+ const previousDiscounts = await Discount.count({
61
+ where: { customer_id: customerId, coupon_id: promotionCode.coupon_id },
62
+ });
63
+ if (previousDiscounts > 0) {
64
+ return { valid: false, reason: 'This promotion is only available for first-time purchases' };
65
+ }
66
+ }
67
+ }
68
+ if (
69
+ amount &&
70
+ currencyId &&
71
+ (promotionCode.restrictions?.minimum_amount || promotionCode.restrictions?.currency_options?.[currencyId])
72
+ ) {
73
+ const minimumAmount =
74
+ currencyId === promotionCode.restrictions?.minimum_amount_currency
75
+ ? promotionCode.restrictions?.minimum_amount
76
+ : promotionCode.restrictions?.currency_options?.[currencyId]?.minimum_amount;
77
+
78
+ if (minimumAmount) {
79
+ const amountBN = new BN(amount);
80
+ const minimumBN = new BN(minimumAmount);
81
+ if (amountBN.lt(minimumBN)) {
82
+ return {
83
+ valid: false,
84
+ reason: 'This promotion requires a minimum purchase amount. Please add more items to your cart.',
85
+ };
86
+ }
87
+ }
88
+ }
89
+ return { valid: true };
90
+ }
91
+
92
+ // Calculate actual discount amount (not the remaining amount after discount)
93
+ export function calculateDiscountAmount(
94
+ coupon: Coupon,
95
+ amount: string,
96
+ currency: { id: string; decimal: number; symbol: string }
97
+ ): string {
98
+ if (coupon.percent_off > 0) {
99
+ // Calculate percentage discount
100
+ const discountAmount = new BN(amount).mul(new BN(coupon.percent_off)).div(new BN(100));
101
+ return discountAmount.toString();
102
+ }
103
+
104
+ if (coupon.amount_off) {
105
+ // Calculate fixed amount discount
106
+ const amountOff =
107
+ coupon.currency_id === currency.id ? coupon.amount_off : coupon.currency_options?.[currency.id]?.amount_off;
108
+
109
+ if (amountOff) {
110
+ // Return the smaller of discount amount or total amount
111
+ const discountBN = new BN(amountOff);
112
+ const totalBN = new BN(amount);
113
+ return BN.min(discountBN, totalBN).toString();
114
+ }
115
+ }
116
+
117
+ return '0';
118
+ }
119
+
120
+ /**
121
+ * Lock coupon and promotion code when discount is created/applied
122
+ */
123
+ export async function lockDiscountResources(couponId: string, promotionCodeId?: string): Promise<void> {
124
+ try {
125
+ // Lock the coupon
126
+ if (couponId) {
127
+ const coupon = await Coupon.findByPk(couponId);
128
+ if (coupon && !coupon.locked) {
129
+ await coupon.update({ locked: true });
130
+ logger.info('Locked coupon when creating discount', {
131
+ couponId,
132
+ });
133
+ }
134
+ }
135
+
136
+ // Lock the promotion code if it exists
137
+ if (promotionCodeId) {
138
+ const promotionCode = await PromotionCode.findByPk(promotionCodeId);
139
+ if (promotionCode && !promotionCode.locked) {
140
+ await promotionCode.update({ locked: true });
141
+ logger.info('Locked promotion code when creating discount', {
142
+ promotionCodeId,
143
+ });
144
+ }
145
+ }
146
+ } catch (error) {
147
+ logger.error('Error locking discount resources', {
148
+ couponId,
149
+ promotionCodeId,
150
+ error: error.message,
151
+ });
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Get discount records for a checkout session
157
+ * Used when creating invoices/subscriptions to include discount information
158
+ */
159
+ export async function getDiscountRecordsForCheckout({
160
+ checkoutSessionId,
161
+ customerId,
162
+ }: {
163
+ checkoutSessionId: string;
164
+ customerId: string;
165
+ }): Promise<{
166
+ discountRecords: any[];
167
+ appliedDiscounts: string[];
168
+ discountBreakdown: Array<{ amount: string; discount: string }>;
169
+ }> {
170
+ const discountRecords = await Discount.findAll({
171
+ where: {
172
+ customer_id: customerId,
173
+ checkout_session_id: checkoutSessionId,
174
+ },
175
+ include: [
176
+ { model: Coupon, as: 'coupon' },
177
+ { model: PromotionCode, as: 'promotionCode', required: false },
178
+ ],
179
+ });
180
+
181
+ if (discountRecords.length === 0) {
182
+ return {
183
+ discountRecords: [],
184
+ appliedDiscounts: [],
185
+ discountBreakdown: [],
186
+ };
187
+ }
188
+
189
+ const appliedDiscounts = discountRecords.map((d) => d.id);
190
+ const discountBreakdown = discountRecords.map((d) => ({
191
+ amount: d.metadata?.discount_amount || '0',
192
+ discount: d.id,
193
+ }));
194
+
195
+ logger.info('Retrieved discount records for checkout session', {
196
+ checkoutSessionId,
197
+ customerId,
198
+ recordCount: discountRecords.length,
199
+ });
200
+
201
+ return {
202
+ discountRecords,
203
+ appliedDiscounts,
204
+ discountBreakdown,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Get existing discount records for a checkout session
210
+ */
211
+ async function getExistingDiscountRecords(customerId: string, checkoutSessionId: string) {
212
+ try {
213
+ const existingDiscounts = await Discount.findAll({
214
+ where: {
215
+ customer_id: customerId,
216
+ checkout_session_id: checkoutSessionId,
217
+ },
218
+ });
219
+
220
+ const existingDiscountMap = new Map();
221
+ existingDiscounts.forEach((discount) => {
222
+ const key = discount.subscription_id || 'no_subscription';
223
+ existingDiscountMap.set(key, discount);
224
+ });
225
+
226
+ return existingDiscountMap;
227
+ } catch (error) {
228
+ logger.error('Error fetching existing discount records', {
229
+ customerId,
230
+ checkoutSessionId,
231
+ error: error.message,
232
+ });
233
+ throw new Error(`Failed to fetch existing discount records: ${error.message}`);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Build base discount data from checkout session and coupon info
239
+ */
240
+ function buildBaseDiscountData({
241
+ checkoutSession,
242
+ customerId,
243
+ coupon,
244
+ promotionCode,
245
+ discountAmount,
246
+ verificationData,
247
+ }: {
248
+ checkoutSession: CheckoutSession;
249
+ customerId: string;
250
+ coupon: Coupon;
251
+ promotionCode: PromotionCode;
252
+ discountAmount: string;
253
+ verificationData?: any;
254
+ }) {
255
+ const now = Math.floor(Date.now() / 1000);
256
+ let endTime: number | undefined;
257
+
258
+ if (coupon.duration === 'once') {
259
+ endTime = now + 60;
260
+ } else if (coupon.duration === 'repeating' && coupon.duration_in_months) {
261
+ const endDate = new Date();
262
+ endDate.setMonth(endDate.getMonth() + coupon.duration_in_months);
263
+ endTime = Math.floor(endDate.getTime() / 1000);
264
+ }
265
+
266
+ return {
267
+ livemode: coupon.livemode,
268
+ coupon_id: coupon.id,
269
+ promotion_code_id: promotionCode.id,
270
+ customer_id: customerId,
271
+ checkout_session_id: checkoutSession.id,
272
+ start: now,
273
+ end: endTime,
274
+ verification_method: promotionCode.verification_type,
275
+ verification_data: verificationData || checkoutSession.metadata?.verification_data,
276
+ metadata: {
277
+ checkout_session_id: checkoutSession.id,
278
+ discount_amount: discountAmount,
279
+ original_amount: checkoutSession.amount_subtotal,
280
+ final_amount: checkoutSession.amount_total,
281
+ applied_at: new Date().toISOString(),
282
+ },
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Process discount record for a single subscription
288
+ */
289
+ async function processSubscriptionDiscount({
290
+ subscriptionId,
291
+ baseDiscountData,
292
+ existingDiscountMap,
293
+ checkoutSessionId,
294
+ }: {
295
+ subscriptionId: string;
296
+ baseDiscountData: any;
297
+ existingDiscountMap: Map<string, any>;
298
+ checkoutSessionId: string;
299
+ }): Promise<{ discountRecord: any; shouldUpdateUsage: boolean }> {
300
+ try {
301
+ const existingDiscount = existingDiscountMap.get(subscriptionId);
302
+
303
+ if (existingDiscount) {
304
+ // Check if coupon or promotion code has changed
305
+ if (
306
+ existingDiscount.coupon_id !== baseDiscountData.coupon_id ||
307
+ existingDiscount.promotion_code_id !== baseDiscountData.promotion_code_id
308
+ ) {
309
+ // Update existing discount record
310
+ await existingDiscount.update({
311
+ ...baseDiscountData,
312
+ subscription_id: subscriptionId,
313
+ metadata: {
314
+ ...baseDiscountData.metadata,
315
+ subscription_id: subscriptionId,
316
+ updated_at: new Date().toISOString(),
317
+ },
318
+ });
319
+
320
+ logger.info('Updated existing discount record for subscription', {
321
+ checkoutSessionId,
322
+ subscriptionId,
323
+ discountId: existingDiscount.id,
324
+ oldCouponId: existingDiscount.coupon_id,
325
+ newCouponId: baseDiscountData.coupon_id,
326
+ });
327
+
328
+ return { discountRecord: existingDiscount, shouldUpdateUsage: true };
329
+ }
330
+
331
+ // Same coupon/promotion code, no update needed
332
+ logger.debug('Discount record already exists with same coupon/promotion', {
333
+ checkoutSessionId,
334
+ subscriptionId,
335
+ discountId: existingDiscount.id,
336
+ });
337
+
338
+ return { discountRecord: existingDiscount, shouldUpdateUsage: false };
339
+ }
340
+
341
+ // Create new discount record
342
+ const newDiscount = await Discount.create({
343
+ ...baseDiscountData,
344
+ subscription_id: subscriptionId,
345
+ metadata: {
346
+ ...baseDiscountData.metadata,
347
+ subscription_id: subscriptionId,
348
+ },
349
+ });
350
+
351
+ // Lock coupon and promotion code when discount is created
352
+ await lockDiscountResources(baseDiscountData.coupon_id, baseDiscountData.promotion_code_id);
353
+
354
+ logger.info('Created new discount record for subscription', {
355
+ checkoutSessionId,
356
+ subscriptionId,
357
+ discountId: newDiscount.id,
358
+ });
359
+
360
+ return { discountRecord: newDiscount, shouldUpdateUsage: true };
361
+ } catch (error) {
362
+ logger.error('Error processing subscription discount', {
363
+ subscriptionId,
364
+ checkoutSessionId,
365
+ error: error.message,
366
+ });
367
+ throw new Error(`Failed to process subscription discount: ${error.message}`);
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Process discount record for non-subscription case
373
+ */
374
+ async function processNonSubscriptionDiscount(
375
+ baseDiscountData: any,
376
+ existingDiscountMap: Map<string, any>,
377
+ checkoutSessionId: string
378
+ ): Promise<{ discountRecord: any; shouldUpdateUsage: boolean }> {
379
+ try {
380
+ const existingDiscount = existingDiscountMap.get('no_subscription');
381
+
382
+ if (existingDiscount) {
383
+ if (
384
+ existingDiscount.coupon_id !== baseDiscountData.coupon_id ||
385
+ existingDiscount.promotion_code_id !== baseDiscountData.promotion_code_id
386
+ ) {
387
+ // Update existing discount record
388
+ await existingDiscount.update({
389
+ ...baseDiscountData,
390
+ metadata: {
391
+ ...baseDiscountData.metadata,
392
+ updated_at: new Date().toISOString(),
393
+ },
394
+ });
395
+
396
+ logger.info('Updated existing discount record', {
397
+ checkoutSessionId,
398
+ discountId: existingDiscount.id,
399
+ oldCouponId: existingDiscount.coupon_id,
400
+ newCouponId: baseDiscountData.coupon_id,
401
+ });
402
+
403
+ return { discountRecord: existingDiscount, shouldUpdateUsage: true };
404
+ }
405
+
406
+ logger.debug('Discount record already exists with same coupon/promotion', {
407
+ checkoutSessionId,
408
+ discountId: existingDiscount.id,
409
+ });
410
+
411
+ return { discountRecord: existingDiscount, shouldUpdateUsage: false };
412
+ }
413
+
414
+ // Create new discount record
415
+ const newDiscount = await Discount.create(baseDiscountData);
416
+
417
+ // Lock coupon and promotion code when discount is created
418
+ await lockDiscountResources(baseDiscountData.coupon_id, baseDiscountData.promotion_code_id);
419
+
420
+ logger.info('Created new single discount record', {
421
+ checkoutSessionId,
422
+ discountId: newDiscount.id,
423
+ });
424
+
425
+ return { discountRecord: newDiscount, shouldUpdateUsage: true };
426
+ } catch (error) {
427
+ logger.error('Error processing non-subscription discount', {
428
+ checkoutSessionId,
429
+ error: error.message,
430
+ });
431
+ throw new Error(`Failed to process non-subscription discount: ${error.message}`);
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Update usage counts for coupons and promotion codes
437
+ * Uses checkout session ID to prevent duplicate counting when multiple subscriptions are created
438
+ */
439
+ async function updateUsageCounts({
440
+ coupon,
441
+ promotionCode,
442
+ updatedCoupons,
443
+ updatedPromotionCodes,
444
+ checkoutSessionId,
445
+ }: {
446
+ coupon: Coupon;
447
+ promotionCode: PromotionCode;
448
+ updatedCoupons: Set<string>;
449
+ updatedPromotionCodes: Set<string>;
450
+ checkoutSessionId: string;
451
+ }): Promise<void> {
452
+ try {
453
+ const updatePromises = [];
454
+
455
+ // Create unique keys based on checkout session to prevent duplicate counting
456
+ const couponSessionKey = `${coupon.id}-${checkoutSessionId}`;
457
+ const promotionCodeSessionKey = `${promotionCode.id}-${checkoutSessionId}`;
458
+
459
+ if (!updatedCoupons.has(couponSessionKey)) {
460
+ updatedCoupons.add(couponSessionKey);
461
+ updatePromises.push(
462
+ coupon.update({
463
+ times_redeemed: (coupon.times_redeemed || 0) + 1,
464
+ })
465
+ );
466
+ }
467
+
468
+ if (!updatedPromotionCodes.has(promotionCodeSessionKey)) {
469
+ updatedPromotionCodes.add(promotionCodeSessionKey);
470
+ updatePromises.push(
471
+ promotionCode.update({
472
+ times_redeemed: (promotionCode.times_redeemed || 0) + 1,
473
+ })
474
+ );
475
+ }
476
+
477
+ await Promise.all(updatePromises);
478
+ emitAsync('discount-status.queued', coupon, 'coupon', true);
479
+ emitAsync('discount-status.queued', promotionCode, 'promotion-code', true);
480
+
481
+ logger.debug('Updated coupon and promotion code usage counts', {
482
+ checkoutSessionId,
483
+ couponId: coupon.id,
484
+ promotionCodeId: promotionCode.id,
485
+ });
486
+ } catch (error) {
487
+ logger.error('Error updating usage counts', {
488
+ checkoutSessionId,
489
+ couponId: coupon.id,
490
+ promotionCodeId: promotionCode.id,
491
+ error: error.message,
492
+ });
493
+ throw new Error(`Failed to update usage counts: ${error.message}`);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Update subscription discount_id references after discount records are created/updated
499
+ */
500
+ export async function updateSubscriptionDiscountReferences({
501
+ discountRecords,
502
+ subscriptionsUpdated,
503
+ }: {
504
+ discountRecords: any[];
505
+ subscriptionsUpdated: string[];
506
+ }): Promise<{ updatedSubscriptions: string[] }> {
507
+ if (subscriptionsUpdated.length === 0) {
508
+ return { updatedSubscriptions: [] };
509
+ }
510
+
511
+ const updatedSubscriptions: string[] = [];
512
+
513
+ try {
514
+ logger.info('Updating subscription discount_id references', {
515
+ subscriptionsToUpdate: subscriptionsUpdated.length,
516
+ });
517
+
518
+ const subscriptionUpdatePromises = subscriptionsUpdated.map(async (subscriptionId) => {
519
+ try {
520
+ // Find the discount record for this subscription
521
+ const discountRecord = discountRecords.find((d) => d.subscription_id === subscriptionId);
522
+ if (!discountRecord) {
523
+ logger.warn('No discount record found for subscription', { subscriptionId });
524
+ return null;
525
+ }
526
+
527
+ // Update subscription with discount_id
528
+ const [updateCount] = await Subscription.update(
529
+ { discount_id: discountRecord.id },
530
+ { where: { id: subscriptionId } }
531
+ );
532
+
533
+ if (updateCount > 0) {
534
+ logger.debug('Updated subscription discount_id', {
535
+ subscriptionId,
536
+ discountId: discountRecord.id,
537
+ });
538
+ return subscriptionId;
539
+ }
540
+
541
+ logger.warn('Subscription not found for discount_id update', { subscriptionId });
542
+ return null;
543
+ } catch (error) {
544
+ logger.error('Failed to update subscription discount_id', {
545
+ subscriptionId,
546
+ error: error.message,
547
+ });
548
+ return null;
549
+ }
550
+ });
551
+
552
+ const updateResults = await Promise.all(subscriptionUpdatePromises);
553
+ updatedSubscriptions.push(...updateResults.filter((id) => id !== null));
554
+
555
+ logger.info('Completed subscription discount_id reference updates', {
556
+ requestedUpdates: subscriptionsUpdated.length,
557
+ successfulUpdates: updatedSubscriptions.length,
558
+ });
559
+
560
+ return { updatedSubscriptions };
561
+ } catch (error) {
562
+ logger.error('Error updating subscription discount references', {
563
+ subscriptionsUpdated,
564
+ error: error.message,
565
+ });
566
+ throw new Error(`Failed to update subscription discount references: ${error.message}`);
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Create or update discount records for a completed checkout session
572
+ * This creates/updates the actual discount records and manages usage counts
573
+ * For subscriptions, creates separate discount records for each subscription
574
+ * Handles deduplication and updates when coupon/promotion code changes
575
+ */
576
+ export async function createDiscountRecordsForCheckout({
577
+ checkoutSession,
578
+ customerId,
579
+ subscriptionIds = [],
580
+ }: {
581
+ checkoutSession: any; // CheckoutSession type
582
+ customerId: string;
583
+ subscriptionIds?: string[];
584
+ }): Promise<{
585
+ discountRecords: any[];
586
+ updatedCoupons: string[];
587
+ updatedPromotionCodes: string[];
588
+ subscriptionsUpdated: string[];
589
+ }> {
590
+ if (!checkoutSession.discounts?.length) {
591
+ return { discountRecords: [], updatedCoupons: [], updatedPromotionCodes: [], subscriptionsUpdated: [] };
592
+ }
593
+
594
+ logger.info('Creating/updating discount records for checkout session', {
595
+ checkoutSessionId: checkoutSession.id,
596
+ customerId,
597
+ subscriptionIds,
598
+ });
599
+
600
+ try {
601
+ // Get existing discount records
602
+ const existingDiscountMap = await getExistingDiscountRecords(customerId, checkoutSession.id);
603
+
604
+ const updatedCoupons = new Set<string>();
605
+ const updatedPromotionCodes = new Set<string>();
606
+ const allDiscountRecords: any[] = [];
607
+ const subscriptionsUpdated: string[] = [];
608
+
609
+ // Process each discount configuration
610
+ const discountProcessingPromises = checkoutSession.discounts.map(async (discount: any) => {
611
+ const { promotion_code: promotionCodeId, coupon: couponId, discount_amount: discountAmount } = discount;
612
+
613
+ if (!promotionCodeId || !couponId) {
614
+ logger.warn('Incomplete discount configuration, skipping', {
615
+ checkoutSessionId: checkoutSession.id,
616
+ hasPromotionCode: !!promotionCodeId,
617
+ hasCoupon: !!couponId,
618
+ });
619
+ return null;
620
+ }
621
+
622
+ try {
623
+ // Fetch coupon and promotion code
624
+ const [coupon, promotionCode] = await Promise.all([
625
+ Coupon.findByPk(couponId),
626
+ PromotionCode.findByPk(promotionCodeId),
627
+ ]);
628
+
629
+ if (!coupon || !promotionCode) {
630
+ logger.warn('Coupon or promotion code not found, skipping discount', {
631
+ couponId,
632
+ promotionCodeId,
633
+ checkoutSessionId: checkoutSession.id,
634
+ });
635
+ return null;
636
+ }
637
+
638
+ // Build base discount data
639
+ const baseDiscountData = buildBaseDiscountData({
640
+ checkoutSession,
641
+ customerId,
642
+ coupon,
643
+ promotionCode,
644
+ discountAmount,
645
+ verificationData: discount.verification_data,
646
+ });
647
+
648
+ const discountRecords: any[] = [];
649
+ let shouldUpdateUsageCount = false;
650
+ const currentSubscriptionsUpdated: string[] = [];
651
+
652
+ // Process subscriptions
653
+ if (subscriptionIds.length > 0) {
654
+ const subscriptionProcessingPromises = subscriptionIds.map(async (subscriptionId) => {
655
+ try {
656
+ const result = await processSubscriptionDiscount({
657
+ subscriptionId,
658
+ baseDiscountData,
659
+ existingDiscountMap,
660
+ checkoutSessionId: checkoutSession.id,
661
+ });
662
+
663
+ if (result.shouldUpdateUsage) {
664
+ currentSubscriptionsUpdated.push(subscriptionId);
665
+ }
666
+
667
+ return result.discountRecord;
668
+ } catch (error) {
669
+ logger.error('Failed to process subscription discount, continuing with others', {
670
+ subscriptionId,
671
+ error: error.message,
672
+ });
673
+ return null;
674
+ }
675
+ });
676
+
677
+ const subscriptionResults = await Promise.all(subscriptionProcessingPromises);
678
+ discountRecords.push(...subscriptionResults.filter((record) => record !== null));
679
+
680
+ if (currentSubscriptionsUpdated.length > 0) {
681
+ shouldUpdateUsageCount = true;
682
+ }
683
+ } else {
684
+ // Process non-subscription case
685
+ try {
686
+ const result = await processNonSubscriptionDiscount(
687
+ baseDiscountData,
688
+ existingDiscountMap,
689
+ checkoutSession.id
690
+ );
691
+
692
+ discountRecords.push(result.discountRecord);
693
+ shouldUpdateUsageCount = result.shouldUpdateUsage;
694
+ } catch (error) {
695
+ logger.error('Failed to process non-subscription discount', {
696
+ checkoutSessionId: checkoutSession.id,
697
+ error: error.message,
698
+ });
699
+ throw error;
700
+ }
701
+ }
702
+
703
+ // Update usage counts if needed
704
+ if (shouldUpdateUsageCount) {
705
+ try {
706
+ await updateUsageCounts({
707
+ coupon,
708
+ promotionCode,
709
+ updatedCoupons,
710
+ updatedPromotionCodes,
711
+ checkoutSessionId: checkoutSession.id,
712
+ });
713
+ } catch (error) {
714
+ logger.error('Failed to update usage counts, but continuing', {
715
+ couponId: coupon.id,
716
+ promotionCodeId: promotionCode.id,
717
+ error: error.message,
718
+ });
719
+ }
720
+ }
721
+
722
+ return {
723
+ discountRecords,
724
+ subscriptionsUpdated: currentSubscriptionsUpdated,
725
+ };
726
+ } catch (error) {
727
+ logger.error('Error processing discount configuration, skipping', {
728
+ couponId,
729
+ promotionCodeId,
730
+ checkoutSessionId: checkoutSession.id,
731
+ error: error.message,
732
+ });
733
+ return null;
734
+ }
735
+ });
736
+
737
+ const processingResults = await Promise.all(discountProcessingPromises);
738
+
739
+ // Aggregate results
740
+ processingResults.forEach((result) => {
741
+ if (result) {
742
+ allDiscountRecords.push(...result.discountRecords);
743
+ subscriptionsUpdated.push(...result.subscriptionsUpdated);
744
+ }
745
+ });
746
+
747
+ logger.info('All discount records processed successfully', {
748
+ checkoutSessionId: checkoutSession.id,
749
+ customerId,
750
+ totalRecords: allDiscountRecords.length,
751
+ subscriptionCount: subscriptionIds.length,
752
+ subscriptionsUpdated: subscriptionsUpdated.length,
753
+ updatedCouponsCount: updatedCoupons.size,
754
+ updatedPromotionCodesCount: updatedPromotionCodes.size,
755
+ });
756
+
757
+ return {
758
+ discountRecords: allDiscountRecords,
759
+ updatedCoupons: Array.from(updatedCoupons),
760
+ updatedPromotionCodes: Array.from(updatedPromotionCodes),
761
+ subscriptionsUpdated,
762
+ };
763
+ } catch (error) {
764
+ logger.error('Critical error processing discount records for checkout', {
765
+ checkoutSessionId: checkoutSession.id,
766
+ customerId,
767
+ subscriptionIds,
768
+ error: error.message,
769
+ });
770
+ throw new Error(`Failed to process discount records: ${error.message}`);
771
+ }
772
+ }
773
+
774
+ // Unified promotion code eligibility check
775
+ export async function checkPromotionCodeEligibility({
776
+ promotionCode,
777
+ couponId,
778
+ customerId,
779
+ amount,
780
+ currencyId,
781
+ lineItems = [],
782
+ }: {
783
+ promotionCode: PromotionCode;
784
+ couponId: string;
785
+ customerId: string;
786
+ amount: string;
787
+ currencyId: string;
788
+ lineItems?: TLineItemExpanded[];
789
+ }): Promise<{ eligible: boolean; reason?: string }> {
790
+ // Basic promotion code checks
791
+ if (!promotionCode.active) {
792
+ return { eligible: false, reason: 'This promotion code is no longer available' };
793
+ }
794
+
795
+ if (promotionCode.expires_at && Math.floor(Date.now() / 1000) > promotionCode.expires_at) {
796
+ return { eligible: false, reason: 'This promotion code has expired and cannot be used' };
797
+ }
798
+
799
+ if (promotionCode.max_redemptions && (promotionCode.times_redeemed ?? 0) >= promotionCode.max_redemptions) {
800
+ return { eligible: false, reason: 'This promotion code has been fully redeemed and is no longer available' };
801
+ }
802
+
803
+ // Get associated coupon and validate
804
+ const coupon = await Coupon.findByPk(couponId);
805
+ if (!coupon) {
806
+ return { eligible: false, reason: 'This promotion is no longer available' };
807
+ }
808
+
809
+ // Coupon validity check
810
+ const couponValidation = validCoupon(coupon, lineItems);
811
+ if (!couponValidation.valid) {
812
+ return { eligible: false, reason: couponValidation.reason };
813
+ }
814
+
815
+ const promotionValidation = await validPromotionCode(promotionCode, {
816
+ customerId,
817
+ amount,
818
+ currencyId,
819
+ });
820
+ if (!promotionValidation.valid) {
821
+ return { eligible: false, reason: promotionValidation.reason };
822
+ }
823
+ return { eligible: true };
824
+ }
825
+
826
+ // Validate discount for billing cycles (subscription invoices)
827
+ export async function validateDiscountForBilling({
828
+ discount,
829
+ }: {
830
+ discount: Discount;
831
+ subscriptionId?: string;
832
+ }): Promise<{ valid: boolean; reason?: string; shouldRemove?: boolean }> {
833
+ const coupon = await Coupon.findByPk(discount.coupon_id);
834
+ if (!coupon) {
835
+ return {
836
+ valid: false,
837
+ reason: 'This discount is no longer available',
838
+ shouldRemove: true,
839
+ };
840
+ }
841
+
842
+ const now = Math.floor(Date.now() / 1000);
843
+
844
+ // Check discount duration logic
845
+ switch (coupon.duration) {
846
+ case 'once':
847
+ // One-time discounts should not apply to recurring billing
848
+ return {
849
+ valid: false,
850
+ reason: 'One-time discount cannot be applied to subscription billing',
851
+ shouldRemove: true,
852
+ };
853
+
854
+ case 'repeating':
855
+ if (!discount.end) {
856
+ return {
857
+ valid: false,
858
+ reason: 'Repeating discount missing end date',
859
+ shouldRemove: true,
860
+ };
861
+ }
862
+
863
+ if (now > Number(discount.end)) {
864
+ return {
865
+ valid: false,
866
+ reason: 'Repeating discount has expired',
867
+ shouldRemove: true,
868
+ };
869
+ }
870
+ break;
871
+
872
+ case 'forever':
873
+ // Forever discounts are always valid
874
+ break;
875
+
876
+ default:
877
+ return {
878
+ valid: false,
879
+ reason: `Unknown coupon duration: ${coupon.duration}`,
880
+ shouldRemove: true,
881
+ };
882
+ }
883
+
884
+ return { valid: true };
885
+ }
886
+
887
+ // Get all valid discounts for a subscription's billing period
888
+ export async function getValidDiscountsForSubscriptionBilling({
889
+ subscriptionId,
890
+ customerId,
891
+ }: {
892
+ subscriptionId: string;
893
+ customerId: string;
894
+ }): Promise<{
895
+ validDiscounts: Discount[];
896
+ expiredDiscounts: Discount[];
897
+ }> {
898
+ const discounts = await Discount.findAll({
899
+ where: {
900
+ customer_id: customerId,
901
+ subscription_id: subscriptionId,
902
+ },
903
+ include: [
904
+ { model: Coupon, as: 'coupon' },
905
+ { model: PromotionCode, as: 'promotionCode' },
906
+ ],
907
+ });
908
+
909
+ const validDiscounts: Discount[] = [];
910
+ const expiredDiscounts: Discount[] = [];
911
+
912
+ logger.info('Found discounts for subscription billing', {
913
+ subscriptionId,
914
+ discountCount: discounts.map((d) => d.id),
915
+ });
916
+
917
+ await Promise.all(
918
+ discounts.map(async (discount) => {
919
+ const validation = await validateDiscountForBilling({
920
+ discount,
921
+ subscriptionId,
922
+ });
923
+
924
+ if (validation.valid) {
925
+ validDiscounts.push(discount);
926
+ } else {
927
+ expiredDiscounts.push(discount);
928
+ logger.info('Expired discount for subscription billing', {
929
+ subscriptionId,
930
+ discountId: discount.id,
931
+ reason: validation.reason,
932
+ });
933
+ }
934
+ })
935
+ );
936
+
937
+ return { validDiscounts, expiredDiscounts };
938
+ }
939
+
940
+ /**
941
+ * Expand line items with complete coupon information
942
+ * Similar to Price.expand but for coupon information
943
+ */
944
+ export async function expandLineItemsWithCouponInfo(
945
+ lineItems: TLineItemExpanded[],
946
+ sessionDiscounts?: any[],
947
+ currencyId?: string
948
+ ): Promise<TLineItemExpanded[]> {
949
+ if (!sessionDiscounts?.length) {
950
+ return lineItems;
951
+ }
952
+
953
+ const enhancedLineItems = await Promise.all(
954
+ lineItems.map(async (item) => {
955
+ if (!item.discount_amounts?.length) {
956
+ return item;
957
+ }
958
+
959
+ const enhancedDiscountAmounts = await Promise.all(
960
+ item.discount_amounts.map(async (discountAmount: any) => {
961
+ let couponInfo = null;
962
+ let promotionCodeInfo = null;
963
+
964
+ // Find the discount in checkout session discounts to get IDs
965
+ const sessionDiscount = sessionDiscounts.find((d: any) => d.coupon || d.promotion_code);
966
+ if (sessionDiscount) {
967
+ if (sessionDiscount.coupon) {
968
+ const coupon = await Coupon.findByPk(sessionDiscount.coupon);
969
+ if (coupon) {
970
+ couponInfo = {
971
+ object: 'coupon',
972
+ id: coupon.id,
973
+ name: coupon.name,
974
+ amount_off: coupon.amount_off,
975
+ percent_off: coupon.percent_off,
976
+ currency: coupon.currency_id,
977
+ duration: coupon.duration,
978
+ duration_in_months: coupon.duration_in_months,
979
+ has_applies_to_products: true,
980
+ };
981
+ }
982
+ }
983
+
984
+ if (sessionDiscount.promotion_code) {
985
+ const promotionCode = await PromotionCode.findByPk(sessionDiscount.promotion_code);
986
+ if (promotionCode) {
987
+ promotionCodeInfo = {
988
+ object: 'promotion_code',
989
+ id: promotionCode.id,
990
+ code: promotionCode.code,
991
+ };
992
+ }
993
+ }
994
+ }
995
+
996
+ return {
997
+ ...discountAmount,
998
+ coupon: couponInfo,
999
+ promotion_code: promotionCodeInfo,
1000
+ currency: currencyId,
1001
+ };
1002
+ })
1003
+ );
1004
+
1005
+ return {
1006
+ ...item,
1007
+ discount_amounts: enhancedDiscountAmounts,
1008
+ };
1009
+ })
1010
+ );
1011
+
1012
+ return enhancedLineItems;
1013
+ }
1014
+
1015
+ /**
1016
+ * Expand discounts with complete coupon and promotion code information
1017
+ */
1018
+ export async function expandDiscountsWithDetails(discounts?: any[]): Promise<any[]> {
1019
+ if (!discounts?.length) {
1020
+ return [];
1021
+ }
1022
+
1023
+ const enhancedDiscounts = await Promise.all(
1024
+ discounts.map(async (discount) => {
1025
+ let couponInfo = null;
1026
+ let promotionCodeInfo = null;
1027
+
1028
+ if (discount.coupon) {
1029
+ const coupon = await Coupon.findByPk(discount.coupon);
1030
+ if (coupon) {
1031
+ couponInfo = pick(coupon, [
1032
+ 'id',
1033
+ 'name',
1034
+ 'percent_off',
1035
+ 'amount_off',
1036
+ 'valid',
1037
+ 'duration',
1038
+ 'duration_in_months',
1039
+ 'currency_id',
1040
+ 'currency_options',
1041
+ ]);
1042
+ }
1043
+ }
1044
+
1045
+ if (discount.promotion_code) {
1046
+ const promotionCode = await PromotionCode.findByPk(discount.promotion_code);
1047
+ if (promotionCode) {
1048
+ promotionCodeInfo = pick(promotionCode, ['id', 'code']);
1049
+ }
1050
+ }
1051
+
1052
+ return {
1053
+ ...discount,
1054
+ coupon_details: couponInfo,
1055
+ promotion_code_details: promotionCodeInfo,
1056
+ };
1057
+ })
1058
+ );
1059
+
1060
+ return enhancedDiscounts;
1061
+ }