payment-kit 1.24.4 → 1.25.0
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 +1 -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/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
|
@@ -53,6 +53,7 @@ export default {
|
|
|
53
53
|
billingThreshold: 0,
|
|
54
54
|
items: expandedItems,
|
|
55
55
|
requiredStake: false,
|
|
56
|
+
slippageConfig: autoRechargeConfig.slippage_config || undefined,
|
|
56
57
|
}),
|
|
57
58
|
});
|
|
58
59
|
|
|
@@ -79,6 +80,7 @@ export default {
|
|
|
79
80
|
billingThreshold: 0,
|
|
80
81
|
items: expandedItems,
|
|
81
82
|
requiredStake: false,
|
|
83
|
+
slippageConfig: autoRechargeConfig.slippage_config || undefined,
|
|
82
84
|
}),
|
|
83
85
|
});
|
|
84
86
|
|
|
@@ -48,6 +48,7 @@ export default {
|
|
|
48
48
|
billingThreshold,
|
|
49
49
|
items,
|
|
50
50
|
requiredStake: false,
|
|
51
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
51
52
|
}),
|
|
52
53
|
});
|
|
53
54
|
return claimsList;
|
|
@@ -70,6 +71,7 @@ export default {
|
|
|
70
71
|
trialing,
|
|
71
72
|
billingThreshold,
|
|
72
73
|
items,
|
|
74
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
73
75
|
}),
|
|
74
76
|
});
|
|
75
77
|
|
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
import { ensureStakeInvoice } from '../../libs/invoice';
|
|
14
14
|
import { EVM_CHAIN_TYPES } from '../../libs/constants';
|
|
15
15
|
import logger from '../../libs/logger';
|
|
16
|
-
import { getFastCheckoutAmount } from '../../libs/session';
|
|
16
|
+
import { getFastCheckoutAmount, getSubscriptionCreateSetup, SlippageOptions } from '../../libs/session';
|
|
17
17
|
import { isDelegationSufficientForPayment } from '../../libs/payment';
|
|
18
|
+
import { getQuoteService } from '../../libs/quote-service';
|
|
18
19
|
|
|
19
20
|
export default {
|
|
20
21
|
action: 'change-payment',
|
|
@@ -35,19 +36,34 @@ export default {
|
|
|
35
36
|
const items = subscription!.items as TLineItemExpanded[];
|
|
36
37
|
const trialing = true;
|
|
37
38
|
const billingThreshold = Number(subscription.billing_thresholds?.amount_gte || 0);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
|
|
40
|
+
// Calculate required amount considering slippage_config for dynamic pricing
|
|
41
|
+
const slippageConfig = subscription?.slippage_config;
|
|
42
|
+
let requiredAmount: string;
|
|
43
|
+
if (slippageConfig?.min_acceptable_rate) {
|
|
44
|
+
// Use slippage_config for precise calculation
|
|
45
|
+
const slippageOptions: SlippageOptions = {
|
|
46
|
+
percent: slippageConfig.percent ?? 0.5,
|
|
47
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
48
|
+
currencyDecimal: paymentCurrency.decimal,
|
|
49
|
+
};
|
|
50
|
+
const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
|
|
51
|
+
requiredAmount = setup.amount.setup;
|
|
52
|
+
} else {
|
|
53
|
+
requiredAmount = await getFastCheckoutAmount({
|
|
54
|
+
items,
|
|
55
|
+
mode: 'subscription',
|
|
56
|
+
currencyId: paymentCurrency.id,
|
|
57
|
+
trialing: false,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
44
60
|
|
|
45
61
|
if (paymentMethod.type === 'arcblock') {
|
|
46
62
|
const delegation = await isDelegationSufficientForPayment({
|
|
47
63
|
paymentMethod,
|
|
48
64
|
paymentCurrency,
|
|
49
65
|
userDid,
|
|
50
|
-
amount:
|
|
66
|
+
amount: requiredAmount,
|
|
51
67
|
});
|
|
52
68
|
const needDelegation = delegation.sufficient === false;
|
|
53
69
|
const requiredStake = !subscription.billing_thresholds?.no_stake;
|
|
@@ -65,6 +81,7 @@ export default {
|
|
|
65
81
|
billingThreshold,
|
|
66
82
|
items,
|
|
67
83
|
requiredStake,
|
|
84
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
68
85
|
}),
|
|
69
86
|
});
|
|
70
87
|
}
|
|
@@ -101,6 +118,7 @@ export default {
|
|
|
101
118
|
trialing,
|
|
102
119
|
billingThreshold,
|
|
103
120
|
items,
|
|
121
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
104
122
|
}),
|
|
105
123
|
});
|
|
106
124
|
|
|
@@ -183,6 +201,41 @@ export default {
|
|
|
183
201
|
},
|
|
184
202
|
});
|
|
185
203
|
|
|
204
|
+
// Create quotes for dynamic pricing items after payment method change
|
|
205
|
+
// @ts-ignore
|
|
206
|
+
const items = subscription!.items as TLineItemExpanded[];
|
|
207
|
+
const dynamicItems = items.filter(
|
|
208
|
+
(item: TLineItemExpanded) => (item.upsell_price || item.price)?.pricing_type === 'dynamic'
|
|
209
|
+
);
|
|
210
|
+
if (dynamicItems.length > 0) {
|
|
211
|
+
const quoteService = getQuoteService();
|
|
212
|
+
for (const item of dynamicItems) {
|
|
213
|
+
try {
|
|
214
|
+
const price = item.upsell_price || item.price;
|
|
215
|
+
// eslint-disable-next-line no-await-in-loop
|
|
216
|
+
await quoteService.getOrCreateQuote({
|
|
217
|
+
price_id: price.id,
|
|
218
|
+
target_currency_id: paymentCurrency.id,
|
|
219
|
+
quantity: item.quantity || 1,
|
|
220
|
+
idempotency_key_salt: `change-payment-${subscription!.id}-${Date.now()}`,
|
|
221
|
+
});
|
|
222
|
+
logger.info('Created quote for dynamic pricing item after payment change', {
|
|
223
|
+
subscriptionId: subscription!.id,
|
|
224
|
+
priceId: price.id,
|
|
225
|
+
currencyId: paymentCurrency.id,
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
logger.error('Failed to create quote for dynamic pricing item after payment change', {
|
|
229
|
+
subscriptionId: subscription!.id,
|
|
230
|
+
priceId: (item.upsell_price || item.price)?.id,
|
|
231
|
+
currencyId: paymentCurrency.id,
|
|
232
|
+
error,
|
|
233
|
+
});
|
|
234
|
+
// Continue with other items even if one fails
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
186
239
|
await Lock.acquire(`${subscription.id}-change-plan`, subscription.current_period_end);
|
|
187
240
|
// update stripe subscription
|
|
188
241
|
await updateStripeSubscriptionAfterChangePayment(setupIntent, subscription);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/ethereum/tx';
|
|
2
2
|
import type { CallbackArgs } from '../../libs/auth';
|
|
3
3
|
import { isDelegationSufficientForPayment } from '../../libs/payment';
|
|
4
|
-
import { getFastCheckoutAmount } from '../../libs/session';
|
|
5
|
-
import { onSubscriptionUpdateConnected } from '../../libs/subscription';
|
|
4
|
+
import { getFastCheckoutAmount, getSubscriptionCreateSetup, type SlippageOptions } from '../../libs/session';
|
|
5
|
+
import { checkRemainingStake, onSubscriptionUpdateConnected } from '../../libs/subscription';
|
|
6
6
|
import { getTxMetadata } from '../../libs/util';
|
|
7
7
|
import { invoiceQueue } from '../../queues/invoice';
|
|
8
8
|
import { addSubscriptionJob, subscriptionQueue } from '../../queues/subscription';
|
|
9
9
|
import type { TLineItemExpanded } from '../../store/models';
|
|
10
|
+
import { Invoice } from '../../store/models/invoice';
|
|
10
11
|
import {
|
|
11
12
|
ensureSubscription,
|
|
12
13
|
executeOcapTransactions,
|
|
@@ -37,23 +38,68 @@ export default {
|
|
|
37
38
|
const items = subscription!.items as TLineItemExpanded[];
|
|
38
39
|
const trialing = false;
|
|
39
40
|
const billingThreshold = Number(subscription!.billing_thresholds?.amount_gte || 0);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
|
|
42
|
+
// Calculate required amount considering slippage_config for dynamic pricing
|
|
43
|
+
const slippageConfig = subscription?.slippage_config;
|
|
44
|
+
let requiredAmount: string;
|
|
45
|
+
if (slippageConfig?.min_acceptable_rate) {
|
|
46
|
+
// Use slippage_config for precise calculation
|
|
47
|
+
const slippageOptions: SlippageOptions = {
|
|
48
|
+
percent: slippageConfig.percent ?? 0.5,
|
|
49
|
+
minAcceptableRate: slippageConfig.min_acceptable_rate,
|
|
50
|
+
currencyDecimal: paymentCurrency.decimal,
|
|
51
|
+
};
|
|
52
|
+
const setup = getSubscriptionCreateSetup(items, paymentCurrency.id, 0, 0, slippageOptions);
|
|
53
|
+
requiredAmount = setup.amount.setup;
|
|
54
|
+
} else {
|
|
55
|
+
requiredAmount = await getFastCheckoutAmount({
|
|
56
|
+
items,
|
|
57
|
+
mode: 'subscription',
|
|
58
|
+
currencyId: paymentCurrency.id,
|
|
59
|
+
trialing: false,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
46
62
|
|
|
47
63
|
if (paymentMethod.type === 'arcblock') {
|
|
64
|
+
const requiredStake = !subscription!.billing_thresholds?.no_stake;
|
|
65
|
+
|
|
66
|
+
// 1. First check if stake is needed and if there's a valid existing stake
|
|
67
|
+
let needsNewStake = false;
|
|
68
|
+
let existingStakeAddress: string | undefined;
|
|
69
|
+
if (requiredStake) {
|
|
70
|
+
existingStakeAddress = subscription?.payment_details?.arcblock?.staking?.address;
|
|
71
|
+
if (existingStakeAddress) {
|
|
72
|
+
const stakeCheck = await checkRemainingStake(paymentMethod, paymentCurrency, existingStakeAddress, '1');
|
|
73
|
+
needsNewStake = !stakeCheck.enough;
|
|
74
|
+
logger.info('Change plan: checking existing stake', {
|
|
75
|
+
subscriptionId: subscription!.id,
|
|
76
|
+
existingStakeAddress,
|
|
77
|
+
hasValidExistingStake: stakeCheck.enough,
|
|
78
|
+
needsNewStake,
|
|
79
|
+
stakeCheck,
|
|
80
|
+
});
|
|
81
|
+
} else {
|
|
82
|
+
needsNewStake = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 2. Check if delegation is sufficient
|
|
48
87
|
const delegation = await isDelegationSufficientForPayment({
|
|
49
88
|
paymentMethod,
|
|
50
89
|
paymentCurrency,
|
|
51
90
|
userDid,
|
|
52
|
-
amount:
|
|
91
|
+
amount: requiredAmount,
|
|
53
92
|
});
|
|
54
93
|
|
|
55
|
-
|
|
94
|
+
logger.info('Change plan: delegation check', {
|
|
95
|
+
subscriptionId: subscription!.id,
|
|
96
|
+
delegationSufficient: delegation.sufficient,
|
|
97
|
+
needsNewStake,
|
|
98
|
+
requiredStake,
|
|
99
|
+
});
|
|
56
100
|
|
|
101
|
+
// 3. Add delegation claim if needed
|
|
102
|
+
// Need delegation if: delegation is not sufficient, OR we don't require stake (legacy behavior)
|
|
57
103
|
if (delegation.sufficient === false || !requiredStake) {
|
|
58
104
|
claimsList.push({
|
|
59
105
|
signature: await getDelegationTxClaim({
|
|
@@ -67,11 +113,13 @@ export default {
|
|
|
67
113
|
trialing,
|
|
68
114
|
billingThreshold,
|
|
69
115
|
items,
|
|
116
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
70
117
|
}),
|
|
71
118
|
});
|
|
72
119
|
}
|
|
73
120
|
|
|
74
|
-
if
|
|
121
|
+
// 4. Add stake claim only if we need a new stake
|
|
122
|
+
if (needsNewStake) {
|
|
75
123
|
claimsList.push({
|
|
76
124
|
prepareTx: await getStakeTxClaim({
|
|
77
125
|
userDid,
|
|
@@ -82,10 +130,22 @@ export default {
|
|
|
82
130
|
subscription: subscription!,
|
|
83
131
|
}),
|
|
84
132
|
});
|
|
133
|
+
} else if (requiredStake && existingStakeAddress) {
|
|
134
|
+
logger.info('Change plan: skipping stake request, reusing existing stake', {
|
|
135
|
+
subscriptionId: subscription!.id,
|
|
136
|
+
existingStakeAddress,
|
|
137
|
+
});
|
|
85
138
|
}
|
|
86
139
|
|
|
140
|
+
// 5. If no claims needed, this is an error - API should have prevented entering connect flow
|
|
87
141
|
if (claimsList.length === 0) {
|
|
88
|
-
|
|
142
|
+
logger.warn('Change plan: no claims needed but entered connect flow', {
|
|
143
|
+
subscriptionId: subscription!.id,
|
|
144
|
+
delegationSufficient: delegation.sufficient,
|
|
145
|
+
needsNewStake,
|
|
146
|
+
requiredStake,
|
|
147
|
+
});
|
|
148
|
+
throw new Error('No authorization needed for this subscription update. Please try again.');
|
|
89
149
|
}
|
|
90
150
|
|
|
91
151
|
return claimsList;
|
|
@@ -108,6 +168,7 @@ export default {
|
|
|
108
168
|
trialing,
|
|
109
169
|
billingThreshold,
|
|
110
170
|
items,
|
|
171
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
111
172
|
}),
|
|
112
173
|
});
|
|
113
174
|
|
|
@@ -119,8 +180,46 @@ export default {
|
|
|
119
180
|
|
|
120
181
|
onAuth: async ({ request, userDid, userPk, claims, extraParams, updateSession, step }: CallbackArgs) => {
|
|
121
182
|
const { subscriptionId } = extraParams;
|
|
122
|
-
const {
|
|
123
|
-
|
|
183
|
+
const {
|
|
184
|
+
invoice: latestInvoice,
|
|
185
|
+
paymentMethod,
|
|
186
|
+
subscription,
|
|
187
|
+
paymentCurrency,
|
|
188
|
+
customer,
|
|
189
|
+
} = await ensureSubscription(subscriptionId);
|
|
190
|
+
|
|
191
|
+
// For plan change, use the pending_update invoice if exists, not the latest_invoice_id
|
|
192
|
+
// The plan change invoice is created in subscriptions.ts and stored in pending_update.updates.latest_invoice_id
|
|
193
|
+
const pendingInvoiceId = subscription?.pending_update?.updates?.latest_invoice_id;
|
|
194
|
+
let invoice = latestInvoice;
|
|
195
|
+
if (pendingInvoiceId && pendingInvoiceId !== latestInvoice?.id) {
|
|
196
|
+
const pendingInvoice = await Invoice.findByPk(pendingInvoiceId);
|
|
197
|
+
if (pendingInvoice) {
|
|
198
|
+
invoice = pendingInvoice;
|
|
199
|
+
logger.info('Change plan: using pending_update invoice instead of latest_invoice', {
|
|
200
|
+
subscriptionId,
|
|
201
|
+
pendingInvoiceId,
|
|
202
|
+
latestInvoiceId: latestInvoice?.id,
|
|
203
|
+
});
|
|
204
|
+
} else {
|
|
205
|
+
logger.error('Change plan: pending invoice not found in database', {
|
|
206
|
+
subscriptionId,
|
|
207
|
+
pendingInvoiceId,
|
|
208
|
+
latestInvoiceId: latestInvoice?.id,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate invoice exists - change-plan should always have an invoice to process
|
|
214
|
+
if (!invoice) {
|
|
215
|
+
logger.error('Change plan: no invoice found for subscription update', {
|
|
216
|
+
subscriptionId,
|
|
217
|
+
pendingInvoiceId,
|
|
218
|
+
latestInvoiceId: latestInvoice?.id,
|
|
219
|
+
pendingUpdate: subscription?.pending_update,
|
|
220
|
+
});
|
|
221
|
+
throw new Error('Invoice not found for subscription plan change. Please try again.');
|
|
222
|
+
}
|
|
124
223
|
|
|
125
224
|
const result = request?.context?.store?.result || [];
|
|
126
225
|
result.push({
|
|
@@ -133,10 +232,22 @@ export default {
|
|
|
133
232
|
|
|
134
233
|
const requiredStake = !subscription!.billing_thresholds?.no_stake;
|
|
135
234
|
|
|
235
|
+
// Check if we have a valid existing stake (which means no new staking claim was requested)
|
|
236
|
+
let hasValidExistingStake = false;
|
|
237
|
+
if (requiredStake && paymentMethod.type === 'arcblock') {
|
|
238
|
+
const existingStakeAddress = subscription?.payment_details?.arcblock?.staking?.address;
|
|
239
|
+
if (existingStakeAddress) {
|
|
240
|
+
const stakeCheck = await checkRemainingStake(paymentMethod, paymentCurrency, existingStakeAddress, '1');
|
|
241
|
+
hasValidExistingStake = stakeCheck.enough;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
136
245
|
// 判断是否为最后一步
|
|
246
|
+
// Final step if: we have a staking claim, OR stake is not required, OR we're reusing existing stake
|
|
137
247
|
const staking = result.find((x: any) => x.claim?.type === 'prepareTx' && x.claim?.meta?.purpose === 'staking');
|
|
138
248
|
const isFinalStep =
|
|
139
|
-
(paymentMethod.type === 'arcblock' && (staking || !requiredStake)) ||
|
|
249
|
+
(paymentMethod.type === 'arcblock' && (staking || !requiredStake || hasValidExistingStake)) ||
|
|
250
|
+
paymentMethod.type !== 'arcblock';
|
|
140
251
|
|
|
141
252
|
if (!isFinalStep) {
|
|
142
253
|
await updateSession({
|
|
@@ -172,13 +283,46 @@ export default {
|
|
|
172
283
|
await subscription?.update({ payment_details: { [paymentMethod.type]: paymentDetails } });
|
|
173
284
|
|
|
174
285
|
if (invoice) {
|
|
175
|
-
|
|
176
|
-
|
|
286
|
+
// Update invoice status and payment_settings before processing
|
|
287
|
+
const invoiceUpdates: any = {
|
|
288
|
+
payment_settings: {
|
|
289
|
+
payment_method_types: [paymentMethod.type],
|
|
290
|
+
payment_method_options: {
|
|
291
|
+
[paymentMethod.type]: { payer: userDid },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
if (invoice.status === 'uncollectible' || invoice.status === 'draft') {
|
|
296
|
+
invoiceUpdates.status = 'open';
|
|
177
297
|
}
|
|
298
|
+
await invoice.update(invoiceUpdates);
|
|
299
|
+
logger.info('Change plan: invoice updated before queue processing', {
|
|
300
|
+
invoiceId: invoice.id,
|
|
301
|
+
status: invoiceUpdates.status || invoice.status,
|
|
302
|
+
paymentMethodType: paymentMethod.type,
|
|
303
|
+
});
|
|
304
|
+
|
|
178
305
|
await invoiceQueue.pushAndWait({
|
|
179
306
|
id: invoice.id,
|
|
180
307
|
job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
|
|
181
308
|
});
|
|
309
|
+
|
|
310
|
+
// Reload invoice to check processing result
|
|
311
|
+
await invoice.reload();
|
|
312
|
+
logger.info('Change plan: invoice queue processing completed', {
|
|
313
|
+
invoiceId: invoice.id,
|
|
314
|
+
status: invoice.status,
|
|
315
|
+
paymentIntentId: invoice.payment_intent_id,
|
|
316
|
+
paid: invoice.paid,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (!invoice.payment_intent_id && invoice.amount_remaining !== '0') {
|
|
320
|
+
logger.error('Change plan: invoice processed but no paymentIntent created', {
|
|
321
|
+
invoiceId: invoice.id,
|
|
322
|
+
status: invoice.status,
|
|
323
|
+
amountRemaining: invoice.amount_remaining,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
182
326
|
}
|
|
183
327
|
if (subscription) {
|
|
184
328
|
await onSubscriptionUpdateConnected(subscriptionId);
|
|
@@ -22,13 +22,16 @@ export default {
|
|
|
22
22
|
authPrincipal: false,
|
|
23
23
|
claims: {
|
|
24
24
|
authPrincipal: async ({ extraParams }: CallbackArgs) => {
|
|
25
|
-
const {
|
|
25
|
+
const { allowCreatePI } = extraParams;
|
|
26
|
+
const { paymentMethod } = await ensureInvoiceForCollect(extraParams.invoiceId, { allowCreatePI });
|
|
26
27
|
return getAuthPrincipalClaim(paymentMethod, 'pay');
|
|
27
28
|
},
|
|
28
29
|
},
|
|
29
30
|
onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
|
|
30
|
-
const { invoiceId, action } = extraParams;
|
|
31
|
-
const { invoice, paymentIntent, paymentCurrency, paymentMethod } = await ensureInvoiceForCollect(invoiceId
|
|
31
|
+
const { invoiceId, action, allowCreatePI } = extraParams;
|
|
32
|
+
const { invoice, paymentIntent, paymentCurrency, paymentMethod } = await ensureInvoiceForCollect(invoiceId, {
|
|
33
|
+
allowCreatePI,
|
|
34
|
+
});
|
|
32
35
|
|
|
33
36
|
if (paymentMethod.type === 'arcblock') {
|
|
34
37
|
const tokens = [{ address: paymentCurrency.contract as string, value: invoice.amount_due }];
|
|
@@ -69,6 +72,7 @@ export default {
|
|
|
69
72
|
paymentMethod,
|
|
70
73
|
items,
|
|
71
74
|
requiredStake: false,
|
|
75
|
+
slippageConfig: subscription?.slippage_config || undefined,
|
|
72
76
|
});
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -100,9 +104,8 @@ export default {
|
|
|
100
104
|
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
101
105
|
},
|
|
102
106
|
onAuth: async ({ request, userDid, claims, extraParams }: CallbackArgs) => {
|
|
103
|
-
const { invoiceId } = extraParams;
|
|
104
|
-
const { invoice, paymentIntent, paymentMethod } = await ensureInvoiceForCollect(invoiceId);
|
|
105
|
-
|
|
107
|
+
const { invoiceId, allowCreatePI } = extraParams;
|
|
108
|
+
const { invoice, paymentIntent, paymentMethod } = await ensureInvoiceForCollect(invoiceId, { allowCreatePI });
|
|
106
109
|
const afterTxExecution = async (paymentDetails: any) => {
|
|
107
110
|
await paymentIntent.update({
|
|
108
111
|
status: 'succeeded',
|
|
@@ -13,6 +13,7 @@ import { handlePaymentSucceed } from '../../queues/payment';
|
|
|
13
13
|
import { ensureInvoiceForCheckout, ensurePaymentIntent, getAuthPrincipalClaim } from './shared';
|
|
14
14
|
import { EVMChainType } from '../../store/models';
|
|
15
15
|
import { EVM_CHAIN_TYPES } from '../../libs/constants';
|
|
16
|
+
import { validateQuoteForPayment, handleExpiredQuotePayment } from '../../libs/quote-validation';
|
|
16
17
|
|
|
17
18
|
export default {
|
|
18
19
|
action: 'payment',
|
|
@@ -98,6 +99,31 @@ export default {
|
|
|
98
99
|
|
|
99
100
|
await ensureInvoiceForCheckout({ checkoutSession, customer, paymentIntent });
|
|
100
101
|
|
|
102
|
+
const preQuoteValidation = await validateQuoteForPayment({
|
|
103
|
+
checkoutSessionId: checkoutSession?.id,
|
|
104
|
+
invoiceId: paymentIntent.invoice_id,
|
|
105
|
+
amount: paymentIntent.amount,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (!preQuoteValidation.valid) {
|
|
109
|
+
const errorCode =
|
|
110
|
+
preQuoteValidation.action === 'require_manual_review' ? 'quote_expired' : 'quote_validation_failed';
|
|
111
|
+
logger.error('Payment blocked due to quote validation before submit', {
|
|
112
|
+
paymentIntentId: paymentIntent.id,
|
|
113
|
+
quoteId: preQuoteValidation.quoteId,
|
|
114
|
+
reason: preQuoteValidation.reason,
|
|
115
|
+
});
|
|
116
|
+
await paymentIntent.update({
|
|
117
|
+
status: 'requires_action',
|
|
118
|
+
last_payment_error: {
|
|
119
|
+
type: 'validation_error',
|
|
120
|
+
code: errorCode,
|
|
121
|
+
message: preQuoteValidation.reason || 'Quote validation failed',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
throw new Error(`Payment validation failed: ${preQuoteValidation.reason}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
101
127
|
if (paymentMethod.type === 'arcblock') {
|
|
102
128
|
try {
|
|
103
129
|
await paymentIntent.update({ status: 'processing' });
|
|
@@ -118,6 +144,72 @@ export default {
|
|
|
118
144
|
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
|
|
119
145
|
);
|
|
120
146
|
|
|
147
|
+
const quoteValidation = await validateQuoteForPayment({
|
|
148
|
+
checkoutSessionId: checkoutSession?.id,
|
|
149
|
+
invoiceId: paymentIntent.invoice_id,
|
|
150
|
+
amount: paymentIntent.amount,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!quoteValidation.valid) {
|
|
154
|
+
if (quoteValidation.action === 'reject') {
|
|
155
|
+
// Reject the payment - mark as failed
|
|
156
|
+
logger.error('Payment rejected due to quote validation', {
|
|
157
|
+
paymentIntentId: paymentIntent.id,
|
|
158
|
+
quoteId: quoteValidation.quoteId,
|
|
159
|
+
reason: quoteValidation.reason,
|
|
160
|
+
txHash,
|
|
161
|
+
});
|
|
162
|
+
await paymentIntent.update({
|
|
163
|
+
status: 'requires_action',
|
|
164
|
+
last_payment_error: {
|
|
165
|
+
type: 'validation_error',
|
|
166
|
+
code: 'quote_validation_failed',
|
|
167
|
+
message: quoteValidation.reason || 'Quote validation failed',
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
throw new Error(`Payment validation failed: ${quoteValidation.reason}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (quoteValidation.action === 'require_manual_review') {
|
|
174
|
+
// Hold for manual review
|
|
175
|
+
logger.error('Payment requires manual review due to expired quote', {
|
|
176
|
+
paymentIntentId: paymentIntent.id,
|
|
177
|
+
quoteId: quoteValidation.quoteId,
|
|
178
|
+
reason: quoteValidation.reason,
|
|
179
|
+
txHash,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await handleExpiredQuotePayment({
|
|
183
|
+
quoteId: quoteValidation.quoteId!,
|
|
184
|
+
checkoutSessionId: checkoutSession?.id,
|
|
185
|
+
invoiceId: paymentIntent.invoice_id,
|
|
186
|
+
paymentIntentId: paymentIntent.id,
|
|
187
|
+
amount: paymentIntent.amount,
|
|
188
|
+
currencyId: paymentIntent.currency_id,
|
|
189
|
+
txHash,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await paymentIntent.update({
|
|
193
|
+
status: 'requires_action',
|
|
194
|
+
last_payment_error: {
|
|
195
|
+
type: 'validation_error',
|
|
196
|
+
code: 'quote_expired',
|
|
197
|
+
message: quoteValidation.reason || 'Quote expired - manual review required',
|
|
198
|
+
},
|
|
199
|
+
metadata: {
|
|
200
|
+
...paymentIntent.metadata,
|
|
201
|
+
quote_validation: {
|
|
202
|
+
status: 'expired',
|
|
203
|
+
quoteId: quoteValidation.quoteId,
|
|
204
|
+
reason: quoteValidation.reason,
|
|
205
|
+
held_for_review: true,
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
throw new Error(`Payment held for manual review: ${quoteValidation.reason}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
121
213
|
await paymentIntent.update({
|
|
122
214
|
status: 'succeeded',
|
|
123
215
|
amount_received: paymentIntent.amount,
|
|
@@ -153,6 +245,71 @@ export default {
|
|
|
153
245
|
paymentMethod.confirmation.block
|
|
154
246
|
)
|
|
155
247
|
.then(async () => {
|
|
248
|
+
const quoteValidation = await validateQuoteForPayment({
|
|
249
|
+
checkoutSessionId: checkoutSession?.id,
|
|
250
|
+
invoiceId: paymentIntent.invoice_id,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!quoteValidation.valid) {
|
|
254
|
+
if (quoteValidation.action === 'reject') {
|
|
255
|
+
// Reject the payment - mark as failed
|
|
256
|
+
logger.error('Payment rejected due to quote validation', {
|
|
257
|
+
paymentIntentId: paymentIntent.id,
|
|
258
|
+
quoteId: quoteValidation.quoteId,
|
|
259
|
+
reason: quoteValidation.reason,
|
|
260
|
+
txHash: paymentDetails.tx_hash,
|
|
261
|
+
});
|
|
262
|
+
await paymentIntent.update({
|
|
263
|
+
status: 'requires_action',
|
|
264
|
+
last_payment_error: {
|
|
265
|
+
type: 'validation_error',
|
|
266
|
+
code: 'quote_validation_failed',
|
|
267
|
+
message: quoteValidation.reason || 'Quote validation failed',
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (quoteValidation.action === 'require_manual_review') {
|
|
274
|
+
// Hold for manual review
|
|
275
|
+
logger.error('Payment requires manual review due to expired quote', {
|
|
276
|
+
paymentIntentId: paymentIntent.id,
|
|
277
|
+
quoteId: quoteValidation.quoteId,
|
|
278
|
+
reason: quoteValidation.reason,
|
|
279
|
+
txHash: paymentDetails.tx_hash,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await handleExpiredQuotePayment({
|
|
283
|
+
quoteId: quoteValidation.quoteId!,
|
|
284
|
+
checkoutSessionId: checkoutSession?.id,
|
|
285
|
+
invoiceId: paymentIntent.invoice_id,
|
|
286
|
+
paymentIntentId: paymentIntent.id,
|
|
287
|
+
amount: paymentIntent.amount,
|
|
288
|
+
currencyId: paymentIntent.currency_id,
|
|
289
|
+
txHash: paymentDetails.tx_hash,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await paymentIntent.update({
|
|
293
|
+
status: 'requires_action',
|
|
294
|
+
last_payment_error: {
|
|
295
|
+
type: 'validation_error',
|
|
296
|
+
code: 'quote_expired',
|
|
297
|
+
message: quoteValidation.reason || 'Quote expired - manual review required',
|
|
298
|
+
},
|
|
299
|
+
metadata: {
|
|
300
|
+
...paymentIntent.metadata,
|
|
301
|
+
quote_validation: {
|
|
302
|
+
status: 'expired',
|
|
303
|
+
quoteId: quoteValidation.quoteId,
|
|
304
|
+
reason: quoteValidation.reason,
|
|
305
|
+
held_for_review: true,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
156
313
|
await paymentIntent.update({
|
|
157
314
|
status: 'succeeded',
|
|
158
315
|
amount_received: paymentIntent.amount,
|