payment-kit 1.24.4 → 1.25.1
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/api/src/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +3 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +167 -1
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/refund.ts +41 -9
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- package/src/pages/customer/subscription/detail.tsx +599 -419
|
@@ -4,11 +4,12 @@ import dayjs from '../../libs/dayjs';
|
|
|
4
4
|
import logger from '../../libs/logger';
|
|
5
5
|
import { isDelegationSufficientForPayment } from '../../libs/payment';
|
|
6
6
|
import { getSubscriptionTrialSetup } from '../../libs/subscription';
|
|
7
|
-
import { getFastCheckoutAmount } from '../../libs/session';
|
|
7
|
+
import { getFastCheckoutAmount, getSubscriptionCreateSetup, type SlippageOptions } from '../../libs/session';
|
|
8
8
|
import { getTxMetadata } from '../../libs/util';
|
|
9
|
+
import { normalizeSlippageConfigFromMetadata } from '../../libs/slippage';
|
|
9
10
|
import { invoiceQueue } from '../../queues/invoice';
|
|
10
11
|
import { addSubscriptionJob } from '../../queues/subscription';
|
|
11
|
-
import
|
|
12
|
+
import { Price } from '../../store/models';
|
|
12
13
|
import {
|
|
13
14
|
ensureSetupIntent,
|
|
14
15
|
executeOcapTransactions,
|
|
@@ -43,7 +44,9 @@ export default {
|
|
|
43
44
|
|
|
44
45
|
const claimsList: any[] = [];
|
|
45
46
|
const now = dayjs().unix();
|
|
46
|
-
|
|
47
|
+
// Expand line_items to include full price and upsell_price objects
|
|
48
|
+
// This is critical for getSubscriptionCreateSetup to correctly use upsell_price when calculating authorization amount
|
|
49
|
+
const items = await Price.expand(checkoutSession.line_items);
|
|
47
50
|
const { trialEnd, trialInDays } = getSubscriptionTrialSetup(
|
|
48
51
|
checkoutSession.subscription_data as any,
|
|
49
52
|
paymentCurrency.id
|
|
@@ -52,19 +55,36 @@ export default {
|
|
|
52
55
|
const trialing = trialInDays > 0 || trialEnd > now;
|
|
53
56
|
const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
|
|
54
57
|
const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
|
|
59
|
+
// Calculate required amount considering slippage_config for dynamic pricing
|
|
60
|
+
// Priority: subscription.slippage_config > checkoutSession.metadata.slippage
|
|
61
|
+
const slippageConfig =
|
|
62
|
+
subscription?.slippage_config || normalizeSlippageConfigFromMetadata(checkoutSession.metadata);
|
|
63
|
+
let requiredAmount: string;
|
|
64
|
+
if (slippageConfig?.min_acceptable_rate) {
|
|
65
|
+
// Use slippage_config for precise calculation
|
|
66
|
+
const slippageOptions: SlippageOptions = {
|
|
67
|
+
percent: slippageConfig.percent ?? 0.5,
|
|
68
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
69
|
+
currencyDecimal: paymentCurrency.decimal,
|
|
70
|
+
};
|
|
71
|
+
const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, trialInDays, trialEnd, slippageOptions);
|
|
72
|
+
requiredAmount = setup.amount.setup;
|
|
73
|
+
} else {
|
|
74
|
+
requiredAmount = await getFastCheckoutAmount({
|
|
75
|
+
items,
|
|
76
|
+
mode: checkoutSession.mode,
|
|
77
|
+
currencyId: paymentCurrency.id,
|
|
78
|
+
trialing,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
61
81
|
|
|
62
82
|
if (paymentMethod.type === 'arcblock') {
|
|
63
83
|
const delegation = await isDelegationSufficientForPayment({
|
|
64
84
|
paymentMethod,
|
|
65
85
|
paymentCurrency,
|
|
66
86
|
userDid,
|
|
67
|
-
amount:
|
|
87
|
+
amount: requiredAmount,
|
|
68
88
|
});
|
|
69
89
|
// if we can complete purchase without any wallet interaction
|
|
70
90
|
if (delegation.sufficient === false) {
|
|
@@ -80,6 +100,7 @@ export default {
|
|
|
80
100
|
trialing,
|
|
81
101
|
billingThreshold: Math.max(minStakeAmount, billingThreshold),
|
|
82
102
|
items,
|
|
103
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
83
104
|
}),
|
|
84
105
|
});
|
|
85
106
|
}
|
|
@@ -116,6 +137,7 @@ export default {
|
|
|
116
137
|
trialing,
|
|
117
138
|
billingThreshold,
|
|
118
139
|
items,
|
|
140
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
119
141
|
}),
|
|
120
142
|
});
|
|
121
143
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getSubscriptionCreateSetup,
|
|
22
22
|
isDonationCheckoutSession,
|
|
23
23
|
getSubscriptionLineItems,
|
|
24
|
+
SlippageOptions,
|
|
24
25
|
} from '../../libs/session';
|
|
25
26
|
import { getDiscountRecordsForCheckout } from '../../libs/discount/coupon';
|
|
26
27
|
import {
|
|
@@ -40,6 +41,7 @@ import { PaymentCurrency } from '../../store/models/payment-currency';
|
|
|
40
41
|
import { PaymentIntent } from '../../store/models/payment-intent';
|
|
41
42
|
import { PaymentMethod } from '../../store/models/payment-method';
|
|
42
43
|
import { Price } from '../../store/models/price';
|
|
44
|
+
import { PriceQuote } from '../../store/models/price-quote';
|
|
43
45
|
import { SetupIntent } from '../../store/models/setup-intent';
|
|
44
46
|
import { Subscription } from '../../store/models/subscription';
|
|
45
47
|
import { ensureInvoiceAndItems } from '../../libs/invoice';
|
|
@@ -200,7 +202,7 @@ export async function ensurePaymentIntent(
|
|
|
200
202
|
throw new Error(`Payment method ${paymentMethod.type} should not be here`);
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
checkoutSession.line_items = await Price.expand(checkoutSession.line_items);
|
|
205
|
+
checkoutSession.line_items = await Price.expand(checkoutSession.line_items, { upsell: true });
|
|
204
206
|
|
|
205
207
|
return {
|
|
206
208
|
checkoutSession,
|
|
@@ -262,7 +264,7 @@ export async function ensureSetupIntent(checkoutSessionId: string, skipInvoice?:
|
|
|
262
264
|
throw new Error(`Payment method ${paymentMethod.type} should not be here`);
|
|
263
265
|
}
|
|
264
266
|
|
|
265
|
-
checkoutSession.line_items = await Price.expand(checkoutSession.line_items);
|
|
267
|
+
checkoutSession.line_items = await Price.expand(checkoutSession.line_items, { upsell: true });
|
|
266
268
|
|
|
267
269
|
let invoice;
|
|
268
270
|
if (subscription && !skipInvoice) {
|
|
@@ -421,6 +423,9 @@ export async function ensureInvoiceForCheckout({
|
|
|
421
423
|
);
|
|
422
424
|
}
|
|
423
425
|
|
|
426
|
+
// Final Freeze: Quotes in 'used' or 'payment_failed' status can be retried directly
|
|
427
|
+
// No status reset needed - quote price is immutable once created
|
|
428
|
+
|
|
424
429
|
return {
|
|
425
430
|
invoice,
|
|
426
431
|
items: await InvoiceItem.findAll({ where: { invoice_id: existingInvoice } }),
|
|
@@ -462,7 +467,61 @@ export async function ensureInvoiceForCheckout({
|
|
|
462
467
|
const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
|
|
463
468
|
const trialEnd = Number(checkoutSession.subscription_data?.trial_end || 0);
|
|
464
469
|
const now = dayjs().unix();
|
|
465
|
-
|
|
470
|
+
let invoiceItems = lineItems || (await Price.expand(checkoutSession.line_items, { product: true, upsell: true }));
|
|
471
|
+
|
|
472
|
+
// For items with quote_id, fetch full quote info and attach to line item metadata
|
|
473
|
+
// This ensures invoice line items have complete quote info for display
|
|
474
|
+
const itemsWithQuoteIds = invoiceItems.filter((item) => (item as any).quote_id);
|
|
475
|
+
|
|
476
|
+
if (itemsWithQuoteIds.length > 0) {
|
|
477
|
+
const quoteIds = itemsWithQuoteIds.map((item) => (item as any).quote_id).filter(Boolean);
|
|
478
|
+
const quotes = await PriceQuote.findAll({
|
|
479
|
+
where: { id: quoteIds },
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const quotesById = new Map(quotes.map((q) => [q.id, q]));
|
|
483
|
+
|
|
484
|
+
// Fill custom_amount and metadata.quote from quotes
|
|
485
|
+
invoiceItems = invoiceItems.map((item) => {
|
|
486
|
+
const quoteId = (item as any).quote_id;
|
|
487
|
+
if (quoteId) {
|
|
488
|
+
const quote = quotesById.get(quoteId);
|
|
489
|
+
if (quote) {
|
|
490
|
+
return {
|
|
491
|
+
...item,
|
|
492
|
+
quote_id: quote.id, // Add quote_id directly to line item for invoice item creation
|
|
493
|
+
custom_amount: item.custom_amount || quote.quoted_amount,
|
|
494
|
+
metadata: {
|
|
495
|
+
...((item as any).metadata || {}),
|
|
496
|
+
quote_id: quote.id, // Also in metadata for audit trail
|
|
497
|
+
quote: {
|
|
498
|
+
id: quote.id,
|
|
499
|
+
base_currency: quote.base_currency,
|
|
500
|
+
base_amount: quote.base_amount,
|
|
501
|
+
quoted_amount: quote.quoted_amount,
|
|
502
|
+
target_currency_id: quote.target_currency_id,
|
|
503
|
+
rate_currency_symbol: quote.rate_currency_symbol,
|
|
504
|
+
exchange_rate: quote.exchange_rate,
|
|
505
|
+
rate_provider_name: quote.rate_provider_name,
|
|
506
|
+
rate_provider_id: quote.rate_provider_id,
|
|
507
|
+
rate_timestamp_ms: quote.rate_timestamp_ms,
|
|
508
|
+
slippage_percent: quote.slippage_percent ?? (quote.metadata as any)?.slippage?.percent ?? 0.5,
|
|
509
|
+
quantity: item.quantity,
|
|
510
|
+
status: quote.status,
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return item;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
logger.info('Enriched line items with quote metadata for invoice creation', {
|
|
520
|
+
checkoutSessionId: checkoutSession.id,
|
|
521
|
+
itemCount: itemsWithQuoteIds.length,
|
|
522
|
+
foundQuotes: quotesById.size,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
466
525
|
|
|
467
526
|
// Get discount records for this checkout session
|
|
468
527
|
let discountInfo: { appliedDiscounts: string[]; discountBreakdown: Array<{ amount: string; discount: string }> } = {
|
|
@@ -562,6 +621,23 @@ export async function ensureInvoiceForCheckout({
|
|
|
562
621
|
await subscription.update({ latest_invoice_id: invoice.id });
|
|
563
622
|
}
|
|
564
623
|
|
|
624
|
+
// Update quotes with invoice_id for audit trail
|
|
625
|
+
// Find quote_ids from invoice items and update the quotes
|
|
626
|
+
const quoteIdsFromItems = items
|
|
627
|
+
.map((item) => item.metadata?.quote_id)
|
|
628
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
629
|
+
|
|
630
|
+
if (quoteIdsFromItems.length > 0) {
|
|
631
|
+
await PriceQuote.update(
|
|
632
|
+
{ invoice_id: invoice.id },
|
|
633
|
+
{ where: { id: quoteIdsFromItems } }
|
|
634
|
+
);
|
|
635
|
+
logger.info('Updated quotes with invoice_id', {
|
|
636
|
+
invoiceId: invoice.id,
|
|
637
|
+
quoteIds: quoteIdsFromItems,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
565
641
|
return { invoice, items };
|
|
566
642
|
}
|
|
567
643
|
|
|
@@ -578,7 +654,7 @@ export async function ensureInvoicesForSubscriptions({
|
|
|
578
654
|
return { invoices: [] };
|
|
579
655
|
}
|
|
580
656
|
|
|
581
|
-
const lineItems = await Price.expand(checkoutSession.line_items, { product: true });
|
|
657
|
+
const lineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
|
|
582
658
|
|
|
583
659
|
const primarySubscription = (subscriptions.find((x) => x.metadata.is_primary_subscription) ||
|
|
584
660
|
subscriptions[0]) as Subscription;
|
|
@@ -607,7 +683,7 @@ export async function ensureInvoicesForSubscriptions({
|
|
|
607
683
|
return { invoices: createdInvoices as Invoice[] };
|
|
608
684
|
}
|
|
609
685
|
|
|
610
|
-
export async function ensureInvoiceForCollect(invoiceId: string) {
|
|
686
|
+
export async function ensureInvoiceForCollect(invoiceId: string, options?: { allowCreatePI?: boolean }) {
|
|
611
687
|
const invoice = await Invoice.findByPk(invoiceId);
|
|
612
688
|
if (!invoice) {
|
|
613
689
|
throw new Error(`Invoice ${invoiceId} not found`);
|
|
@@ -623,11 +699,41 @@ export async function ensureInvoiceForCollect(invoiceId: string) {
|
|
|
623
699
|
}
|
|
624
700
|
|
|
625
701
|
if (!invoice.payment_intent_id) {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
702
|
+
// For open invoices, use invoiceQueue to create PI
|
|
703
|
+
if (invoice.status === 'open') {
|
|
704
|
+
await invoiceQueue.pushAndWait({
|
|
705
|
+
id: invoice.id,
|
|
706
|
+
job: { invoiceId: invoice.id, retryOnError: false, justCreate: true },
|
|
707
|
+
});
|
|
708
|
+
await invoice.reload();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// For uncollectible invoices with allowCreatePI flag, create PI directly
|
|
712
|
+
if (invoice.status === 'uncollectible' && options?.allowCreatePI) {
|
|
713
|
+
const customer = await Customer.findByPk(invoice.customer_id);
|
|
714
|
+
const paymentIntent = await PaymentIntent.create({
|
|
715
|
+
livemode: !!invoice.livemode,
|
|
716
|
+
amount: invoice.amount_remaining,
|
|
717
|
+
amount_received: '0',
|
|
718
|
+
amount_capturable: '0',
|
|
719
|
+
customer_id: invoice.customer_id,
|
|
720
|
+
description: 'Manual payment for uncollectible invoice',
|
|
721
|
+
currency_id: invoice.currency_id,
|
|
722
|
+
payment_method_id: invoice.default_payment_method_id,
|
|
723
|
+
invoice_id: invoice.id,
|
|
724
|
+
status: 'requires_action',
|
|
725
|
+
capture_method: 'manual',
|
|
726
|
+
confirmation_method: 'manual',
|
|
727
|
+
payment_method_types: [],
|
|
728
|
+
receipt_email: customer?.email || invoice.customer_email,
|
|
729
|
+
statement_descriptor: invoice.statement_descriptor,
|
|
730
|
+
});
|
|
731
|
+
await invoice.update({ payment_intent_id: paymentIntent.id });
|
|
732
|
+
logger.info('Created PI for uncollectible invoice via allowCreatePI', {
|
|
733
|
+
invoiceId: invoice.id,
|
|
734
|
+
paymentIntentId: paymentIntent.id,
|
|
735
|
+
});
|
|
736
|
+
}
|
|
631
737
|
}
|
|
632
738
|
|
|
633
739
|
const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
@@ -754,6 +860,7 @@ export async function getDelegationTxClaim({
|
|
|
754
860
|
trialing = false,
|
|
755
861
|
billingThreshold = 0,
|
|
756
862
|
requiredStake = true,
|
|
863
|
+
slippageConfig,
|
|
757
864
|
}: {
|
|
758
865
|
userDid: string;
|
|
759
866
|
userPk: string;
|
|
@@ -767,8 +874,33 @@ export async function getDelegationTxClaim({
|
|
|
767
874
|
billingThreshold?: number;
|
|
768
875
|
requiredStake?: boolean;
|
|
769
876
|
requiredAmount?: string;
|
|
877
|
+
slippageConfig?: {
|
|
878
|
+
mode?: 'percent' | 'rate';
|
|
879
|
+
percent?: number;
|
|
880
|
+
min_acceptable_rate?: string;
|
|
881
|
+
base_currency?: string;
|
|
882
|
+
};
|
|
770
883
|
}) {
|
|
771
|
-
|
|
884
|
+
// Calculate authorization amount
|
|
885
|
+
// When slippage_config with min_acceptable_rate is provided, use it for precise calculation
|
|
886
|
+
let amount: string;
|
|
887
|
+
if (slippageConfig?.min_acceptable_rate) {
|
|
888
|
+
const slippageOptions: SlippageOptions = {
|
|
889
|
+
percent: slippageConfig.percent ?? 0.5,
|
|
890
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
891
|
+
currencyDecimal: paymentCurrency.decimal,
|
|
892
|
+
};
|
|
893
|
+
const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
|
|
894
|
+
amount = setup.amount.setup;
|
|
895
|
+
logger.info('getDelegationTxClaim: Using slippage_config for authorization calculation', {
|
|
896
|
+
mode,
|
|
897
|
+
slippageConfig,
|
|
898
|
+
authorizationAmount: amount,
|
|
899
|
+
});
|
|
900
|
+
} else {
|
|
901
|
+
amount = await getFastCheckoutAmount({ items, mode, currencyId: paymentCurrency.id });
|
|
902
|
+
}
|
|
903
|
+
|
|
772
904
|
const address = toDelegateAddress(userDid, wallet.address);
|
|
773
905
|
const tokenLimits = await getTokenLimitsForDelegation(items, paymentMethod, paymentCurrency, address, amount);
|
|
774
906
|
let tokenRequirements = await getTokenRequirements({
|
|
@@ -939,7 +1071,14 @@ export async function getStakeTxClaim({
|
|
|
939
1071
|
),
|
|
940
1072
|
};
|
|
941
1073
|
|
|
942
|
-
|
|
1074
|
+
// Build slippage options with min_acceptable_rate for precise authorization calculation
|
|
1075
|
+
const slippageConfig = subscription.slippage_config;
|
|
1076
|
+
const slippageOptions = {
|
|
1077
|
+
percent: slippageConfig?.percent ?? 0.5,
|
|
1078
|
+
minAcceptableRate: slippageConfig?.min_acceptable_rate,
|
|
1079
|
+
currencyDecimal: paymentCurrency.decimal,
|
|
1080
|
+
};
|
|
1081
|
+
const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
|
|
943
1082
|
|
|
944
1083
|
return {
|
|
945
1084
|
type: 'StakeTx',
|
|
@@ -1030,7 +1169,14 @@ export async function getOverdraftProtectionStakeTxClaim({
|
|
|
1030
1169
|
),
|
|
1031
1170
|
};
|
|
1032
1171
|
|
|
1033
|
-
|
|
1172
|
+
// Build slippage options with min_acceptable_rate for precise authorization calculation
|
|
1173
|
+
const slippageConfig = subscription.slippage_config;
|
|
1174
|
+
const slippageOptions = {
|
|
1175
|
+
percent: slippageConfig?.percent ?? 0.5,
|
|
1176
|
+
minAcceptableRate: slippageConfig?.min_acceptable_rate,
|
|
1177
|
+
currencyDecimal: paymentCurrency.decimal,
|
|
1178
|
+
};
|
|
1179
|
+
const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
|
|
1034
1180
|
|
|
1035
1181
|
return {
|
|
1036
1182
|
type: 'StakeTx',
|
|
@@ -5,11 +5,13 @@ import dayjs from '../../libs/dayjs';
|
|
|
5
5
|
import logger from '../../libs/logger';
|
|
6
6
|
import { isDelegationSufficientForPayment, checkTokenBalance } from '../../libs/payment';
|
|
7
7
|
import { getSubscriptionTrialSetup } from '../../libs/subscription';
|
|
8
|
-
import { getFastCheckoutAmount } from '../../libs/session';
|
|
8
|
+
import { getFastCheckoutAmount, getSubscriptionCreateSetup, type SlippageOptions } from '../../libs/session';
|
|
9
9
|
import { getTxMetadata } from '../../libs/util';
|
|
10
|
+
import { normalizeSlippageConfigFromMetadata } from '../../libs/slippage';
|
|
10
11
|
import { invoiceQueue } from '../../queues/invoice';
|
|
11
12
|
import { addSubscriptionJob } from '../../queues/subscription';
|
|
12
13
|
import type { Invoice, Subscription, TLineItemExpanded } from '../../store/models';
|
|
14
|
+
import { Price } from '../../store/models';
|
|
13
15
|
import {
|
|
14
16
|
ensureInvoicesForSubscriptions,
|
|
15
17
|
ensurePaymentIntent,
|
|
@@ -65,7 +67,9 @@ export default {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
const now = dayjs().unix();
|
|
68
|
-
|
|
70
|
+
// Expand line_items to include full price and upsell_price objects
|
|
71
|
+
// This is critical for getSubscriptionCreateSetup to correctly use upsell_price when calculating authorization amount
|
|
72
|
+
const items = await Price.expand(checkoutSession.line_items);
|
|
69
73
|
const { trialEnd, trialInDays } = getSubscriptionTrialSetup(
|
|
70
74
|
checkoutSession.subscription_data as any,
|
|
71
75
|
paymentCurrency.id
|
|
@@ -73,12 +77,29 @@ export default {
|
|
|
73
77
|
const trialing = trialInDays > 0 || trialEnd > now;
|
|
74
78
|
const billingThreshold = Number(checkoutSession.subscription_data?.billing_threshold_amount || 0);
|
|
75
79
|
const minStakeAmount = Number(checkoutSession.subscription_data?.min_stake_amount || 0);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
|
|
81
|
+
// Calculate required amount considering slippage_config for dynamic pricing
|
|
82
|
+
// Priority: primarySubscription.slippage_config > checkoutSession.metadata.slippage
|
|
83
|
+
const slippageConfig =
|
|
84
|
+
primarySubscription?.slippage_config || normalizeSlippageConfigFromMetadata(checkoutSession.metadata);
|
|
85
|
+
let requiredAmount: string;
|
|
86
|
+
if (slippageConfig?.min_acceptable_rate) {
|
|
87
|
+
// Use slippage_config for precise calculation
|
|
88
|
+
const slippageOptions: SlippageOptions = {
|
|
89
|
+
percent: slippageConfig.percent ?? 0.5,
|
|
90
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
91
|
+
currencyDecimal: paymentCurrency.decimal,
|
|
92
|
+
};
|
|
93
|
+
const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, trialInDays, trialEnd, slippageOptions);
|
|
94
|
+
requiredAmount = setup.amount.setup;
|
|
95
|
+
} else {
|
|
96
|
+
requiredAmount = await getFastCheckoutAmount({
|
|
97
|
+
items,
|
|
98
|
+
mode: checkoutSession.mode,
|
|
99
|
+
currencyId: paymentCurrency.id,
|
|
100
|
+
trialing,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
82
103
|
const claimsList: any[] = [];
|
|
83
104
|
|
|
84
105
|
const allSubscriptionIds = subscriptions.map((sub) => sub.id);
|
|
@@ -88,7 +109,7 @@ export default {
|
|
|
88
109
|
paymentMethod,
|
|
89
110
|
paymentCurrency,
|
|
90
111
|
userDid,
|
|
91
|
-
amount:
|
|
112
|
+
amount: requiredAmount,
|
|
92
113
|
});
|
|
93
114
|
|
|
94
115
|
// if we can complete purchase without any wallet interaction
|
|
@@ -111,6 +132,7 @@ export default {
|
|
|
111
132
|
billingThreshold: Math.max(minStakeAmount, billingThreshold),
|
|
112
133
|
items,
|
|
113
134
|
requiredStake,
|
|
135
|
+
slippageConfig: primarySubscription?.slippage_config || undefined,
|
|
114
136
|
}),
|
|
115
137
|
});
|
|
116
138
|
}
|
|
@@ -157,6 +179,7 @@ export default {
|
|
|
157
179
|
trialing,
|
|
158
180
|
billingThreshold,
|
|
159
181
|
items,
|
|
182
|
+
slippageConfig: primarySubscription?.slippage_config || undefined,
|
|
160
183
|
}),
|
|
161
184
|
});
|
|
162
185
|
|
|
@@ -9,6 +9,7 @@ import logger from '../libs/logger';
|
|
|
9
9
|
import { authenticate } from '../libs/security';
|
|
10
10
|
import {
|
|
11
11
|
AutoRechargeConfig,
|
|
12
|
+
ChainType,
|
|
12
13
|
CreditGrant,
|
|
13
14
|
Customer,
|
|
14
15
|
Invoice,
|
|
@@ -26,6 +27,9 @@ import { blocklet } from '../libs/auth';
|
|
|
26
27
|
import { formatMetadata } from '../libs/util';
|
|
27
28
|
import { getPriceUintAmountByCurrency } from '../libs/price';
|
|
28
29
|
import { checkTokenBalance } from '../libs/payment';
|
|
30
|
+
import { getExchangeRateService } from '../libs/exchange-rate/service';
|
|
31
|
+
import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
|
|
32
|
+
import { isRateBelowMinAcceptableRate } from '../libs/slippage';
|
|
29
33
|
import { trimDecimals } from '../libs/math-utils';
|
|
30
34
|
import { systemMaxPendingAmount } from '../libs/env';
|
|
31
35
|
|
|
@@ -546,11 +550,106 @@ router.get('/verify-availability', authMine, async (req, res) => {
|
|
|
546
550
|
}
|
|
547
551
|
}
|
|
548
552
|
|
|
553
|
+
// 7. Check dynamic pricing constraints
|
|
554
|
+
const isDynamicPricing = config.price.pricing_type === 'dynamic';
|
|
555
|
+
let exchangeRateInfo: { rate: string; timestamp_ms: number; degraded?: boolean } | null = null;
|
|
556
|
+
let slippageCheck: { passed: boolean; reason?: string } | null = null;
|
|
557
|
+
let canCoverPending = true;
|
|
558
|
+
|
|
559
|
+
if (isDynamicPricing) {
|
|
560
|
+
// 7a. Check if exchange rate is available
|
|
561
|
+
const methodType = config.paymentMethod.type as ChainType;
|
|
562
|
+
if (!hasTokenAddress(config.rechargeCurrency.symbol, methodType)) {
|
|
563
|
+
return res.json({
|
|
564
|
+
can_continue: false,
|
|
565
|
+
reason: 'exchange_rate_not_supported',
|
|
566
|
+
is_dynamic_pricing: true,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 7b. Get current exchange rate
|
|
571
|
+
try {
|
|
572
|
+
const exchangeRateService = getExchangeRateService();
|
|
573
|
+
const rateSymbol = getExchangeRateSymbol(config.rechargeCurrency.symbol, methodType);
|
|
574
|
+
const rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
575
|
+
exchangeRateInfo = {
|
|
576
|
+
rate: rateResult.rate,
|
|
577
|
+
timestamp_ms: rateResult.timestamp_ms,
|
|
578
|
+
degraded: rateResult.degraded,
|
|
579
|
+
};
|
|
580
|
+
} catch (err: any) {
|
|
581
|
+
logger.warn('Failed to fetch exchange rate for auto-recharge check', {
|
|
582
|
+
error: err.message,
|
|
583
|
+
symbol: config.rechargeCurrency.symbol,
|
|
584
|
+
});
|
|
585
|
+
return res.json({
|
|
586
|
+
can_continue: false,
|
|
587
|
+
reason: 'exchange_rate_fetch_failed',
|
|
588
|
+
is_dynamic_pricing: true,
|
|
589
|
+
error: err.message,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 7c. Check slippage if config exists
|
|
594
|
+
if (config.slippage_config?.min_acceptable_rate && exchangeRateInfo) {
|
|
595
|
+
const rateBelowMin = isRateBelowMinAcceptableRate(
|
|
596
|
+
exchangeRateInfo.rate,
|
|
597
|
+
config.slippage_config.min_acceptable_rate
|
|
598
|
+
);
|
|
599
|
+
slippageCheck = {
|
|
600
|
+
passed: !rateBelowMin,
|
|
601
|
+
reason: rateBelowMin ? 'rate_below_min_acceptable' : undefined,
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
if (rateBelowMin) {
|
|
605
|
+
return res.json({
|
|
606
|
+
can_continue: false,
|
|
607
|
+
reason: 'slippage_exceeded',
|
|
608
|
+
is_dynamic_pricing: true,
|
|
609
|
+
exchange_rate: exchangeRateInfo,
|
|
610
|
+
slippage_check: slippageCheck,
|
|
611
|
+
slippage_config: {
|
|
612
|
+
min_acceptable_rate: config.slippage_config.min_acceptable_rate,
|
|
613
|
+
percent: config.slippage_config.percent,
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 7d. For dynamic pricing, check if single recharge can cover pending amount
|
|
620
|
+
if (pendingAmountBN.gt(new BN(0))) {
|
|
621
|
+
// Calculate credit amount from single recharge
|
|
622
|
+
const creditConfig = config.price.metadata?.credit_config as { credit_amount?: string | number } | undefined;
|
|
623
|
+
const creditAmount = creditConfig?.credit_amount
|
|
624
|
+
? new BN(String(creditConfig.credit_amount)).mul(new BN(config.quantity ?? 1))
|
|
625
|
+
: new BN(0);
|
|
626
|
+
|
|
627
|
+
// Convert pending amount to credit currency for comparison
|
|
628
|
+
canCoverPending = creditAmount.gte(pendingAmountBN);
|
|
629
|
+
|
|
630
|
+
if (!canCoverPending) {
|
|
631
|
+
return res.json({
|
|
632
|
+
can_continue: false,
|
|
633
|
+
reason: 'cannot_cover_pending_single_recharge',
|
|
634
|
+
is_dynamic_pricing: true,
|
|
635
|
+
exchange_rate: exchangeRateInfo,
|
|
636
|
+
slippage_check: slippageCheck,
|
|
637
|
+
pending_amount: pendingAmount,
|
|
638
|
+
single_recharge_credit: creditAmount.toString(),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
549
644
|
return res.json({
|
|
550
645
|
can_continue: true,
|
|
551
646
|
payment_account_sufficient: true,
|
|
552
647
|
payment_account_balance: balanceResult?.token?.balance || '0',
|
|
553
648
|
pending_amount: pendingAmount,
|
|
649
|
+
is_dynamic_pricing: isDynamicPricing,
|
|
650
|
+
...(exchangeRateInfo && { exchange_rate: exchangeRateInfo }),
|
|
651
|
+
...(slippageCheck && { slippage_check: slippageCheck }),
|
|
652
|
+
can_cover_pending: canCoverPending,
|
|
554
653
|
});
|
|
555
654
|
} catch (err: any) {
|
|
556
655
|
logger.error('check auto recharge failed', {
|