payment-kit 1.20.10 → 1.20.12

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 (83) hide show
  1. package/README.md +25 -24
  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/invoice.ts +50 -16
  13. package/api/src/libs/math-utils.ts +6 -0
  14. package/api/src/libs/price.ts +43 -0
  15. package/api/src/libs/session.ts +242 -57
  16. package/api/src/libs/subscription.ts +2 -6
  17. package/api/src/locales/en.ts +38 -38
  18. package/api/src/queues/auto-recharge.ts +1 -1
  19. package/api/src/queues/discount-status.ts +200 -0
  20. package/api/src/queues/subscription.ts +98 -5
  21. package/api/src/queues/usage-record.ts +1 -1
  22. package/api/src/routes/auto-recharge-configs.ts +5 -3
  23. package/api/src/routes/checkout-sessions.ts +755 -64
  24. package/api/src/routes/connect/change-payment.ts +6 -1
  25. package/api/src/routes/connect/change-plan.ts +6 -1
  26. package/api/src/routes/connect/setup.ts +6 -1
  27. package/api/src/routes/connect/shared.ts +80 -9
  28. package/api/src/routes/connect/subscribe.ts +12 -2
  29. package/api/src/routes/coupons.ts +518 -0
  30. package/api/src/routes/index.ts +4 -0
  31. package/api/src/routes/invoices.ts +44 -3
  32. package/api/src/routes/meter-events.ts +2 -1
  33. package/api/src/routes/payment-currencies.ts +1 -0
  34. package/api/src/routes/promotion-codes.ts +482 -0
  35. package/api/src/routes/subscriptions.ts +23 -2
  36. package/api/src/store/migrations/20250904-discount.ts +136 -0
  37. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  38. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  39. package/api/src/store/models/checkout-session.ts +12 -0
  40. package/api/src/store/models/coupon.ts +144 -4
  41. package/api/src/store/models/discount.ts +23 -10
  42. package/api/src/store/models/index.ts +13 -2
  43. package/api/src/store/models/promotion-code.ts +295 -18
  44. package/api/src/store/models/types.ts +30 -1
  45. package/api/tests/libs/session.spec.ts +48 -27
  46. package/blocklet.yml +1 -1
  47. package/doc/vendor_fulfillment_system.md +38 -38
  48. package/package.json +20 -20
  49. package/src/app.tsx +2 -0
  50. package/src/components/customer/link.tsx +1 -1
  51. package/src/components/discount/discount-info.tsx +178 -0
  52. package/src/components/invoice/table.tsx +140 -48
  53. package/src/components/invoice-pdf/styles.ts +6 -0
  54. package/src/components/invoice-pdf/template.tsx +59 -33
  55. package/src/components/metadata/form.tsx +14 -5
  56. package/src/components/payment-link/actions.tsx +42 -0
  57. package/src/components/price/form.tsx +91 -65
  58. package/src/components/product/vendor-config.tsx +5 -3
  59. package/src/components/promotion/active-redemptions.tsx +534 -0
  60. package/src/components/promotion/currency-multi-select.tsx +350 -0
  61. package/src/components/promotion/currency-restrictions.tsx +117 -0
  62. package/src/components/promotion/product-select.tsx +292 -0
  63. package/src/components/promotion/promotion-code-form.tsx +534 -0
  64. package/src/components/subscription/portal/list.tsx +6 -1
  65. package/src/components/subscription/vendor-service-list.tsx +13 -2
  66. package/src/locales/en.tsx +253 -26
  67. package/src/locales/zh.tsx +222 -1
  68. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  69. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  70. package/src/pages/admin/products/coupons/create.tsx +612 -0
  71. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  72. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  73. package/src/pages/admin/products/coupons/index.tsx +210 -3
  74. package/src/pages/admin/products/index.tsx +22 -3
  75. package/src/pages/admin/products/products/detail.tsx +12 -2
  76. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  77. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  78. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  79. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  80. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  81. package/src/pages/admin/products/vendors/index.tsx +17 -5
  82. package/src/pages/customer/subscription/detail.tsx +5 -0
  83. package/vite.config.ts +4 -3
@@ -43,6 +43,8 @@ import {
43
43
  getSubscriptionLineItems,
44
44
  isCreditMeteredLineItems,
45
45
  validatePaymentAmounts,
46
+ processCheckoutSessionDiscounts,
47
+ getCheckoutSessionAmounts,
46
48
  } from '../libs/session';
47
49
  import { getDaysUntilCancel, getDaysUntilDue, getSubscriptionTrialSetup } from '../libs/subscription';
48
50
  import {
@@ -65,6 +67,8 @@ import {
65
67
  type TProductExpanded,
66
68
  type TLineItemExpanded,
67
69
  Invoice,
70
+ Coupon,
71
+ PromotionCode,
68
72
  } from '../store/models';
69
73
  import { CheckoutSession } from '../store/models/checkout-session';
70
74
  import { Customer } from '../store/models/customer';
@@ -94,7 +98,15 @@ import { CHARGE_SUPPORTED_CHAIN_TYPES } from '../libs/constants';
94
98
  import { blocklet } from '../libs/auth';
95
99
  import { addSubscriptionJob } from '../queues/subscription';
96
100
  import { updateDataConcurrency } from '../libs/env';
101
+ import {
102
+ expandLineItemsWithCouponInfo,
103
+ expandDiscountsWithDetails,
104
+ checkPromotionCodeEligibility,
105
+ createDiscountRecordsForCheckout,
106
+ updateSubscriptionDiscountReferences,
107
+ } from '../libs/discount/coupon';
97
108
  import { formatToShortUrl } from '../libs/url';
109
+ import { applyDiscountsToLineItems } from '../libs/discount/discount';
98
110
 
99
111
  const router = Router();
100
112
 
@@ -184,7 +196,87 @@ export async function validatePaymentSettings(paymentMethodId: string, paymentCu
184
196
  }
185
197
 
186
198
  /**
187
- * 计算并更新支付金额
199
+ * Validate promotion codes are still valid during checkout submission
200
+ * Uses unified eligibility check to avoid duplicate validation
201
+ */
202
+ async function validatePromotionCodesOnSubmit(
203
+ checkoutSession: CheckoutSession,
204
+ {
205
+ lineItems,
206
+ customerId,
207
+ amount,
208
+ }: {
209
+ lineItems: TLineItemExpanded[];
210
+ customerId: string;
211
+ amount?: string;
212
+ }
213
+ ) {
214
+ if (!checkoutSession.discounts?.length) {
215
+ return;
216
+ }
217
+
218
+ logger.info('Validating promotion codes on checkout submission', {
219
+ checkoutSessionId: checkoutSession.id,
220
+ discountCount: checkoutSession.discounts.length,
221
+ customerId,
222
+ });
223
+
224
+ const validationPromises = checkoutSession.discounts.map(async (discount, index) => {
225
+ const { promotion_code: promotionCodeId, coupon: couponId } = discount;
226
+
227
+ if (!promotionCodeId || !couponId) {
228
+ logger.warn('Incomplete discount configuration', {
229
+ checkoutSessionId: checkoutSession.id,
230
+ discountIndex: index,
231
+ hasPromotionCode: !!promotionCodeId,
232
+ hasCoupon: !!couponId,
233
+ });
234
+ return;
235
+ }
236
+
237
+ // Fetch promotion code
238
+ const promotionCode = await PromotionCode.findByPk(promotionCodeId);
239
+ if (!promotionCode) {
240
+ throw new CustomError(400, 'Promotion code not found');
241
+ }
242
+
243
+ // Use unified eligibility check - this includes all validations
244
+ const eligibilityCheck = await checkPromotionCodeEligibility({
245
+ promotionCode,
246
+ couponId,
247
+ customerId: customerId || checkoutSession.customer_id || '',
248
+ amount: amount || checkoutSession.amount_total,
249
+ currencyId: checkoutSession.currency_id || '',
250
+ lineItems,
251
+ });
252
+
253
+ if (!eligibilityCheck.eligible) {
254
+ logger.warn('Promotion code validation failed', {
255
+ checkoutSessionId: checkoutSession.id,
256
+ promotionCodeId,
257
+ couponId,
258
+ reason: eligibilityCheck.reason,
259
+ });
260
+ throw new CustomError(400, eligibilityCheck.reason || 'Promotion code is no longer valid');
261
+ }
262
+
263
+ logger.debug('Promotion code validation passed', {
264
+ checkoutSessionId: checkoutSession.id,
265
+ promotionCodeId,
266
+ couponId,
267
+ });
268
+ });
269
+
270
+ await Promise.all(validationPromises);
271
+
272
+ logger.info('All promotion codes validated successfully', {
273
+ checkoutSessionId: checkoutSession.id,
274
+ validatedCount: checkoutSession.discounts.length,
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Calculate and update payment amount for checkout session
188
280
  */
189
281
  export async function calculateAndUpdateAmount(
190
282
  checkoutSession: CheckoutSession,
@@ -204,7 +296,38 @@ export async function calculateAndUpdateAmount(
204
296
  trialEnd = trialSetup.trialEnd;
205
297
  }
206
298
 
207
- const amount = getCheckoutAmount(lineItems, paymentCurrencyId, trialInDays > 0 || trialEnd > now);
299
+ // Prepare discount configuration for getCheckoutAmount
300
+ let discountConfig;
301
+ if (checkoutSession.discounts && checkoutSession.discounts.length > 0) {
302
+ const firstDiscount = checkoutSession.discounts[0];
303
+ if (firstDiscount?.coupon) {
304
+ discountConfig = {
305
+ couponId: firstDiscount.coupon,
306
+ };
307
+ }
308
+ if (firstDiscount?.promotion_code) {
309
+ discountConfig = {
310
+ ...discountConfig,
311
+ promotionCodeId: firstDiscount.promotion_code,
312
+ };
313
+ }
314
+ if (checkoutSession.customer_id) {
315
+ discountConfig = {
316
+ ...discountConfig,
317
+ customerId: checkoutSession.customer_id,
318
+ };
319
+ }
320
+ }
321
+
322
+ logger.info('Getting checkout amount', {
323
+ discountConfig,
324
+ });
325
+ const amount = await getCheckoutAmount(
326
+ lineItems,
327
+ paymentCurrencyId,
328
+ trialInDays > 0 || trialEnd > now,
329
+ discountConfig
330
+ );
208
331
 
209
332
  await checkoutSession.update({
210
333
  amount_subtotal: amount.subtotal,
@@ -216,11 +339,22 @@ export async function calculateAndUpdateAmount(
216
339
  },
217
340
  });
218
341
 
219
- if (checkoutSession.mode === 'payment' && amount.total <= 0) {
220
- throw new Error('Payment amount should be greater than 0');
342
+ logger.info('Amount calculated', {
343
+ checkoutSessionId: checkoutSession.id,
344
+ amount,
345
+ });
346
+
347
+ if (checkoutSession.mode === 'payment' && amount.total < 0) {
348
+ throw new Error('Payment amount should be greater or equal to 0');
221
349
  }
222
350
 
223
- return { lineItems, amount, trialInDays, trialEnd, now };
351
+ return {
352
+ lineItems: amount.processedItems || lineItems,
353
+ amount,
354
+ trialInDays,
355
+ trialEnd,
356
+ now,
357
+ };
224
358
  }
225
359
 
226
360
  /**
@@ -399,6 +533,14 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
399
533
  submit_type: 'pay',
400
534
  cross_sell_behavior: 'auto',
401
535
  enable_subscription_grouping: false,
536
+ // Ensure required amount fields are never null
537
+ amount_subtotal: '0',
538
+ amount_total: '0',
539
+ total_details: {
540
+ amount_discount: '0',
541
+ amount_shipping: '0',
542
+ amount_tax: '0',
543
+ },
402
544
  },
403
545
  pick(payload, [
404
546
  'currency_id',
@@ -422,6 +564,8 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
422
564
  'client_reference_id',
423
565
  'after_expiration',
424
566
  'enable_subscription_grouping',
567
+ 'promotion_code',
568
+ 'discounts',
425
569
  ])
426
570
  );
427
571
 
@@ -502,23 +646,6 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
502
646
  });
503
647
  };
504
648
 
505
- export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession) {
506
- const now = dayjs().unix();
507
- const items = await Price.expand(checkoutSession.line_items);
508
- const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
509
- const trialEnd = Number(checkoutSession.subscription_data?.trial_end || 0);
510
- const amount = getCheckoutAmount(items, checkoutSession.currency_id, trialInDays > 0 || trialEnd > now);
511
- return {
512
- amount_subtotal: amount.subtotal,
513
- amount_total: amount.total,
514
- total_details: {
515
- amount_discount: amount.discount,
516
- amount_shipping: amount.shipping,
517
- amount_tax: amount.tax,
518
- },
519
- };
520
- }
521
-
522
649
  export async function ensureCheckoutSessionOpen(req: Request, res: Response, next: NextFunction) {
523
650
  const doc = await CheckoutSession.findByPk(req.params.id);
524
651
  if (!doc) {
@@ -635,7 +762,20 @@ async function processSubscriptionFastCheckout({
635
762
  const subscriptionAmounts = await pAll(
636
763
  subscriptions.map((sub) => async () => {
637
764
  const subItems = await getSubscriptionLineItems(sub, lineItems, primarySubscription);
638
- return getFastCheckoutAmount(subItems, 'subscription', paymentCurrency.id, trialEnd > now);
765
+ const discountConfig = {
766
+ promotionCodeId: checkoutSession.discounts?.[0]?.promotion_code,
767
+ couponId: checkoutSession.discounts?.[0]?.coupon,
768
+ customerId: checkoutSession.customer_id,
769
+ };
770
+ const amount = await getFastCheckoutAmount({
771
+ items: subItems,
772
+ mode: 'subscription',
773
+ currencyId: paymentCurrency.id,
774
+ trialing: trialEnd > now,
775
+ minimumCycle: 1,
776
+ discountConfig,
777
+ });
778
+ return amount;
639
779
  }),
640
780
  { concurrency: updateDataConcurrency }
641
781
  );
@@ -689,10 +829,11 @@ async function processSubscriptionFastCheckout({
689
829
  );
690
830
  if (paymentCurrency.isCredit()) {
691
831
  // skip invoice creation for credit subscriptions
692
- checkoutSession.update({
832
+ await checkoutSession.update({
693
833
  status: 'complete',
694
834
  payment_status: 'paid',
695
835
  });
836
+
696
837
  await pAll(
697
838
  subscriptions.map((sub) => async () => {
698
839
  await sub.update({
@@ -783,6 +924,8 @@ router.post('/', authLogin, async (req, res) => {
783
924
  // Customer permission validation and createMine handling
784
925
  const { create_mine: createMine } = req.body;
785
926
  const currentUserDid = req.user?.did;
927
+
928
+ const isAdmin = ['owner', 'admin'].includes(req.user?.role as string);
786
929
  // Handle createMine parameter
787
930
  if (createMine === true) {
788
931
  if (!currentUserDid) {
@@ -798,7 +941,7 @@ router.post('/', authLogin, async (req, res) => {
798
941
  ...(raw.metadata || {}),
799
942
  createdBy: currentUserDid,
800
943
  };
801
- } else if (!['owner', 'admin'].includes(req.user?.role as string)) {
944
+ } else if (!isAdmin) {
802
945
  return res.status(403).json({ error: 'Not authorized to perform this action' });
803
946
  }
804
947
 
@@ -811,7 +954,30 @@ router.post('/', authLogin, async (req, res) => {
811
954
  }
812
955
  }
813
956
 
957
+ // Process discounts before creating checkout session
958
+ let processedDiscounts: any[] = [];
959
+ if (req.body.discounts && Array.isArray(req.body.discounts)) {
960
+ if (!isAdmin) {
961
+ return res.status(403).json({ error: 'Not allowed to apply discounts' });
962
+ }
963
+ try {
964
+ processedDiscounts = await processCheckoutSessionDiscounts(raw as any, req.body.discounts);
965
+ } catch (discountError) {
966
+ logger.error('Discount processing failed during checkout session creation', {
967
+ error: discountError.message,
968
+ discounts: req.body.discounts,
969
+ });
970
+ return res.status(400).json({ error: discountError.message });
971
+ }
972
+ }
973
+
974
+ // Include processed discounts in the checkout session data
975
+ if (processedDiscounts.length > 0) {
976
+ raw.discounts = processedDiscounts;
977
+ }
978
+
814
979
  const doc = await CheckoutSession.create(raw as any);
980
+
815
981
  let url = getUrl(`/checkout/${doc.submit_type}/${doc.id}`);
816
982
  if (createMine && currentUserDid) {
817
983
  url = withQuery(url, getConnectQueryParam({ userDid: currentUserDid }));
@@ -1063,11 +1229,24 @@ router.get('/retrieve/:id', user, async (req, res) => {
1063
1229
  // @ts-ignore
1064
1230
  doc.line_items = await Price.expand(doc.line_items, { upsell: true });
1065
1231
 
1232
+ // Enhance line items with coupon information if discounts are applied
1233
+ if (doc.discounts?.length) {
1234
+ doc.line_items = await expandLineItemsWithCouponInfo(
1235
+ doc.line_items as TLineItemExpanded[],
1236
+ doc.discounts,
1237
+ doc.currency_id
1238
+ );
1239
+ }
1240
+
1241
+ // Expand discounts with complete details
1242
+ const enhancedDiscounts = await expandDiscountsWithDetails(doc.discounts);
1243
+
1066
1244
  // check payment intent
1067
1245
  const paymentIntent = doc.payment_intent_id ? await PaymentIntent.findByPk(doc.payment_intent_id) : null;
1068
1246
  res.json({
1069
1247
  checkoutSession: {
1070
1248
  ...doc.toJSON(),
1249
+ discounts: enhancedDiscounts,
1071
1250
  subscriptions,
1072
1251
  },
1073
1252
  paymentMethods: await getPaymentMethods(doc),
@@ -1116,32 +1295,6 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1116
1295
  );
1117
1296
  await checkoutSession.update({ currency_id: paymentCurrency.id });
1118
1297
 
1119
- // calculate amount and update checkout session
1120
- const { lineItems, trialInDays, trialEnd, now } = await calculateAndUpdateAmount(
1121
- checkoutSession,
1122
- paymentCurrency.id,
1123
- true
1124
- );
1125
-
1126
- // Validate payment amounts meet minimum requirements
1127
- if (paymentMethod.type === 'stripe') {
1128
- const result = validatePaymentAmounts(lineItems, paymentCurrency, checkoutSession);
1129
- if (!result.valid) {
1130
- return res.status(400).json({ error: result.error });
1131
- }
1132
- }
1133
-
1134
- // Validate checkout session ownership if it was created for a specific customer
1135
- if (checkoutSession.customer_did && checkoutSession.metadata?.createdBy) {
1136
- const createdByDid = checkoutSession.metadata.createdBy;
1137
- if (createdByDid !== req.user.did) {
1138
- return res.status(403).json({
1139
- error:
1140
- "It's not allowed to submit checkout sessions created by other users, please create your own checkout session",
1141
- });
1142
- }
1143
- }
1144
-
1145
1298
  let customer = await Customer.findOne({ where: { did: req.user.did } });
1146
1299
  if (!customer) {
1147
1300
  const { user: userInfo } = await blocklet.getUser(req.user.did);
@@ -1248,6 +1401,39 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1248
1401
 
1249
1402
  await checkoutSession.update({ customer_id: customer.id, customer_did: req.user.did });
1250
1403
 
1404
+ // Verify and recalculate amount with current discounts
1405
+ const { lineItems, trialInDays, trialEnd, now } = await calculateAndUpdateAmount(
1406
+ checkoutSession,
1407
+ paymentCurrency.id,
1408
+ true
1409
+ );
1410
+
1411
+ // Validate payment amounts meet minimum requirements
1412
+ if (paymentMethod.type === 'stripe') {
1413
+ const result = await validatePaymentAmounts(lineItems, paymentCurrency, checkoutSession);
1414
+ if (!result.valid) {
1415
+ return res.status(400).json({ error: result.error });
1416
+ }
1417
+ }
1418
+
1419
+ // Validate checkout session ownership if it was created for a specific customer
1420
+ if (checkoutSession.customer_did && checkoutSession.metadata?.createdBy) {
1421
+ const createdByDid = checkoutSession.metadata.createdBy;
1422
+ if (createdByDid !== req.user.did) {
1423
+ return res.status(403).json({
1424
+ error:
1425
+ "It's not allowed to submit checkout sessions created by other users, please create your own checkout session",
1426
+ });
1427
+ }
1428
+ }
1429
+
1430
+ // Validate promotion codes are still valid during submission
1431
+ await validatePromotionCodesOnSubmit(checkoutSession, {
1432
+ lineItems,
1433
+ customerId: req.user.did,
1434
+ amount: checkoutSession.amount_subtotal,
1435
+ });
1436
+
1251
1437
  // create or update payment intent
1252
1438
  let paymentIntent: PaymentIntent | null = null;
1253
1439
  if (checkoutSession.mode === 'payment') {
@@ -1260,6 +1446,30 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1260
1446
  customer.email
1261
1447
  );
1262
1448
  paymentIntent = result.paymentIntent;
1449
+
1450
+ // Create discount records for payment mode immediately after payment intent creation
1451
+ if (checkoutSession.discounts?.length) {
1452
+ try {
1453
+ const discountResult = await createDiscountRecordsForCheckout({
1454
+ checkoutSession,
1455
+ customerId: customer.id,
1456
+ subscriptionIds: [], // No subscriptions for payment mode
1457
+ });
1458
+
1459
+ logger.info('Created discount records for checkout session payment', {
1460
+ checkoutSessionId: checkoutSession.id,
1461
+ paymentIntentId: paymentIntent?.id,
1462
+ discountRecordCount: discountResult.discountRecords.length,
1463
+ newRecords: discountResult.subscriptionsUpdated.length,
1464
+ });
1465
+ } catch (discountError) {
1466
+ logger.error('Failed to create discount records for checkout session payment', {
1467
+ checkoutSessionId: checkoutSession.id,
1468
+ paymentIntentId: paymentIntent?.id,
1469
+ error: discountError.message,
1470
+ });
1471
+ }
1472
+ }
1263
1473
  }
1264
1474
 
1265
1475
  // SetupIntent processing
@@ -1350,14 +1560,77 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
1350
1560
  primarySubscription: subscription ? (subscription as any).id : null,
1351
1561
  enableSubscriptionGrouping: checkoutSession.enable_subscription_grouping,
1352
1562
  });
1563
+
1564
+ // Create discount records immediately after subscriptions are created
1565
+ if (checkoutSession.discounts?.length) {
1566
+ try {
1567
+ const discountResult = await createDiscountRecordsForCheckout({
1568
+ checkoutSession,
1569
+ customerId: customer.id,
1570
+ subscriptionIds: subscriptions.map((s) => s.id),
1571
+ });
1572
+
1573
+ // Update subscription discount_id references
1574
+ if (discountResult.subscriptionsUpdated.length > 0) {
1575
+ try {
1576
+ const subscriptionUpdateResult = await updateSubscriptionDiscountReferences({
1577
+ discountRecords: discountResult.discountRecords,
1578
+ subscriptionsUpdated: discountResult.subscriptionsUpdated,
1579
+ });
1580
+
1581
+ logger.debug('Updated subscription discount_id references', {
1582
+ updatedCount: subscriptionUpdateResult.updatedSubscriptions.length,
1583
+ updatedSubscriptions: subscriptionUpdateResult.updatedSubscriptions,
1584
+ });
1585
+ } catch (subscriptionUpdateError) {
1586
+ logger.error('Failed to update subscription discount_id references', {
1587
+ checkoutSessionId: checkoutSession.id,
1588
+ subscriptionsUpdated: discountResult.subscriptionsUpdated,
1589
+ error: subscriptionUpdateError.message,
1590
+ });
1591
+ throw subscriptionUpdateError;
1592
+ }
1593
+ }
1594
+
1595
+ logger.info('Created discount records for checkout session subscriptions', {
1596
+ checkoutSessionId: checkoutSession.id,
1597
+ discountRecordCount: discountResult.discountRecords.length,
1598
+ subscriptionIds: subscriptions.map((s) => s.id),
1599
+ subscriptionsUpdated: discountResult.subscriptionsUpdated,
1600
+ });
1601
+ } catch (discountError) {
1602
+ logger.error('Failed to create discount records for checkout session', {
1603
+ checkoutSessionId: checkoutSession.id,
1604
+ error: discountError.message,
1605
+ });
1606
+ return res
1607
+ .status(400)
1608
+ .json({ error: 'Failed to create discount records for checkout session, please try again later' });
1609
+ }
1610
+ }
1353
1611
  }
1354
1612
 
1355
- const fastCheckoutAmount = getFastCheckoutAmount(
1356
- lineItems,
1357
- checkoutSession.mode,
1358
- paymentCurrency.id,
1359
- trialInDays > 0 || trialEnd > now
1360
- );
1613
+ // Prepare discount configuration
1614
+ let discountConfig;
1615
+ if (checkoutSession.discounts && checkoutSession.discounts.length > 0) {
1616
+ const firstDiscount = checkoutSession.discounts[0];
1617
+ if (firstDiscount?.coupon && checkoutSession.customer_id) {
1618
+ discountConfig = {
1619
+ promotionCodeId: firstDiscount.promotion_code,
1620
+ couponId: firstDiscount.coupon,
1621
+ customerId: checkoutSession.customer_id,
1622
+ };
1623
+ }
1624
+ }
1625
+
1626
+ const fastCheckoutAmount = await getFastCheckoutAmount({
1627
+ items: lineItems,
1628
+ mode: checkoutSession.mode,
1629
+ currencyId: paymentCurrency.id,
1630
+ trialing: trialInDays > 0 || trialEnd > now,
1631
+ minimumCycle: 1,
1632
+ discountConfig,
1633
+ });
1361
1634
  const paymentSettings = {
1362
1635
  payment_method_types: checkoutSession.payment_method_types,
1363
1636
  payment_method_options: {
@@ -1734,12 +2007,20 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
1734
2007
  paymentIntent = result.paymentIntent;
1735
2008
  }
1736
2009
 
1737
- const fastCheckoutAmount = getFastCheckoutAmount(
1738
- lineItems,
1739
- checkoutSession.mode,
1740
- paymentCurrency.id,
1741
- trialInDays > 0 || trialEnd > now
1742
- );
2010
+ const discountConfig = {
2011
+ promotionCodeId: checkoutSession.discounts?.[0]?.promotion_code,
2012
+ couponId: checkoutSession.discounts?.[0]?.coupon,
2013
+ customerId: checkoutSession.customer_id,
2014
+ };
2015
+
2016
+ const fastCheckoutAmount = await getFastCheckoutAmount({
2017
+ items: lineItems,
2018
+ mode: checkoutSession.mode,
2019
+ currencyId: paymentCurrency.id,
2020
+ trialing: trialInDays > 0 || trialEnd > now,
2021
+ minimumCycle: 1,
2022
+ discountConfig,
2023
+ });
1743
2024
 
1744
2025
  const paymentSettings = {
1745
2026
  payment_method_types: checkoutSession.payment_method_types,
@@ -1800,10 +2081,11 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
1800
2081
  ...paymentSettings,
1801
2082
  });
1802
2083
  }
1803
- checkoutSession.update({
2084
+ await checkoutSession.update({
1804
2085
  status: 'complete',
1805
2086
  payment_status: 'paid',
1806
2087
  });
2088
+
1807
2089
  await pAll(
1808
2090
  subscriptions.map((sub) => async () => {
1809
2091
  await sub.update({
@@ -2133,6 +2415,415 @@ router.delete('/:id/cross-sell', user, ensureCheckoutSessionOpen, async (req, re
2133
2415
  }
2134
2416
  });
2135
2417
 
2418
+ // Apply promotion code discount (preview only - doesn't create actual discount record)
2419
+ router.post('/:id/apply-promotion', user, ensureCheckoutSessionOpen, async (req, res) => {
2420
+ try {
2421
+ const checkoutSession = req.doc as CheckoutSession;
2422
+ const { promotion_code: promotionCode, currency_id: currencyId } = req.body;
2423
+
2424
+ if (!promotionCode) {
2425
+ return res.status(400).json({ error: 'Promotion code is required' });
2426
+ }
2427
+
2428
+ if (!req.user) {
2429
+ return res.status(403).json({ error: 'Authentication required' });
2430
+ }
2431
+
2432
+ // Find customer by DID
2433
+ const customer = await Customer.findOne({ where: { did: req.user.did } });
2434
+ if (!customer) {
2435
+ return res.status(400).json({ error: 'Customer not found' });
2436
+ }
2437
+
2438
+ // Get currency
2439
+ const curCurrencyId = currencyId || checkoutSession.currency_id;
2440
+ if (!curCurrencyId) {
2441
+ return res.status(400).json({ error: 'Currency not found in checkout session' });
2442
+ }
2443
+
2444
+ const currency = await PaymentCurrency.findByPk(curCurrencyId);
2445
+ if (!currency) {
2446
+ return res.status(400).json({ error: 'Currency not found' });
2447
+ }
2448
+
2449
+ const foundPromotionCode = (await PromotionCode.findOne({
2450
+ where: { code: promotionCode },
2451
+ include: [
2452
+ {
2453
+ model: Coupon,
2454
+ as: 'coupon',
2455
+ attributes: ['id', 'name', 'duration', 'duration_in_months'],
2456
+ },
2457
+ ],
2458
+ })) as PromotionCode & { coupon: Coupon };
2459
+
2460
+ if (!foundPromotionCode || !foundPromotionCode.active) {
2461
+ return res.status(400).json({ error: 'Promotion code not found or inactive' });
2462
+ }
2463
+
2464
+ // Validate subscription grouping + repeating coupon combination
2465
+ if (checkoutSession.enable_subscription_grouping) {
2466
+ if (foundPromotionCode?.coupon?.duration === 'repeating') {
2467
+ return res.status(400).json({
2468
+ error:
2469
+ 'Repeating coupons cannot be used with subscription grouping. This limitation ensures proper discount management across multiple subscriptions in a single purchase.',
2470
+ });
2471
+ }
2472
+ }
2473
+
2474
+ const checkoutItems = checkoutSession.line_items || [];
2475
+ // Get line items
2476
+ const expandedItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
2477
+
2478
+ // Calculate trial status for discount application (same logic as in calculateAndUpdateAmount)
2479
+ const now = dayjs().unix();
2480
+ const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, currency.id);
2481
+ const isTrialing = trialSetup.trialInDays > 0 || trialSetup.trialEnd > now;
2482
+
2483
+ // Apply discount using our new function
2484
+ const discountResult = await applyDiscountsToLineItems({
2485
+ lineItems: expandedItems,
2486
+ promotionCodeId: foundPromotionCode.id,
2487
+ couponId: foundPromotionCode.coupon_id,
2488
+ customerId: customer.id,
2489
+ currency,
2490
+ billingContext: {
2491
+ trialing: isTrialing,
2492
+ },
2493
+ });
2494
+
2495
+ if (!discountResult.discountSummary.appliedCoupon) {
2496
+ return res
2497
+ .status(400)
2498
+ .json({ error: discountResult.notValidReason || 'Promotion code cannot be applied to this order' });
2499
+ }
2500
+
2501
+ // Create discount configuration for checkout session
2502
+ const coupon = await Coupon.findByPk(foundPromotionCode.coupon_id);
2503
+ const discountConfig = {
2504
+ promotion_code: foundPromotionCode.id,
2505
+ coupon: coupon?.id,
2506
+ discount_amount: discountResult.discountSummary.totalDiscountAmount,
2507
+ verification_method: 'code',
2508
+ verification_data: { code: promotionCode },
2509
+ };
2510
+
2511
+ // Update checkout session with discount preview (without detailed coupon info in line_items)
2512
+ await checkoutSession.update({
2513
+ discounts: [discountConfig],
2514
+ currency_id: curCurrencyId,
2515
+ line_items: discountResult.enhancedLineItems.map((item: TLineItemExpanded) => {
2516
+ const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
2517
+ return {
2518
+ ...cur,
2519
+ discountable: item.discountable,
2520
+ discount_amounts: item.discount_amounts,
2521
+ };
2522
+ }),
2523
+ amount_subtotal: checkoutSession.amount_total, // Original amount
2524
+ amount_total: discountResult.discountSummary.finalTotal, // Discounted amount
2525
+ total_details: {
2526
+ ...checkoutSession.total_details,
2527
+ amount_discount: discountResult.discountSummary.totalDiscountAmount,
2528
+ },
2529
+ metadata: {
2530
+ ...checkoutSession.metadata,
2531
+ promotion_code: promotionCode,
2532
+ },
2533
+ });
2534
+
2535
+ // Create enhanced line items with complete coupon information for response
2536
+ const enhancedLineItemsWithCoupon = await expandLineItemsWithCouponInfo(
2537
+ discountResult.enhancedLineItems,
2538
+ [discountConfig],
2539
+ currency.id
2540
+ );
2541
+
2542
+ logger.info('Promotion code applied to checkout session', {
2543
+ sessionId: checkoutSession.id,
2544
+ promotionCode,
2545
+ originalAmount: checkoutSession.amount_subtotal,
2546
+ discountAmount: discountResult.discountSummary.totalDiscountAmount,
2547
+ finalAmount: discountResult.discountSummary.finalTotal,
2548
+ });
2549
+
2550
+ // Expand discounts with complete details for response
2551
+ const enhancedDiscounts = await expandDiscountsWithDetails([discountConfig]);
2552
+
2553
+ res.json({
2554
+ ...checkoutSession.toJSON(),
2555
+ line_items: enhancedLineItemsWithCoupon,
2556
+ discounts: enhancedDiscounts,
2557
+ discount_applied: true,
2558
+ });
2559
+ } catch (err) {
2560
+ logger.error('Error applying promotion code', {
2561
+ sessionId: req.params.id,
2562
+ error: err.message,
2563
+ stack: err.stack,
2564
+ });
2565
+ res.status(400).json({ error: err.message });
2566
+ }
2567
+ });
2568
+
2569
+ // Recalculate promotion code discount when currency changes
2570
+ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async (req, res) => {
2571
+ try {
2572
+ const checkoutSession = req.doc as CheckoutSession;
2573
+ const { currency_id: newCurrencyId } = req.body;
2574
+
2575
+ if (!newCurrencyId) {
2576
+ return res.status(400).json({ error: 'Currency ID is required' });
2577
+ }
2578
+
2579
+ if (!req.user) {
2580
+ return res.status(403).json({ error: 'Authentication required' });
2581
+ }
2582
+
2583
+ // Check if there are existing discounts to recalculate
2584
+ if (!checkoutSession.discounts?.length) {
2585
+ return res.json({
2586
+ ...checkoutSession.toJSON(),
2587
+ discounts: [],
2588
+ discount_applied: false,
2589
+ message: 'No promotion codes to recalculate',
2590
+ });
2591
+ }
2592
+
2593
+ // Find customer by DID
2594
+ const customer = await Customer.findOne({ where: { did: req.user.did } });
2595
+ if (!customer) {
2596
+ return res.status(400).json({ error: 'Customer not found' });
2597
+ }
2598
+
2599
+ // Get new currency
2600
+ const currency = await PaymentCurrency.findByPk(newCurrencyId);
2601
+ if (!currency) {
2602
+ return res.status(400).json({ error: 'Currency not found' });
2603
+ }
2604
+
2605
+ const checkoutItems = checkoutSession.line_items || [];
2606
+ // Get line items
2607
+ const expandedItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
2608
+
2609
+ // Get the first discount (assuming only one promotion code at a time)
2610
+ const existingDiscount = checkoutSession.discounts[0];
2611
+ if (!existingDiscount) {
2612
+ return res.status(400).json({ error: 'No discount found' });
2613
+ }
2614
+
2615
+ const promotionCodeId = existingDiscount.promotion_code;
2616
+ const couponId = existingDiscount.coupon;
2617
+
2618
+ if (!promotionCodeId || !couponId) {
2619
+ return res.status(400).json({ error: 'Invalid discount configuration' });
2620
+ }
2621
+
2622
+ // Validate promotion code and coupon still exist and are active
2623
+ const [foundPromotionCode, coupon] = await Promise.all([
2624
+ PromotionCode.findByPk(promotionCodeId),
2625
+ Coupon.findByPk(couponId),
2626
+ ]);
2627
+
2628
+ if (!foundPromotionCode || !foundPromotionCode.active) {
2629
+ return res.status(400).json({ error: 'Promotion code no longer active' });
2630
+ }
2631
+
2632
+ if (!coupon || !coupon.valid) {
2633
+ return res.status(400).json({ error: 'Coupon no longer valid' });
2634
+ }
2635
+
2636
+ // Apply discount with new currency
2637
+ const discountResult = await applyDiscountsToLineItems({
2638
+ lineItems: expandedItems,
2639
+ promotionCodeId,
2640
+ couponId,
2641
+ customerId: customer.id,
2642
+ currency,
2643
+ });
2644
+
2645
+ // Check if discount can still be applied with the new currency
2646
+ if (!discountResult.discountSummary.appliedCoupon) {
2647
+ // Calculate original total without discount (finalTotal when no discount is applied)
2648
+ const originalTotal = discountResult.discountSummary.finalTotal;
2649
+
2650
+ // Remove discount if it can't be applied with new currency
2651
+ await checkoutSession.update({
2652
+ discounts: [],
2653
+ line_items: expandedItems.map((item) => {
2654
+ const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
2655
+ return {
2656
+ ...cur,
2657
+ };
2658
+ }),
2659
+ currency_id: newCurrencyId,
2660
+ amount_subtotal: originalTotal,
2661
+ amount_total: originalTotal,
2662
+ total_details: {
2663
+ ...checkoutSession.total_details,
2664
+ amount_discount: '0',
2665
+ },
2666
+ metadata: {
2667
+ ...omit(checkoutSession.metadata, ['promotion_code']),
2668
+ },
2669
+ });
2670
+
2671
+ logger.info('Promotion code removed due to currency incompatibility', {
2672
+ sessionId: checkoutSession.id,
2673
+ oldCurrencyId: checkoutSession.currency_id,
2674
+ newCurrencyId,
2675
+ promotionCode: foundPromotionCode.code,
2676
+ });
2677
+
2678
+ return res.json({
2679
+ ...checkoutSession.toJSON(),
2680
+ line_items: expandedItems,
2681
+ discounts: [],
2682
+ discount_applied: false,
2683
+ discount_removed_reason: 'currency_incompatible',
2684
+ });
2685
+ }
2686
+
2687
+ // Create updated discount configuration
2688
+ const discountConfig = {
2689
+ promotion_code: promotionCodeId,
2690
+ coupon: couponId,
2691
+ discount_amount: discountResult.discountSummary.totalDiscountAmount,
2692
+ verification_method: existingDiscount.verification_method,
2693
+ verification_data: existingDiscount.verification_data,
2694
+ };
2695
+
2696
+ // Update checkout session with recalculated discount
2697
+ await checkoutSession.update({
2698
+ discounts: [discountConfig],
2699
+ line_items: discountResult.enhancedLineItems.map((item: TLineItemExpanded) => {
2700
+ const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
2701
+ return {
2702
+ ...cur,
2703
+ discountable: item.discountable,
2704
+ discount_amounts: item.discount_amounts,
2705
+ };
2706
+ }),
2707
+ currency_id: newCurrencyId,
2708
+ amount_subtotal: checkoutSession.amount_total, // Original amount
2709
+ amount_total: discountResult.discountSummary.finalTotal, // Discounted amount
2710
+ total_details: {
2711
+ ...checkoutSession.total_details,
2712
+ amount_discount: discountResult.discountSummary.totalDiscountAmount,
2713
+ },
2714
+ });
2715
+
2716
+ // Create enhanced line items with complete coupon information for response
2717
+ const enhancedLineItemsWithCoupon = await expandLineItemsWithCouponInfo(
2718
+ discountResult.enhancedLineItems,
2719
+ [discountConfig],
2720
+ currency.id
2721
+ );
2722
+
2723
+ logger.info('Promotion code recalculated for new currency', {
2724
+ sessionId: checkoutSession.id,
2725
+ oldCurrencyId: checkoutSession.currency_id,
2726
+ newCurrencyId,
2727
+ promotionCode: foundPromotionCode.code,
2728
+ oldDiscountAmount: existingDiscount?.discount_amount || '0',
2729
+ newDiscountAmount: discountResult.discountSummary.totalDiscountAmount,
2730
+ });
2731
+
2732
+ // Expand discounts with complete details for response
2733
+ const enhancedDiscounts = await expandDiscountsWithDetails([discountConfig]);
2734
+
2735
+ res.json({
2736
+ ...checkoutSession.toJSON(),
2737
+ line_items: enhancedLineItemsWithCoupon,
2738
+ discounts: enhancedDiscounts,
2739
+ discount_applied: true,
2740
+ discount_recalculated: true,
2741
+ });
2742
+ } catch (err) {
2743
+ logger.error('Error recalculating promotion code', {
2744
+ sessionId: req.params.id,
2745
+ error: err.message,
2746
+ stack: err.stack,
2747
+ });
2748
+ res.status(400).json({ error: err.message });
2749
+ }
2750
+ });
2751
+
2752
+ // Remove promotion code discount (remove preview)
2753
+ router.delete('/:id/remove-promotion', user, ensureCheckoutSessionOpen, async (req, res) => {
2754
+ try {
2755
+ const checkoutSession = req.doc as CheckoutSession;
2756
+
2757
+ if (checkoutSession.status !== 'open') {
2758
+ return res.status(400).json({ error: 'Checkout session is not open' });
2759
+ }
2760
+
2761
+ // Get currency
2762
+ const currency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
2763
+ if (!currency) {
2764
+ return res.status(400).json({ error: 'Currency not found' });
2765
+ }
2766
+
2767
+ // Get original line items without discount information
2768
+ const originalItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
2769
+
2770
+ // Calculate trial status for accurate original amount calculation
2771
+ const now = dayjs().unix();
2772
+ const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, currency.id);
2773
+ const isTrialing = trialSetup.trialInDays > 0 || trialSetup.trialEnd > now;
2774
+
2775
+ // Calculate original amounts without any discounts
2776
+ const originalResult = await applyDiscountsToLineItems({
2777
+ lineItems: originalItems,
2778
+ couponId: 'dummy', // Won't be found, so no discount applied
2779
+ customerId: 'dummy',
2780
+ currency,
2781
+ billingContext: {
2782
+ trialing: isTrialing,
2783
+ },
2784
+ });
2785
+
2786
+ // Update checkout session to remove discount
2787
+ await checkoutSession.update({
2788
+ discounts: [],
2789
+ line_items: originalResult.enhancedLineItems.map((item: TLineItemExpanded) => ({
2790
+ price_id: item.price_id,
2791
+ quantity: item.quantity,
2792
+ custom_amount: item.custom_amount,
2793
+ upsell_price_id: item.upsell_price_id,
2794
+ cross_sell: item.cross_sell,
2795
+ adjustable_quantity: item.adjustable_quantity,
2796
+ })),
2797
+ amount_subtotal: originalResult.discountSummary.finalTotal,
2798
+ amount_total: originalResult.discountSummary.finalTotal,
2799
+ total_details: {
2800
+ ...checkoutSession.total_details,
2801
+ amount_discount: '0',
2802
+ },
2803
+ metadata: {
2804
+ ...omit(checkoutSession.metadata, ['promotion_code']),
2805
+ },
2806
+ });
2807
+
2808
+ logger.info('Promotion code removed from checkout session', {
2809
+ sessionId: checkoutSession.id,
2810
+ });
2811
+
2812
+ res.json({
2813
+ ...checkoutSession.toJSON(),
2814
+ line_items: originalResult.enhancedLineItems,
2815
+ discount_applied: false,
2816
+ });
2817
+ } catch (err) {
2818
+ logger.error('Error removing promotion code', {
2819
+ sessionId: req.params.id,
2820
+ error: err.message,
2821
+ stack: err.stack,
2822
+ });
2823
+ res.status(500).json({ error: err.message });
2824
+ }
2825
+ });
2826
+
2136
2827
  // change payment amount
2137
2828
  router.put('/:id/amount', ensureCheckoutSessionOpen, async (req, res) => {
2138
2829
  try {