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
@@ -24,9 +24,10 @@ import {
24
24
  TLineItemExpanded,
25
25
  UsageRecord,
26
26
  Lock,
27
+ DiscountAmount,
27
28
  } from '../store/models';
28
29
  import { getConnectQueryParam } from './util';
29
- import { expandLineItems, getPriceUintAmountByCurrency } from './session';
30
+ import { expandLineItems } from './session';
30
31
  import dayjs from './dayjs';
31
32
  import {
32
33
  getSubscriptionCycleAmount,
@@ -39,6 +40,7 @@ import logger from './logger';
39
40
  import { ensureOverdraftProtectionPrice } from './overdraft-protection';
40
41
  import { CHARGE_SUPPORTED_CHAIN_TYPES } from './constants';
41
42
  import { emitAsync } from './event';
43
+ import { getPriceUintAmountByCurrency } from './price';
42
44
 
43
45
  export function getCustomerInvoicePageUrl({
44
46
  invoiceId,
@@ -67,7 +69,7 @@ export async function getOneTimeProductInfo(invoiceId: string, paymentCurrency:
67
69
  include: [{ model: InvoiceItem, as: 'lines' }],
68
70
  });
69
71
  if (!doc) {
70
- throw new Error(`Invoice not found in ${invoiceId}`);
72
+ throw new Error(`Invoice not found: ${invoiceId}`);
71
73
  }
72
74
  const json = doc.toJSON();
73
75
  const products = (await Product.findAll()).map((x) => x.toJSON());
@@ -159,8 +161,25 @@ export async function getInvoiceShouldPayTotal(invoice: Invoice) {
159
161
  return item;
160
162
  })
161
163
  );
162
- const amount = getSubscriptionCycleAmount(expandedItems, subscription.currency_id);
163
- return amount?.total || invoice.total;
164
+
165
+ const baseAmount = getSubscriptionCycleAmount(expandedItems, subscription.currency_id);
166
+ let shouldPayTotal = baseAmount?.total || invoice.total;
167
+
168
+ // Apply discount if exists
169
+ if (baseAmount?.total && invoice.total_discount_amounts && invoice.total_discount_amounts.length > 0) {
170
+ const totalDiscountAmount = invoice.total_discount_amounts.reduce((sum, discount) => {
171
+ return new BN(sum).add(new BN(discount.amount.toString())).toString();
172
+ }, '0');
173
+
174
+ shouldPayTotal = new BN(shouldPayTotal).sub(new BN(totalDiscountAmount)).toString();
175
+
176
+ // Ensure the final amount doesn't go below zero
177
+ if (new BN(shouldPayTotal).lt(new BN('0'))) {
178
+ shouldPayTotal = '0';
179
+ }
180
+ }
181
+
182
+ return shouldPayTotal;
164
183
  } catch (err) {
165
184
  console.error(`Error in getInvoiceShouldPayTotal for invoice ${invoice.id}:`, err);
166
185
  return invoice.total;
@@ -375,6 +394,7 @@ type BaseInvoiceProps = {
375
394
  description: string;
376
395
  statement_descriptor?: string;
377
396
  total: string;
397
+ subtotal?: string;
378
398
  amount_due?: string;
379
399
  amount_paid?: string;
380
400
  amount_remaining?: string;
@@ -382,6 +402,9 @@ type BaseInvoiceProps = {
382
402
  payment_intent_id?: string;
383
403
  checkout_session_id?: string;
384
404
  metadata?: Record<string, any>;
405
+ // Discount fields
406
+ discounts?: string[];
407
+ total_discount_amounts?: DiscountAmount[];
385
408
  items?: Array<{
386
409
  price_id: string;
387
410
  amount: string;
@@ -393,6 +416,9 @@ type BaseInvoiceProps = {
393
416
  };
394
417
  metadata?: Record<string, any>;
395
418
  subscription_item_id?: string;
419
+ // Discount fields for InvoiceItem
420
+ discountable?: boolean;
421
+ discount_amounts?: DiscountAmount[];
396
422
  }>;
397
423
  auto_advance?: boolean;
398
424
  paid?: boolean;
@@ -449,8 +475,8 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
449
475
  attempt_count: 0,
450
476
  attempted: false,
451
477
  tax: '0',
452
- discounts: [],
453
- total_discount_amounts: [],
478
+ discounts: props.discounts || [],
479
+ total_discount_amounts: props.total_discount_amounts || [],
454
480
  collection_method: 'charge_automatically',
455
481
  ...extraProps,
456
482
  livemode,
@@ -471,7 +497,7 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
471
497
  subscription_id: subscription?.id,
472
498
  checkout_session_id: checkoutSessionId,
473
499
  total,
474
- subtotal: total,
500
+ subtotal: props.subtotal || total,
475
501
  amount_due: amountDue,
476
502
  amount_paid: amountPaid,
477
503
  amount_remaining: amountRemaining,
@@ -507,9 +533,11 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
507
533
  invoice_id: invoice.id,
508
534
  subscription_id: subscription?.id,
509
535
  subscription_item_id: item.subscription_item_id,
510
- discountable: false,
511
- discounts: [],
512
- discount_amounts: [],
536
+ discountable: item.discountable || false,
537
+ discounts: [], // Keep as empty for now - this is legacy field
538
+ discount_amounts:
539
+ item.discount_amounts?.map((d) => ({ amount: new BN(d.amount || '0').toString(), discount: d.discount })) ||
540
+ [],
513
541
  proration: false,
514
542
  proration_details: {},
515
543
  metadata: item.metadata || {},
@@ -609,6 +637,9 @@ export async function ensureInvoiceAndItems({
609
637
  period: setup.period,
610
638
  metadata: x.metadata || {},
611
639
  subscription_item_id: subscriptionItems.find((si) => si.price_id === price.id)?.id,
640
+ // Discount fields from pre-calculated line items
641
+ discountable: x.discountable || false,
642
+ discount_amounts: x.discount_amounts || [],
612
643
  };
613
644
  });
614
645
 
@@ -632,16 +663,19 @@ export async function ensureInvoiceAndItems({
632
663
  auto_advance: props.auto_advance,
633
664
 
634
665
  total: props.total,
666
+ subtotal: props.subtotal || props.total,
635
667
  amount_remaining: props.amount_remaining || remaining,
636
668
  amount_paid: props.amount_paid,
637
669
  amount_due: props.amount_due || remaining,
638
- subtotal_excluding_tax: props.total || '0',
670
+ subtotal_excluding_tax: props.subtotal || props.total || '0',
639
671
  starting_token_balance: result.starting,
640
672
  ending_token_balance: result.ending,
641
673
 
642
674
  footer: props.footer,
643
675
  custom_fields: props.custom_fields,
644
676
  payment_settings: props.payment_settings || subscription?.payment_settings,
677
+ discounts: props.discounts,
678
+ total_discount_amounts: props.total_discount_amounts || [],
645
679
  metadata: {
646
680
  ...props.metadata,
647
681
  starting_token_balance: result.starting,
@@ -733,19 +767,19 @@ export async function ensureOverdraftProtectionInvoiceAndItems({
733
767
  const invoicePrice = price?.currency_options?.find((x: any) => x.currency_id === paymentIntent?.currency_id);
734
768
 
735
769
  if (!subscription.overdraft_protection?.enabled) {
736
- throw new Error('create overdraft protection invoice skipped due to overdraft protection not enabled');
770
+ throw new Error('Overdraft protection invoice creation skipped: overdraft protection is not enabled');
737
771
  }
738
772
 
739
773
  if (!invoicePrice) {
740
- throw new Error('overdraft protection invoice price not found');
774
+ throw new Error('Overdraft protection invoice price not found');
741
775
  }
742
776
  const currency = await PaymentCurrency.findByPk(invoicePrice.currency_id);
743
777
  if (!currency) {
744
- throw new Error('overdraft protection invoice currency not found');
778
+ throw new Error('Overdraft protection invoice currency not found');
745
779
  }
746
780
  const paymentMethod = await PaymentMethod.findByPk(currency?.payment_method_id);
747
781
  if (!paymentMethod) {
748
- throw new Error('overdraft protection invoice payment method not found');
782
+ throw new Error('Overdraft protection invoice payment method not found');
749
783
  }
750
784
 
751
785
  if (paymentMethod.type !== 'arcblock') {
@@ -754,7 +788,7 @@ export async function ensureOverdraftProtectionInvoiceAndItems({
754
788
 
755
789
  const { unused } = await isSubscriptionOverdraftProtectionEnabled(subscription, paymentIntent?.currency_id);
756
790
  if (new BN(unused).lt(new BN(invoicePrice.unit_amount))) {
757
- throw new Error('create overdraft protection invoice skipped due to insufficient overdraft protection');
791
+ throw new Error('Overdraft protection invoice creation skipped: insufficient overdraft protection funds');
758
792
  }
759
793
 
760
794
  const result = await createInvoiceWithItems({
@@ -0,0 +1,6 @@
1
+ export function trimDecimals(value: string | number, maxDecimals: number): string {
2
+ const num = typeof value === 'number' ? value : parseFloat(value || '0');
3
+ const multiplier = 10 ** maxDecimals;
4
+ const rounded = Math.round(num * multiplier) / multiplier;
5
+ return rounded.toString();
6
+ }
@@ -0,0 +1,43 @@
1
+ import type { Price, TPriceExpanded } from '../store/models';
2
+ import type { PriceCurrency } from '../store/models/types';
3
+
4
+ /**
5
+ * Get price currency options for a given price
6
+ */
7
+ export function getPriceCurrencyOptions(price: Price | TPriceExpanded): PriceCurrency[] {
8
+ if (Array.isArray(price.currency_options)) {
9
+ return price.currency_options;
10
+ }
11
+
12
+ return [
13
+ {
14
+ currency_id: price.currency_id,
15
+ unit_amount: price.unit_amount,
16
+ custom_unit_amount: price.custom_unit_amount || null,
17
+ tiers: null,
18
+ },
19
+ ];
20
+ }
21
+
22
+ /**
23
+ * Get price unit amount by currency ID
24
+ */
25
+ export function getPriceUintAmountByCurrency(price: Price | TPriceExpanded, currencyId: string) {
26
+ const options = getPriceCurrencyOptions(price);
27
+ const option = options.find((x) => x.currency_id === currencyId);
28
+ if (option) {
29
+ if (option.custom_unit_amount) {
30
+ return option.custom_unit_amount.preset || option.custom_unit_amount.presets[0];
31
+ }
32
+ return option.unit_amount;
33
+ }
34
+
35
+ if (price.currency_id === currencyId) {
36
+ if (price.custom_unit_amount) {
37
+ return price.custom_unit_amount.preset || price.custom_unit_amount.presets[0];
38
+ }
39
+ return price.unit_amount;
40
+ }
41
+
42
+ throw new Error(`Currency option ${currencyId} not configured for price ${price.id}`);
43
+ }
@@ -6,8 +6,11 @@ import isEqual from 'lodash/isEqual';
6
6
  import pAll from 'p-all';
7
7
  import { omit } from 'lodash';
8
8
  import dayjs from './dayjs';
9
+ import { validCoupon } from './discount/coupon';
10
+ import { getPriceUintAmountByCurrency, getPriceCurrencyOptions } from './price';
9
11
 
10
12
  import {
13
+ Coupon,
11
14
  Customer,
12
15
  Invoice,
13
16
  PaymentCurrency,
@@ -21,16 +24,12 @@ import {
21
24
  type TPaymentCurrency,
22
25
  type TPaymentMethodExpanded,
23
26
  } from '../store/models';
24
- import type { Price, Price as TPrice } from '../store/models/price';
27
+ import { Price, Price as TPrice } from '../store/models/price';
25
28
  import type { Product } from '../store/models/product';
26
- import type {
27
- PaymentBeneficiary,
28
- PriceCurrency,
29
- PriceRecurring,
30
- SubscriptionBillingThresholds,
31
- } from '../store/models/types';
29
+ import type { PaymentBeneficiary, PriceRecurring, SubscriptionBillingThresholds } from '../store/models/types';
32
30
  import { wallet } from './auth';
33
31
  import logger from './logger';
32
+ import { applyDiscountsToLineItems } from './discount/discount';
34
33
 
35
34
  export function getStatementDescriptor(items: any[]) {
36
35
  for (const item of items) {
@@ -59,50 +58,51 @@ export function getCheckoutMode(items: TLineItemExpanded[] = []) {
59
58
  return 'payment';
60
59
  }
61
60
 
62
- export function getPriceUintAmountByCurrency(price: TPrice | TPriceExpanded, currencyId: string) {
63
- const options = getPriceCurrencyOptions(price);
64
- const option = options.find((x) => x.currency_id === currencyId);
65
- if (option) {
66
- if (option.custom_unit_amount) {
67
- return option.custom_unit_amount.preset || option.custom_unit_amount.presets[0];
68
- }
69
- return option.unit_amount;
70
- }
71
-
72
- if (price.currency_id === currencyId) {
73
- if (price.custom_unit_amount) {
74
- return price.custom_unit_amount.preset || price.custom_unit_amount.presets[0];
75
- }
76
- return price.unit_amount;
77
- }
78
-
79
- throw new Error(`Currency option ${currencyId} not configured for price ${price.id}`);
80
- }
81
-
82
- export function getPriceCurrencyOptions(price: TPrice | TPriceExpanded): PriceCurrency[] {
83
- if (Array.isArray(price.currency_options)) {
84
- return price.currency_options;
61
+ // Calculate checkout amount with optional discount processing
62
+ export async function getCheckoutAmount(
63
+ items: TLineItemExpanded[],
64
+ currencyId: string,
65
+ trialing = false,
66
+ discountConfig?: {
67
+ promotionCodeId?: string;
68
+ couponId?: string;
69
+ customerId?: string;
85
70
  }
86
-
87
- return [
88
- {
89
- currency_id: price.currency_id,
90
- unit_amount: price.unit_amount,
91
- custom_unit_amount: price.custom_unit_amount || null,
92
- tiers: null,
93
- },
94
- ];
95
- }
96
-
97
- // FIXME: apply coupon for discounts
98
- export function getCheckoutAmount(items: TLineItemExpanded[], currencyId: string, trialing = false) {
71
+ ) {
72
+ let processedItems = items;
99
73
  let renew = new BN(0);
100
74
 
101
75
  if (items.find((x) => (x.upsell_price || x.price).custom_unit_amount) && items.length > 1) {
102
76
  throw new Error('Multiple items with custom unit amount are not supported');
103
77
  }
104
78
 
105
- const total = items
79
+ // Apply discounts if discount configuration is provided
80
+ if (discountConfig?.couponId && discountConfig?.customerId) {
81
+ try {
82
+ const currency = await PaymentCurrency.findByPk(currencyId);
83
+ if (currency) {
84
+ const discountResult = await applyDiscountsToLineItems({
85
+ lineItems: items,
86
+ promotionCodeId: discountConfig.promotionCodeId,
87
+ couponId: discountConfig.couponId,
88
+ customerId: discountConfig.customerId,
89
+ currency,
90
+ billingContext: {
91
+ trialing,
92
+ },
93
+ });
94
+
95
+ if (discountResult.discountSummary.appliedCoupon) {
96
+ processedItems = discountResult.enhancedLineItems;
97
+ }
98
+ }
99
+ } catch (error) {
100
+ // If discount processing fails, continue with original items
101
+ console.warn('Failed to apply discounts in getCheckoutAmount:', error.message);
102
+ }
103
+ }
104
+
105
+ const subtotal = processedItems
106
106
  .reduce((acc, x) => {
107
107
  if (x.custom_amount) {
108
108
  return acc.add(new BN(x.custom_amount));
@@ -131,7 +131,31 @@ export function getCheckoutAmount(items: TLineItemExpanded[], currencyId: string
131
131
  }, new BN(0))
132
132
  .toString();
133
133
 
134
- return { subtotal: total, total, renew: renew.toString(), discount: '0', shipping: '0', tax: '0' };
134
+ // Calculate total discount amount from processed line items
135
+ let totalDiscountAmount = new BN(0);
136
+ processedItems.forEach((item) => {
137
+ if ((item as any).discount_amounts?.length > 0) {
138
+ (item as any).discount_amounts.forEach((discountAmount: any) => {
139
+ totalDiscountAmount = totalDiscountAmount.add(new BN(discountAmount.amount || '0'));
140
+ });
141
+ }
142
+ });
143
+
144
+ // Ensure discount doesn't exceed subtotal
145
+ const adjustedDiscount = totalDiscountAmount.gt(new BN(subtotal)) ? new BN(subtotal) : totalDiscountAmount;
146
+
147
+ const finalTotal = new BN(subtotal).sub(adjustedDiscount);
148
+ const adjustedTotal = finalTotal.lt(new BN('0')) ? '0' : finalTotal.toString();
149
+
150
+ return {
151
+ subtotal: subtotal || '0',
152
+ total: adjustedTotal,
153
+ renew: renew.toString() || '0',
154
+ discount: adjustedDiscount.toString(),
155
+ shipping: '0',
156
+ tax: '0',
157
+ processedItems, // Return processed items with discount information
158
+ };
135
159
  }
136
160
 
137
161
  export function getRecurringPeriod(recurring: PriceRecurring) {
@@ -294,19 +318,31 @@ export function canUpsell(from: TPrice, to: TPrice) {
294
318
  return true;
295
319
  }
296
320
 
297
- export function getFastCheckoutAmount(
298
- items: TLineItemExpanded[],
299
- mode: string,
300
- currencyId: string,
321
+ export async function getFastCheckoutAmount({
322
+ items,
323
+ mode,
324
+ currencyId,
301
325
  trialing = false,
302
- minimumCycle = 1
303
- ) {
304
- if (minimumCycle < 1) {
326
+ minimumCycle = 1,
327
+ discountConfig,
328
+ }: {
329
+ items: TLineItemExpanded[];
330
+ mode: string;
331
+ currencyId: string;
332
+ trialing?: boolean;
333
+ minimumCycle?: number;
334
+ discountConfig?: {
335
+ promotionCodeId?: string;
336
+ couponId?: string;
337
+ customerId?: string;
338
+ };
339
+ }) {
340
+ if (!minimumCycle || minimumCycle < 1) {
305
341
  // eslint-disable-next-line no-param-reassign
306
342
  minimumCycle = 1;
307
343
  }
308
344
 
309
- const { total, renew } = getCheckoutAmount(items, currencyId, trialing);
345
+ const { total, renew } = await getCheckoutAmount(items, currencyId, trialing, discountConfig);
310
346
 
311
347
  if (mode === 'payment' || mode === 'auto-recharge-auth') {
312
348
  return total;
@@ -1008,11 +1044,11 @@ export function validateStripePaymentAmounts(amount: string, currency: PaymentCu
1008
1044
  * @param mode Checkout mode
1009
1045
  * @returns Validation result with error message if any
1010
1046
  */
1011
- export function validatePaymentAmounts(
1047
+ export async function validatePaymentAmounts(
1012
1048
  lineItems: TLineItemExpanded[],
1013
1049
  currency: PaymentCurrency,
1014
1050
  checkoutSession: CheckoutSession
1015
- ): { valid: boolean; error?: string } {
1051
+ ): Promise<{ valid: boolean; error?: string }> {
1016
1052
  const enableGrouping = checkoutSession.enable_subscription_grouping;
1017
1053
  const oneTimeItems = getOneTimeLineItems(lineItems);
1018
1054
  const recurringItems = getRecurringLineItems(lineItems);
@@ -1021,7 +1057,7 @@ export function validatePaymentAmounts(
1021
1057
 
1022
1058
  // Case 1: All one-time items - validate total payment amount
1023
1059
  if (recurringItems.length === 0 && oneTimeItems.length > 0) {
1024
- const { total } = getCheckoutAmount(lineItems, currency.id);
1060
+ const { total } = await getCheckoutAmount(lineItems, currency.id);
1025
1061
  if (new BN(total).lt(new BN(minAmountInUnits))) {
1026
1062
  return {
1027
1063
  valid: false,
@@ -1057,7 +1093,7 @@ export function validatePaymentAmounts(
1057
1093
  }
1058
1094
  } else {
1059
1095
  // When grouping is disabled, validate total subscription amount
1060
- const { renew } = getCheckoutAmount(lineItems, currency.id);
1096
+ const { renew } = await getCheckoutAmount(lineItems, currency.id);
1061
1097
  if (new BN(renew).lt(new BN(minAmountInUnits))) {
1062
1098
  return {
1063
1099
  valid: false,
@@ -1069,3 +1105,152 @@ export function validatePaymentAmounts(
1069
1105
 
1070
1106
  return { valid: true };
1071
1107
  }
1108
+
1109
+ /**
1110
+ * Process discount application for checkout session data
1111
+ * Validates coupon existence and basic validity, calculates discount amounts
1112
+ * Customer-specific validations are done later during checkout submission
1113
+ */
1114
+ export async function processCheckoutSessionDiscounts(
1115
+ checkoutData: {
1116
+ currency_id: string;
1117
+ amount_total: string;
1118
+ line_items?: any[];
1119
+ },
1120
+ discounts: Array<{ coupon: string }>
1121
+ ): Promise<
1122
+ Array<{
1123
+ coupon: string;
1124
+ verification_method: string;
1125
+ }>
1126
+ > {
1127
+ if (!discounts || !Array.isArray(discounts) || discounts.length === 0) {
1128
+ return [];
1129
+ }
1130
+
1131
+ // Get currency for discount calculations
1132
+ const currency = await PaymentCurrency.findByPk(checkoutData.currency_id);
1133
+ if (!currency) {
1134
+ throw new Error('Currency not found');
1135
+ }
1136
+
1137
+ const processedDiscounts: Array<{
1138
+ coupon: string;
1139
+ verification_method: string;
1140
+ }> = [];
1141
+
1142
+ const expandedItems = await Price.expand(checkoutData.line_items || [], { product: true, upsell: true });
1143
+
1144
+ // Process discounts concurrently for better performance
1145
+ const discountProcessingTasks = discounts.map((discountSpec, index) => async () => {
1146
+ if (!discountSpec.coupon) {
1147
+ throw new Error(`Coupon ID is required for discount at index ${index}`);
1148
+ }
1149
+
1150
+ const coupon = await Coupon.findByPk(discountSpec.coupon);
1151
+ if (!coupon) {
1152
+ throw new Error(`Coupon '${discountSpec.coupon}' not found`);
1153
+ }
1154
+
1155
+ // Validate coupon basic validity
1156
+ const couponValidation = validCoupon(coupon, expandedItems as TLineItemExpanded[]);
1157
+ if (!couponValidation.valid) {
1158
+ throw new Error(`Coupon '${discountSpec.coupon}' is not valid: ${couponValidation.reason}`);
1159
+ }
1160
+ return {
1161
+ coupon: coupon.id,
1162
+ verification_method: 'direct',
1163
+ };
1164
+ });
1165
+
1166
+ const processedDiscountsArray = await pAll(discountProcessingTasks, { concurrency: 5 });
1167
+ processedDiscounts.push(...processedDiscountsArray);
1168
+
1169
+ return processedDiscounts;
1170
+ }
1171
+
1172
+ /**
1173
+ * Recalculate checkout session amounts with applied discounts
1174
+ */
1175
+ export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession) {
1176
+ const { discounts } = checkoutSession;
1177
+
1178
+ // Ensure we always have safe default values
1179
+ const safeSubtotal = checkoutSession.amount_subtotal || '0';
1180
+ const safeTotal = checkoutSession.amount_total || '0';
1181
+ const safeTotalDetails = checkoutSession.total_details || {
1182
+ amount_discount: '0',
1183
+ amount_shipping: '0',
1184
+ amount_tax: '0',
1185
+ };
1186
+
1187
+ // If no discounts, return current amounts with safe defaults
1188
+ if (!discounts || discounts.length === 0) {
1189
+ return {
1190
+ amount_subtotal: safeSubtotal,
1191
+ amount_total: safeTotal,
1192
+ total_details: {
1193
+ amount_discount: safeTotalDetails.amount_discount || '0',
1194
+ amount_shipping: safeTotalDetails.amount_shipping || '0',
1195
+ amount_tax: safeTotalDetails.amount_tax || '0',
1196
+ },
1197
+ };
1198
+ }
1199
+
1200
+ const originalTotal = safeTotal;
1201
+ let totalDiscountAmount = new BN('0');
1202
+
1203
+ // Calculate discount for each coupon
1204
+ const discountPromises = discounts.map(async (discount) => {
1205
+ if (!discount.coupon) {
1206
+ return new BN('0');
1207
+ }
1208
+
1209
+ const coupon = await Coupon.findByPk(discount.coupon);
1210
+
1211
+ if (!coupon || !coupon.valid) {
1212
+ return new BN('0');
1213
+ }
1214
+
1215
+ let discountAmount = new BN('0');
1216
+ const baseAmount = new BN(originalTotal);
1217
+
1218
+ if (coupon.percent_off > 0) {
1219
+ // Percentage discount
1220
+ discountAmount = baseAmount.mul(new BN(coupon.percent_off)).div(new BN(100));
1221
+ } else if (coupon.amount_off && new BN(coupon.amount_off).gt(new BN('0'))) {
1222
+ // Fixed amount discount
1223
+ discountAmount = new BN(coupon.amount_off);
1224
+ }
1225
+
1226
+ // Ensure discount doesn't exceed original amount
1227
+ if (discountAmount.gt(baseAmount)) {
1228
+ discountAmount = baseAmount;
1229
+ }
1230
+
1231
+ return discountAmount;
1232
+ });
1233
+
1234
+ const discountAmounts = await Promise.all(discountPromises);
1235
+ totalDiscountAmount = discountAmounts.reduce((sum: BN, amount: BN) => sum.add(amount), new BN('0'));
1236
+
1237
+ // Ensure total discount doesn't exceed original amount
1238
+ if (totalDiscountAmount.gt(new BN(originalTotal))) {
1239
+ totalDiscountAmount = new BN(originalTotal);
1240
+ }
1241
+
1242
+ const finalTotal = new BN(originalTotal).sub(totalDiscountAmount);
1243
+ // Ensure total doesn't go below 0
1244
+ const adjustedTotal = finalTotal.lt(new BN('0')) ? '0' : finalTotal.toString();
1245
+
1246
+ return {
1247
+ amount_subtotal: safeSubtotal,
1248
+ amount_total: adjustedTotal,
1249
+ total_details: {
1250
+ amount_discount: totalDiscountAmount.toString(),
1251
+ amount_shipping: safeTotalDetails.amount_shipping || '0',
1252
+ amount_tax: safeTotalDetails.amount_tax || '0',
1253
+ },
1254
+ discounts,
1255
+ };
1256
+ }
@@ -30,12 +30,8 @@ import { createEvent } from './audit';
30
30
  import dayjs from './dayjs';
31
31
  import env from './env';
32
32
  import logger from './logger';
33
- import {
34
- getPriceCurrencyOptions,
35
- getPriceUintAmountByCurrency,
36
- getRecurringPeriod,
37
- getSubscriptionCreateSetup,
38
- } from './session';
33
+ import { getPriceCurrencyOptions, getPriceUintAmountByCurrency } from './price';
34
+ import { getRecurringPeriod, getSubscriptionCreateSetup } from './session';
39
35
  import { getConnectQueryParam, getCustomerStakeAddress } from './util';
40
36
  import { wallet } from './auth';
41
37
  import { getGasPayerExtra } from './payment';