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.
- package/README.md +25 -24
- package/api/src/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
- package/api/src/integrations/stripe/resource.ts +253 -2
- package/api/src/libs/currency.ts +31 -0
- package/api/src/libs/discount/coupon.ts +1061 -0
- package/api/src/libs/discount/discount.ts +349 -0
- package/api/src/libs/discount/nft.ts +239 -0
- package/api/src/libs/discount/redemption.ts +636 -0
- package/api/src/libs/discount/vc.ts +73 -0
- package/api/src/libs/invoice.ts +50 -16
- package/api/src/libs/math-utils.ts +6 -0
- package/api/src/libs/price.ts +43 -0
- package/api/src/libs/session.ts +242 -57
- package/api/src/libs/subscription.ts +2 -6
- package/api/src/locales/en.ts +38 -38
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/discount-status.ts +200 -0
- package/api/src/queues/subscription.ts +98 -5
- package/api/src/queues/usage-record.ts +1 -1
- package/api/src/routes/auto-recharge-configs.ts +5 -3
- package/api/src/routes/checkout-sessions.ts +755 -64
- package/api/src/routes/connect/change-payment.ts +6 -1
- package/api/src/routes/connect/change-plan.ts +6 -1
- package/api/src/routes/connect/setup.ts +6 -1
- package/api/src/routes/connect/shared.ts +80 -9
- package/api/src/routes/connect/subscribe.ts +12 -2
- package/api/src/routes/coupons.ts +518 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +44 -3
- package/api/src/routes/meter-events.ts +2 -1
- package/api/src/routes/payment-currencies.ts +1 -0
- package/api/src/routes/promotion-codes.ts +482 -0
- package/api/src/routes/subscriptions.ts +23 -2
- package/api/src/store/migrations/20250904-discount.ts +136 -0
- package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
- package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
- package/api/src/store/models/checkout-session.ts +12 -0
- package/api/src/store/models/coupon.ts +144 -4
- package/api/src/store/models/discount.ts +23 -10
- package/api/src/store/models/index.ts +13 -2
- package/api/src/store/models/promotion-code.ts +295 -18
- package/api/src/store/models/types.ts +30 -1
- package/api/tests/libs/session.spec.ts +48 -27
- package/blocklet.yml +1 -1
- package/doc/vendor_fulfillment_system.md +38 -38
- package/package.json +20 -20
- package/src/app.tsx +2 -0
- package/src/components/customer/link.tsx +1 -1
- package/src/components/discount/discount-info.tsx +178 -0
- package/src/components/invoice/table.tsx +140 -48
- package/src/components/invoice-pdf/styles.ts +6 -0
- package/src/components/invoice-pdf/template.tsx +59 -33
- package/src/components/metadata/form.tsx +14 -5
- package/src/components/payment-link/actions.tsx +42 -0
- package/src/components/price/form.tsx +91 -65
- package/src/components/product/vendor-config.tsx +5 -3
- package/src/components/promotion/active-redemptions.tsx +534 -0
- package/src/components/promotion/currency-multi-select.tsx +350 -0
- package/src/components/promotion/currency-restrictions.tsx +117 -0
- package/src/components/promotion/product-select.tsx +292 -0
- package/src/components/promotion/promotion-code-form.tsx +534 -0
- package/src/components/subscription/portal/list.tsx +6 -1
- package/src/components/subscription/vendor-service-list.tsx +13 -2
- package/src/locales/en.tsx +253 -26
- package/src/locales/zh.tsx +222 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
- package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
- package/src/pages/admin/products/coupons/create.tsx +612 -0
- package/src/pages/admin/products/coupons/detail.tsx +538 -0
- package/src/pages/admin/products/coupons/edit.tsx +127 -0
- package/src/pages/admin/products/coupons/index.tsx +210 -3
- package/src/pages/admin/products/index.tsx +22 -3
- package/src/pages/admin/products/products/detail.tsx +12 -2
- package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
- package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
- package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
- package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
- package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
- package/src/pages/admin/products/vendors/index.tsx +17 -5
- package/src/pages/customer/subscription/detail.tsx +5 -0
- package/vite.config.ts +4 -3
package/api/src/libs/invoice.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
+
}
|
package/api/src/libs/session.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
299
|
-
mode
|
|
300
|
-
currencyId
|
|
321
|
+
export async function getFastCheckoutAmount({
|
|
322
|
+
items,
|
|
323
|
+
mode,
|
|
324
|
+
currencyId,
|
|
301
325
|
trialing = false,
|
|
302
|
-
minimumCycle = 1
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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';
|