payment-kit 1.24.3 → 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/crons/overdue-detection.ts +10 -1
- 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 +190 -3
- 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/meter-events.ts +3 -0
- 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
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
|
|
4
|
+
import { ChainType, PaymentCurrency, PaymentMethod, Price, PriceQuote } from '../store/models';
|
|
5
|
+
import type { CreateQuoteInput } from '../store/models/price-quote';
|
|
6
|
+
import { QuoteMetadata } from '../store/models/types';
|
|
7
|
+
import { getExchangeRateService, validateForSubmit } from './exchange-rate';
|
|
8
|
+
import logger from './logger';
|
|
9
|
+
import { getExchangeRateSymbol } from './exchange-rate/token-address-mapping';
|
|
10
|
+
import { trimDecimals, limitTokenPrecision } from './math-utils';
|
|
11
|
+
import { NonRetryableError } from './error';
|
|
12
|
+
|
|
13
|
+
export interface CreateQuoteParams {
|
|
14
|
+
price_id: string;
|
|
15
|
+
session_id?: string;
|
|
16
|
+
invoice_id?: string;
|
|
17
|
+
target_currency_id: string;
|
|
18
|
+
quantity: number;
|
|
19
|
+
idempotency_key_salt?: string;
|
|
20
|
+
slippage_percent?: number;
|
|
21
|
+
/** Minimum acceptable exchange rate (USD per token). Takes priority over slippage_percent for max_payable_token calculation. */
|
|
22
|
+
min_acceptable_rate?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface QuoteResponse {
|
|
26
|
+
quote: PriceQuote;
|
|
27
|
+
computed_unit_amount: string;
|
|
28
|
+
expires_at: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RateResult {
|
|
32
|
+
rate: string;
|
|
33
|
+
provider_id: string;
|
|
34
|
+
provider_name: string;
|
|
35
|
+
timestamp_ms: number;
|
|
36
|
+
degraded: boolean;
|
|
37
|
+
degraded_reason?: string | null;
|
|
38
|
+
providers?: Array<{
|
|
39
|
+
provider_id: string;
|
|
40
|
+
provider_name: string;
|
|
41
|
+
rate: string;
|
|
42
|
+
timestamp_ms: number;
|
|
43
|
+
degraded: boolean;
|
|
44
|
+
degraded_reason?: string;
|
|
45
|
+
}>;
|
|
46
|
+
consensus_method?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_SLIPPAGE_PERCENT = 0.5;
|
|
50
|
+
|
|
51
|
+
const normalizeSlippagePercent = (value?: number | string | null): number => {
|
|
52
|
+
if (value === undefined || value === null) {
|
|
53
|
+
return DEFAULT_SLIPPAGE_PERCENT;
|
|
54
|
+
}
|
|
55
|
+
const normalized = typeof value === 'string' ? Number(value) : value;
|
|
56
|
+
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
57
|
+
return DEFAULT_SLIPPAGE_PERCENT;
|
|
58
|
+
}
|
|
59
|
+
return normalized;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const resolveConsensusMethod = (rateResult: RateResult): string => {
|
|
63
|
+
if (rateResult.consensus_method) {
|
|
64
|
+
return rateResult.consensus_method;
|
|
65
|
+
}
|
|
66
|
+
if (rateResult.providers && rateResult.providers.length > 1) {
|
|
67
|
+
return 'median';
|
|
68
|
+
}
|
|
69
|
+
return 'single';
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function isUniqueConstraintError(error: any): boolean {
|
|
73
|
+
if (!error) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (error.name === 'SequelizeUniqueConstraintError') {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
const message = String(error.message || '').toLowerCase();
|
|
80
|
+
return message.includes('unique constraint') || message.includes('unique violation');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generate idempotency key for quote
|
|
85
|
+
*/
|
|
86
|
+
function generateIdempotencyKey(params: {
|
|
87
|
+
contextId: string; // session_id or invoice_id
|
|
88
|
+
priceId: string;
|
|
89
|
+
currencyId: string;
|
|
90
|
+
quantity: number;
|
|
91
|
+
baseAmount: string;
|
|
92
|
+
salt?: string;
|
|
93
|
+
}): string {
|
|
94
|
+
const { contextId, priceId, currencyId, quantity, baseAmount, salt } = params;
|
|
95
|
+
const saltPart = salt ? `:${salt}` : '';
|
|
96
|
+
return crypto
|
|
97
|
+
.createHash('sha256')
|
|
98
|
+
.update(`${contextId}:${priceId}:${currencyId}:${quantity}:${baseAmount}${saltPart}`)
|
|
99
|
+
.digest('hex')
|
|
100
|
+
.substring(0, 64);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Quote Service
|
|
105
|
+
*
|
|
106
|
+
* Handles creation and management of price quotes for dynamic pricing
|
|
107
|
+
*/
|
|
108
|
+
export class QuoteService {
|
|
109
|
+
/**
|
|
110
|
+
* Get or create a quote for dynamic pricing
|
|
111
|
+
*/
|
|
112
|
+
async getOrCreateQuote(params: CreateQuoteParams): Promise<QuoteResponse> {
|
|
113
|
+
const {
|
|
114
|
+
price_id: priceId,
|
|
115
|
+
session_id: sessionId,
|
|
116
|
+
invoice_id: invoiceId,
|
|
117
|
+
target_currency_id: targetCurrencyId,
|
|
118
|
+
quantity,
|
|
119
|
+
idempotency_key_salt: idempotencyKeySalt,
|
|
120
|
+
} = params;
|
|
121
|
+
|
|
122
|
+
// Validate params
|
|
123
|
+
if (!priceId || !targetCurrencyId || !quantity) {
|
|
124
|
+
throw new NonRetryableError('INVALID_PARAMS', 'price_id, target_currency_id, and quantity are required');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!sessionId && !invoiceId) {
|
|
128
|
+
throw new NonRetryableError('INVALID_PARAMS', 'Either session_id or invoice_id must be provided');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Load price
|
|
132
|
+
const price = await Price.findByPk(priceId);
|
|
133
|
+
if (!price) {
|
|
134
|
+
throw new NonRetryableError('PRICE_NOT_FOUND', `Price ${priceId} not found`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check if price is dynamic
|
|
138
|
+
if (price.pricing_type !== 'dynamic') {
|
|
139
|
+
throw new NonRetryableError('INVALID_PRICE_TYPE', `Price ${priceId} is not a dynamic price`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!price.base_currency || !price.base_amount) {
|
|
143
|
+
throw new NonRetryableError('INVALID_PRICE_CONFIG', `Price ${priceId} missing base_currency or base_amount`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Load target currency with payment method
|
|
147
|
+
const currency = (await PaymentCurrency.findByPk(targetCurrencyId, {
|
|
148
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
149
|
+
})) as PaymentCurrency & { payment_method: PaymentMethod };
|
|
150
|
+
|
|
151
|
+
if (!currency) {
|
|
152
|
+
throw new NonRetryableError('CURRENCY_NOT_FOUND', `Currency ${targetCurrencyId} not found`);
|
|
153
|
+
}
|
|
154
|
+
if (currency.payment_method?.type === 'stripe') {
|
|
155
|
+
throw new NonRetryableError('CURRENCY_NOT_SUPPORTED', `Currency ${targetCurrencyId} is not supported`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Calculate total base amount using BN for precision
|
|
159
|
+
// USD amounts are stored with 8 decimal places (e.g., "10.50" = $10.50)
|
|
160
|
+
const USD_DECIMALS = 8;
|
|
161
|
+
const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
|
|
162
|
+
const quantityBN = new BN(quantity);
|
|
163
|
+
const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
|
|
164
|
+
const totalBaseAmountStr = fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS);
|
|
165
|
+
|
|
166
|
+
// Generate idempotency key
|
|
167
|
+
const contextId = sessionId || invoiceId || '';
|
|
168
|
+
const idempotencyKey = generateIdempotencyKey({
|
|
169
|
+
contextId,
|
|
170
|
+
priceId,
|
|
171
|
+
currencyId: targetCurrencyId,
|
|
172
|
+
quantity,
|
|
173
|
+
baseAmount: totalBaseAmountStr,
|
|
174
|
+
salt: idempotencyKeySalt,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Try to find existing quote by idempotency key
|
|
178
|
+
const existingQuote = await PriceQuote.findByIdempotencyKey(idempotencyKey);
|
|
179
|
+
if (existingQuote && existingQuote.canRetry()) {
|
|
180
|
+
logger.debug('Reusing existing quote', {
|
|
181
|
+
quoteId: existingQuote.id,
|
|
182
|
+
idempotencyKey,
|
|
183
|
+
status: existingQuote.status,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
quote: existingQuote,
|
|
188
|
+
computed_unit_amount: existingQuote.quoted_amount,
|
|
189
|
+
expires_at: existingQuote.expires_at,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const slippagePercent = normalizeSlippagePercent(params.slippage_percent);
|
|
194
|
+
const minAcceptableRate = params.min_acceptable_rate;
|
|
195
|
+
|
|
196
|
+
// Get exchange rate
|
|
197
|
+
// For ArcBlock payment method, always use ABT for exchange rate
|
|
198
|
+
const exchangeRateService = getExchangeRateService();
|
|
199
|
+
const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
|
|
200
|
+
const rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
201
|
+
|
|
202
|
+
// Calculate quoted amount using pure BN arithmetic (no JS number conversion)
|
|
203
|
+
// Formula: quotedAmount = ceil(totalBaseAmountBN * 10^decimals / rateBN)
|
|
204
|
+
//
|
|
205
|
+
// totalBaseAmountBN is already in smallest USD unit (10^8 scale)
|
|
206
|
+
// rateBN is USD per token (also 10^8 scale)
|
|
207
|
+
// Result: token smallest unit (10^decimals scale)
|
|
208
|
+
|
|
209
|
+
const rateBN = fromTokenToUnit(trimDecimals(rateResult.rate, USD_DECIMALS), USD_DECIMALS);
|
|
210
|
+
|
|
211
|
+
// Calculate: quotedAmount = ceil((totalBaseAmountBN * 10^decimals) / rateBN)
|
|
212
|
+
const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(currency.decimal)));
|
|
213
|
+
const denominator = rateBN;
|
|
214
|
+
|
|
215
|
+
// Ceiling division: ceil(a/b) = (a + b - 1) / b for integers
|
|
216
|
+
const quotedAmountRaw = numerator.add(denominator).sub(new BN(1)).div(denominator);
|
|
217
|
+
|
|
218
|
+
// Limit precision to 10 decimal places for dynamic pricing
|
|
219
|
+
// This avoids meaningless precision in token amounts
|
|
220
|
+
const quotedAmount = limitTokenPrecision(quotedAmountRaw, currency.decimal, 10);
|
|
221
|
+
|
|
222
|
+
// Calculate max_payable_token
|
|
223
|
+
// Priority: min_acceptable_rate > slippage_percent
|
|
224
|
+
// Formula with min_acceptable_rate: max_payable_token = ceil(base_amount / min_acceptable_rate)
|
|
225
|
+
// Formula with slippage_percent: max_payable_token = ceil(quoted_amount * (1 + slippage_percent/100))
|
|
226
|
+
let maxPayableToken: string | undefined;
|
|
227
|
+
if (minAcceptableRate && Number(minAcceptableRate) > 0) {
|
|
228
|
+
// Use min_acceptable_rate: max_payable_token = base_amount / min_acceptable_rate
|
|
229
|
+
const minRateBN = fromTokenToUnit(trimDecimals(minAcceptableRate, USD_DECIMALS), USD_DECIMALS);
|
|
230
|
+
const maxPayableRaw = totalBaseAmountBN
|
|
231
|
+
.mul(new BN(10).pow(new BN(currency.decimal)))
|
|
232
|
+
.add(minRateBN)
|
|
233
|
+
.sub(new BN(1))
|
|
234
|
+
.div(minRateBN);
|
|
235
|
+
maxPayableToken = limitTokenPrecision(maxPayableRaw, currency.decimal, 10).toString();
|
|
236
|
+
} else {
|
|
237
|
+
// Use slippage_percent: max_payable_token = quoted_amount * (1 + slippage_percent/100)
|
|
238
|
+
const SLIPPAGE_BPS_BASE = new BN(10000);
|
|
239
|
+
const slippageBps = Math.round(slippagePercent * 100);
|
|
240
|
+
const multiplier = SLIPPAGE_BPS_BASE.add(new BN(slippageBps));
|
|
241
|
+
maxPayableToken = quotedAmount
|
|
242
|
+
.mul(multiplier)
|
|
243
|
+
.add(SLIPPAGE_BPS_BASE.sub(new BN(1)))
|
|
244
|
+
.div(SLIPPAGE_BPS_BASE)
|
|
245
|
+
.toString();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Calculate expiration time
|
|
249
|
+
const lockDuration = price.dynamic_pricing_config?.lock_duration || 180; // Default 180 seconds (3 minutes)
|
|
250
|
+
const expiresAt = Math.floor(Date.now() / 1000) + lockDuration;
|
|
251
|
+
|
|
252
|
+
// Create quote
|
|
253
|
+
const quotePayload = {
|
|
254
|
+
price_id: priceId,
|
|
255
|
+
session_id: sessionId || undefined,
|
|
256
|
+
invoice_id: invoiceId || undefined,
|
|
257
|
+
idempotency_key: idempotencyKey,
|
|
258
|
+
base_currency: price.base_currency,
|
|
259
|
+
base_amount: totalBaseAmountStr,
|
|
260
|
+
target_currency_id: targetCurrencyId,
|
|
261
|
+
rate_currency_symbol: currency.symbol,
|
|
262
|
+
exchange_rate: rateResult.rate,
|
|
263
|
+
quoted_amount: quotedAmount.toString(),
|
|
264
|
+
slippage_percent: slippagePercent,
|
|
265
|
+
max_payable_token: maxPayableToken,
|
|
266
|
+
rate_provider_id: rateResult.provider_id,
|
|
267
|
+
rate_provider_name: rateResult.provider_name,
|
|
268
|
+
rate_timestamp_ms: rateResult.timestamp_ms,
|
|
269
|
+
expires_at: expiresAt,
|
|
270
|
+
status: 'active',
|
|
271
|
+
metadata: {
|
|
272
|
+
calculation: {
|
|
273
|
+
total_base_amount_scaled: totalBaseAmountBN.toString(),
|
|
274
|
+
rate_scaled: rateBN.toString(),
|
|
275
|
+
quoted_amount_unit: quotedAmount.toString(),
|
|
276
|
+
},
|
|
277
|
+
rounding: {
|
|
278
|
+
mode: 'ceil',
|
|
279
|
+
token_decimals: currency.decimal,
|
|
280
|
+
},
|
|
281
|
+
risk: {
|
|
282
|
+
anomaly_detected: false,
|
|
283
|
+
degraded: rateResult.degraded,
|
|
284
|
+
degraded_reason: rateResult.degraded_reason || null,
|
|
285
|
+
},
|
|
286
|
+
rate: {
|
|
287
|
+
consensus_method: resolveConsensusMethod(rateResult),
|
|
288
|
+
providers: rateResult.providers || [],
|
|
289
|
+
},
|
|
290
|
+
slippage: {
|
|
291
|
+
percent: slippagePercent,
|
|
292
|
+
min_acceptable_rate: minAcceptableRate || undefined,
|
|
293
|
+
max_payable_token: maxPayableToken,
|
|
294
|
+
},
|
|
295
|
+
context: {
|
|
296
|
+
quantity,
|
|
297
|
+
base_amount_per_unit: price.base_amount,
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Create quote with retry logic for idempotency key collision
|
|
303
|
+
const MAX_RETRIES = 3;
|
|
304
|
+
let quote: PriceQuote | null = null;
|
|
305
|
+
let lastError: any = null;
|
|
306
|
+
|
|
307
|
+
// Retry loop: sequential await is required for idempotency key collision handling
|
|
308
|
+
// eslint-disable-next-line no-await-in-loop
|
|
309
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
310
|
+
try {
|
|
311
|
+
// eslint-disable-next-line no-await-in-loop
|
|
312
|
+
quote = await PriceQuote.create(quotePayload);
|
|
313
|
+
break; // Success, exit loop
|
|
314
|
+
} catch (error: any) {
|
|
315
|
+
lastError = error;
|
|
316
|
+
|
|
317
|
+
if (isUniqueConstraintError(error)) {
|
|
318
|
+
// Try to reuse existing quote with same idempotency key
|
|
319
|
+
// eslint-disable-next-line no-await-in-loop
|
|
320
|
+
const existingByKey = await PriceQuote.findOne({ where: { idempotency_key: quotePayload.idempotency_key } });
|
|
321
|
+
if (existingByKey && existingByKey.canRetry()) {
|
|
322
|
+
logger.warn('Idempotency key collision, reusing existing quote', {
|
|
323
|
+
quoteId: existingByKey.id,
|
|
324
|
+
idempotencyKey: quotePayload.idempotency_key,
|
|
325
|
+
attempt,
|
|
326
|
+
status: existingByKey.status,
|
|
327
|
+
});
|
|
328
|
+
return {
|
|
329
|
+
quote: existingByKey,
|
|
330
|
+
computed_unit_amount: existingByKey.quoted_amount,
|
|
331
|
+
expires_at: existingByKey.expires_at,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// If last attempt or existing quote not found/not retryable, throw error
|
|
336
|
+
if (attempt >= MAX_RETRIES - 1) {
|
|
337
|
+
logger.error('Max retries exceeded for quote creation in getOrCreateQuote', {
|
|
338
|
+
priceId,
|
|
339
|
+
currencyId: targetCurrencyId,
|
|
340
|
+
attempts: attempt + 1,
|
|
341
|
+
idempotencyKey: quotePayload.idempotency_key,
|
|
342
|
+
});
|
|
343
|
+
break; // Will throw error after loop
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Generate new idempotency key with retry salt
|
|
347
|
+
const retrySalt = `${idempotencyKeySalt || 'fallback'}:retry:${attempt}:${Date.now()}:${crypto.randomBytes(4).toString('hex')}`;
|
|
348
|
+
quotePayload.idempotency_key = generateIdempotencyKey({
|
|
349
|
+
contextId,
|
|
350
|
+
priceId,
|
|
351
|
+
currencyId: targetCurrencyId,
|
|
352
|
+
quantity,
|
|
353
|
+
baseAmount: totalBaseAmountStr,
|
|
354
|
+
salt: retrySalt,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
logger.info('Retrying quote creation with new idempotency key', {
|
|
358
|
+
priceId,
|
|
359
|
+
attempt: attempt + 1,
|
|
360
|
+
newIdempotencyKey: quotePayload.idempotency_key,
|
|
361
|
+
});
|
|
362
|
+
} else {
|
|
363
|
+
// Non-idempotency error, throw immediately
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// If all retries failed, throw the last error
|
|
370
|
+
if (!quote) {
|
|
371
|
+
throw lastError || new Error('Failed to create quote after retries');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
logger.info('Created new quote', {
|
|
375
|
+
quoteId: quote.id,
|
|
376
|
+
priceId,
|
|
377
|
+
currencyId: targetCurrencyId,
|
|
378
|
+
baseAmount: totalBaseAmountStr,
|
|
379
|
+
rate: rateResult.rate,
|
|
380
|
+
quotedAmount: quotedAmount.toString(),
|
|
381
|
+
expiresAt,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
quote,
|
|
386
|
+
computed_unit_amount: quotedAmount.toString(),
|
|
387
|
+
expires_at: expiresAt,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Create a quote using pre-fetched exchange rate
|
|
393
|
+
* This is used for batch quote creation to avoid multiple rate queries
|
|
394
|
+
*/
|
|
395
|
+
async createQuoteWithRate(params: CreateQuoteParams & { rateResult: RateResult }): Promise<QuoteResponse> {
|
|
396
|
+
const {
|
|
397
|
+
price_id: priceId,
|
|
398
|
+
session_id: sessionId,
|
|
399
|
+
invoice_id: invoiceId,
|
|
400
|
+
target_currency_id: targetCurrencyId,
|
|
401
|
+
quantity,
|
|
402
|
+
rateResult,
|
|
403
|
+
idempotency_key_salt: idempotencyKeySalt,
|
|
404
|
+
slippage_percent: slippagePercentInput,
|
|
405
|
+
min_acceptable_rate: minAcceptableRate,
|
|
406
|
+
} = params;
|
|
407
|
+
|
|
408
|
+
// Validate params
|
|
409
|
+
if (!priceId || !targetCurrencyId || !quantity) {
|
|
410
|
+
throw new NonRetryableError('INVALID_PARAMS', 'price_id, target_currency_id, and quantity are required');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!sessionId && !invoiceId) {
|
|
414
|
+
throw new NonRetryableError('INVALID_PARAMS', 'Either session_id or invoice_id must be provided');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Load price
|
|
418
|
+
const price = await Price.findByPk(priceId);
|
|
419
|
+
if (!price) {
|
|
420
|
+
throw new NonRetryableError('PRICE_NOT_FOUND', `Price ${priceId} not found`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Check if price is dynamic
|
|
424
|
+
if (price.pricing_type !== 'dynamic') {
|
|
425
|
+
throw new NonRetryableError('INVALID_PRICE_TYPE', `Price ${priceId} is not a dynamic price`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!price.base_currency || !price.base_amount) {
|
|
429
|
+
throw new NonRetryableError('INVALID_PRICE_CONFIG', `Price ${priceId} missing base_currency or base_amount`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Load target currency
|
|
433
|
+
const currency = await PaymentCurrency.findByPk(targetCurrencyId);
|
|
434
|
+
if (!currency) {
|
|
435
|
+
throw new NonRetryableError('CURRENCY_NOT_FOUND', `Currency ${targetCurrencyId} not found`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Calculate total base amount using BN for precision
|
|
439
|
+
const USD_DECIMALS = 8;
|
|
440
|
+
const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
|
|
441
|
+
const quantityBN = new BN(quantity);
|
|
442
|
+
const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
|
|
443
|
+
const totalBaseAmountStr = fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS);
|
|
444
|
+
|
|
445
|
+
// Generate idempotency key
|
|
446
|
+
const contextId = sessionId || invoiceId || '';
|
|
447
|
+
const idempotencyKey = generateIdempotencyKey({
|
|
448
|
+
contextId,
|
|
449
|
+
priceId,
|
|
450
|
+
currencyId: targetCurrencyId,
|
|
451
|
+
quantity,
|
|
452
|
+
baseAmount: totalBaseAmountStr,
|
|
453
|
+
salt: idempotencyKeySalt,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// Try to find existing quote by idempotency key
|
|
457
|
+
const existingQuote = await PriceQuote.findByIdempotencyKey(idempotencyKey);
|
|
458
|
+
if (existingQuote && existingQuote.canRetry()) {
|
|
459
|
+
logger.debug('Reusing existing quote', {
|
|
460
|
+
quoteId: existingQuote.id,
|
|
461
|
+
idempotencyKey,
|
|
462
|
+
status: existingQuote.status,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
quote: existingQuote,
|
|
467
|
+
computed_unit_amount: existingQuote.quoted_amount,
|
|
468
|
+
expires_at: existingQuote.expires_at,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Calculate quoted amount using pure BN arithmetic
|
|
473
|
+
const rateBN = fromTokenToUnit(trimDecimals(rateResult.rate, USD_DECIMALS), USD_DECIMALS);
|
|
474
|
+
|
|
475
|
+
// Calculate: quotedAmount = ceil((totalBaseAmountBN * 10^decimals) / rateBN)
|
|
476
|
+
const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(currency.decimal)));
|
|
477
|
+
const denominator = rateBN;
|
|
478
|
+
|
|
479
|
+
// Ceiling division: ceil(a/b) = (a + b - 1) / b
|
|
480
|
+
const quotedAmountRaw = numerator.add(denominator).sub(new BN(1)).div(denominator);
|
|
481
|
+
|
|
482
|
+
// Limit precision to 10 decimal places for dynamic pricing
|
|
483
|
+
// This avoids meaningless precision in token amounts
|
|
484
|
+
const quotedAmount = limitTokenPrecision(quotedAmountRaw, currency.decimal, 10);
|
|
485
|
+
|
|
486
|
+
// Calculate expiration time
|
|
487
|
+
const lockDuration = price.dynamic_pricing_config?.lock_duration || 180; // Default 180 seconds (3 minutes)
|
|
488
|
+
const expiresAt = Math.floor(Date.now() / 1000) + lockDuration;
|
|
489
|
+
|
|
490
|
+
const slippagePercent = normalizeSlippagePercent(slippagePercentInput);
|
|
491
|
+
|
|
492
|
+
// Calculate max_payable_token
|
|
493
|
+
// Priority: min_acceptable_rate > slippage_percent
|
|
494
|
+
let maxPayableToken: string | undefined;
|
|
495
|
+
if (minAcceptableRate && Number(minAcceptableRate) > 0) {
|
|
496
|
+
// Use min_acceptable_rate: max_payable_token = base_amount / min_acceptable_rate
|
|
497
|
+
const minRateBN = fromTokenToUnit(trimDecimals(minAcceptableRate, USD_DECIMALS), USD_DECIMALS);
|
|
498
|
+
const maxPayableRaw = totalBaseAmountBN
|
|
499
|
+
.mul(new BN(10).pow(new BN(currency.decimal)))
|
|
500
|
+
.add(minRateBN)
|
|
501
|
+
.sub(new BN(1))
|
|
502
|
+
.div(minRateBN);
|
|
503
|
+
maxPayableToken = limitTokenPrecision(maxPayableRaw, currency.decimal, 10).toString();
|
|
504
|
+
} else {
|
|
505
|
+
// Use slippage_percent: max_payable_token = quoted_amount * (1 + slippage_percent/100)
|
|
506
|
+
const SLIPPAGE_BPS_BASE = new BN(10000);
|
|
507
|
+
const slippageBps = Math.round(slippagePercent * 100);
|
|
508
|
+
const multiplier = SLIPPAGE_BPS_BASE.add(new BN(slippageBps));
|
|
509
|
+
maxPayableToken = quotedAmount
|
|
510
|
+
.mul(multiplier)
|
|
511
|
+
.add(SLIPPAGE_BPS_BASE.sub(new BN(1)))
|
|
512
|
+
.div(SLIPPAGE_BPS_BASE)
|
|
513
|
+
.toString();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Create quote
|
|
517
|
+
const quotePayload = {
|
|
518
|
+
price_id: priceId,
|
|
519
|
+
session_id: sessionId || undefined,
|
|
520
|
+
invoice_id: invoiceId || undefined,
|
|
521
|
+
idempotency_key: idempotencyKey,
|
|
522
|
+
base_currency: price.base_currency,
|
|
523
|
+
base_amount: totalBaseAmountStr,
|
|
524
|
+
target_currency_id: targetCurrencyId,
|
|
525
|
+
rate_currency_symbol: currency.symbol,
|
|
526
|
+
exchange_rate: rateResult.rate,
|
|
527
|
+
quoted_amount: quotedAmount.toString(),
|
|
528
|
+
slippage_percent: slippagePercent,
|
|
529
|
+
max_payable_token: maxPayableToken,
|
|
530
|
+
rate_provider_id: rateResult.provider_id,
|
|
531
|
+
rate_provider_name: rateResult.provider_name,
|
|
532
|
+
rate_timestamp_ms: rateResult.timestamp_ms,
|
|
533
|
+
expires_at: expiresAt,
|
|
534
|
+
status: 'active',
|
|
535
|
+
metadata: {
|
|
536
|
+
calculation: {
|
|
537
|
+
total_base_amount_scaled: totalBaseAmountBN.toString(),
|
|
538
|
+
rate_scaled: rateBN.toString(),
|
|
539
|
+
quoted_amount_unit: quotedAmount.toString(),
|
|
540
|
+
},
|
|
541
|
+
rounding: {
|
|
542
|
+
mode: 'ceil',
|
|
543
|
+
token_decimals: currency.decimal,
|
|
544
|
+
},
|
|
545
|
+
risk: {
|
|
546
|
+
anomaly_detected: false,
|
|
547
|
+
degraded: rateResult.degraded,
|
|
548
|
+
degraded_reason: rateResult.degraded_reason || null,
|
|
549
|
+
},
|
|
550
|
+
rate: {
|
|
551
|
+
consensus_method: resolveConsensusMethod(rateResult),
|
|
552
|
+
providers: rateResult.providers || [],
|
|
553
|
+
},
|
|
554
|
+
slippage: {
|
|
555
|
+
percent: slippagePercent,
|
|
556
|
+
min_acceptable_rate: minAcceptableRate || undefined,
|
|
557
|
+
max_payable_token: maxPayableToken,
|
|
558
|
+
},
|
|
559
|
+
context: {
|
|
560
|
+
quantity,
|
|
561
|
+
base_amount_per_unit: price.base_amount,
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// Create quote with retry logic for idempotency key collision
|
|
567
|
+
const MAX_RETRIES = 3;
|
|
568
|
+
let quote: PriceQuote | null = null;
|
|
569
|
+
let lastError: any = null;
|
|
570
|
+
|
|
571
|
+
// Retry loop: sequential await is required for idempotency key collision handling
|
|
572
|
+
// eslint-disable-next-line no-await-in-loop
|
|
573
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
574
|
+
try {
|
|
575
|
+
// eslint-disable-next-line no-await-in-loop
|
|
576
|
+
quote = await PriceQuote.create(quotePayload);
|
|
577
|
+
break; // Success, exit loop
|
|
578
|
+
} catch (error: any) {
|
|
579
|
+
lastError = error;
|
|
580
|
+
|
|
581
|
+
if (isUniqueConstraintError(error)) {
|
|
582
|
+
// Try to reuse existing quote with same idempotency key
|
|
583
|
+
// eslint-disable-next-line no-await-in-loop
|
|
584
|
+
const existingByKey = await PriceQuote.findOne({ where: { idempotency_key: quotePayload.idempotency_key } });
|
|
585
|
+
if (existingByKey && existingByKey.canRetry()) {
|
|
586
|
+
logger.warn('Idempotency key collision, reusing existing quote', {
|
|
587
|
+
quoteId: existingByKey.id,
|
|
588
|
+
idempotencyKey: quotePayload.idempotency_key,
|
|
589
|
+
attempt,
|
|
590
|
+
status: existingByKey.status,
|
|
591
|
+
});
|
|
592
|
+
return {
|
|
593
|
+
quote: existingByKey,
|
|
594
|
+
computed_unit_amount: existingByKey.quoted_amount,
|
|
595
|
+
expires_at: existingByKey.expires_at,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// If last attempt or existing quote not found/not retryable, throw error
|
|
600
|
+
if (attempt >= MAX_RETRIES - 1) {
|
|
601
|
+
logger.error('Max retries exceeded for quote creation in createQuoteWithRate', {
|
|
602
|
+
priceId,
|
|
603
|
+
currencyId: targetCurrencyId,
|
|
604
|
+
attempts: attempt + 1,
|
|
605
|
+
idempotencyKey: quotePayload.idempotency_key,
|
|
606
|
+
});
|
|
607
|
+
break; // Will throw error after loop
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Generate new idempotency key with retry salt
|
|
611
|
+
const retrySalt = `${idempotencyKeySalt || 'fallback'}:retry:${attempt}:${Date.now()}:${crypto.randomBytes(4).toString('hex')}`;
|
|
612
|
+
quotePayload.idempotency_key = generateIdempotencyKey({
|
|
613
|
+
contextId,
|
|
614
|
+
priceId,
|
|
615
|
+
currencyId: targetCurrencyId,
|
|
616
|
+
quantity,
|
|
617
|
+
baseAmount: totalBaseAmountStr,
|
|
618
|
+
salt: retrySalt,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
logger.info('Retrying quote creation with new idempotency key', {
|
|
622
|
+
priceId,
|
|
623
|
+
attempt: attempt + 1,
|
|
624
|
+
newIdempotencyKey: quotePayload.idempotency_key,
|
|
625
|
+
});
|
|
626
|
+
} else {
|
|
627
|
+
// Non-idempotency error, throw immediately
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// If all retries failed, throw the last error
|
|
634
|
+
if (!quote) {
|
|
635
|
+
throw lastError || new Error('Failed to create quote after retries');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
logger.info('Created new quote with provided rate', {
|
|
639
|
+
quoteId: quote.id,
|
|
640
|
+
priceId,
|
|
641
|
+
currencyId: targetCurrencyId,
|
|
642
|
+
baseAmount: totalBaseAmountStr,
|
|
643
|
+
rate: rateResult.rate,
|
|
644
|
+
quotedAmount: quotedAmount.toString(),
|
|
645
|
+
expiresAt,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return {
|
|
649
|
+
quote,
|
|
650
|
+
computed_unit_amount: quotedAmount.toString(),
|
|
651
|
+
expires_at: expiresAt,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Refresh an existing quote in place using a pre-fetched exchange rate
|
|
657
|
+
* This is used when quotes expire but we don't want to create a new quote_id
|
|
658
|
+
*/
|
|
659
|
+
async refreshQuoteWithRate(
|
|
660
|
+
params: CreateQuoteParams & { rateResult: RateResult; quote_id: string }
|
|
661
|
+
): Promise<QuoteResponse> {
|
|
662
|
+
const {
|
|
663
|
+
quote_id: quoteId,
|
|
664
|
+
price_id: priceId,
|
|
665
|
+
session_id: sessionId,
|
|
666
|
+
invoice_id: invoiceId,
|
|
667
|
+
target_currency_id: targetCurrencyId,
|
|
668
|
+
quantity,
|
|
669
|
+
rateResult,
|
|
670
|
+
slippage_percent: slippagePercentInput,
|
|
671
|
+
} = params;
|
|
672
|
+
|
|
673
|
+
const existing = await PriceQuote.findByPk(quoteId);
|
|
674
|
+
if (!existing) {
|
|
675
|
+
return this.createQuoteWithRate({
|
|
676
|
+
price_id: priceId,
|
|
677
|
+
session_id: sessionId,
|
|
678
|
+
invoice_id: invoiceId,
|
|
679
|
+
target_currency_id: targetCurrencyId,
|
|
680
|
+
quantity,
|
|
681
|
+
rateResult,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (existing.status === 'paid' || existing.status === 'used') {
|
|
686
|
+
return {
|
|
687
|
+
quote: existing,
|
|
688
|
+
computed_unit_amount: existing.quoted_amount,
|
|
689
|
+
expires_at: existing.expires_at,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
if (existing.status === 'failed') {
|
|
693
|
+
return this.createQuoteWithRate({
|
|
694
|
+
price_id: priceId,
|
|
695
|
+
session_id: sessionId,
|
|
696
|
+
invoice_id: invoiceId,
|
|
697
|
+
target_currency_id: targetCurrencyId,
|
|
698
|
+
quantity,
|
|
699
|
+
rateResult,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Load price
|
|
704
|
+
const price = await Price.findByPk(priceId);
|
|
705
|
+
if (!price) {
|
|
706
|
+
throw new NonRetryableError('PRICE_NOT_FOUND', `Price ${priceId} not found`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (price.pricing_type !== 'dynamic') {
|
|
710
|
+
throw new NonRetryableError('INVALID_PRICE_TYPE', `Price ${priceId} is not a dynamic price`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (!price.base_currency || !price.base_amount) {
|
|
714
|
+
throw new NonRetryableError('INVALID_PRICE_CONFIG', `Price ${priceId} missing base_currency or base_amount`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Load target currency
|
|
718
|
+
const currency = await PaymentCurrency.findByPk(targetCurrencyId);
|
|
719
|
+
if (!currency) {
|
|
720
|
+
throw new NonRetryableError('CURRENCY_NOT_FOUND', `Currency ${targetCurrencyId} not found`);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Calculate total base amount using BN for precision
|
|
724
|
+
const USD_DECIMALS = 8;
|
|
725
|
+
const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
|
|
726
|
+
const quantityBN = new BN(quantity);
|
|
727
|
+
const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
|
|
728
|
+
const totalBaseAmountStr = fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS);
|
|
729
|
+
|
|
730
|
+
// Calculate quoted amount using pure BN arithmetic
|
|
731
|
+
const rateBN = fromTokenToUnit(trimDecimals(rateResult.rate, USD_DECIMALS), USD_DECIMALS);
|
|
732
|
+
const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(currency.decimal)));
|
|
733
|
+
const denominator = rateBN;
|
|
734
|
+
const quotedAmountRaw = numerator.add(denominator).sub(new BN(1)).div(denominator);
|
|
735
|
+
const quotedAmount = limitTokenPrecision(quotedAmountRaw, currency.decimal, 10);
|
|
736
|
+
|
|
737
|
+
// Calculate expiration time
|
|
738
|
+
const lockDuration = price.dynamic_pricing_config?.lock_duration || 180; // Default 180 seconds (3 minutes)
|
|
739
|
+
const expiresAt = Math.floor(Date.now() / 1000) + lockDuration;
|
|
740
|
+
|
|
741
|
+
const slippagePercent = normalizeSlippagePercent(slippagePercentInput);
|
|
742
|
+
|
|
743
|
+
await existing.update({
|
|
744
|
+
base_currency: price.base_currency,
|
|
745
|
+
base_amount: totalBaseAmountStr,
|
|
746
|
+
target_currency_id: targetCurrencyId,
|
|
747
|
+
rate_currency_symbol: currency.symbol,
|
|
748
|
+
exchange_rate: rateResult.rate,
|
|
749
|
+
quoted_amount: quotedAmount.toString(),
|
|
750
|
+
slippage_percent: slippagePercent,
|
|
751
|
+
rate_provider_id: rateResult.provider_id,
|
|
752
|
+
rate_provider_name: rateResult.provider_name,
|
|
753
|
+
rate_timestamp_ms: rateResult.timestamp_ms,
|
|
754
|
+
expires_at: expiresAt,
|
|
755
|
+
status: 'active',
|
|
756
|
+
metadata: {
|
|
757
|
+
calculation: {
|
|
758
|
+
total_base_amount_scaled: totalBaseAmountBN.toString(),
|
|
759
|
+
rate_scaled: rateBN.toString(),
|
|
760
|
+
quoted_amount_unit: quotedAmount.toString(),
|
|
761
|
+
},
|
|
762
|
+
rounding: {
|
|
763
|
+
mode: 'ceil',
|
|
764
|
+
token_decimals: currency.decimal,
|
|
765
|
+
},
|
|
766
|
+
risk: {
|
|
767
|
+
anomaly_detected: false,
|
|
768
|
+
degraded: rateResult.degraded,
|
|
769
|
+
degraded_reason: rateResult.degraded_reason || null,
|
|
770
|
+
},
|
|
771
|
+
rate: {
|
|
772
|
+
consensus_method: resolveConsensusMethod(rateResult),
|
|
773
|
+
providers: rateResult.providers || [],
|
|
774
|
+
},
|
|
775
|
+
slippage: {
|
|
776
|
+
percent: slippagePercent,
|
|
777
|
+
},
|
|
778
|
+
context: {
|
|
779
|
+
quantity,
|
|
780
|
+
base_amount_per_unit: price.base_amount,
|
|
781
|
+
},
|
|
782
|
+
} as QuoteMetadata,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
quote: existing,
|
|
787
|
+
computed_unit_amount: existing.quoted_amount,
|
|
788
|
+
expires_at: existing.expires_at,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* @deprecated In Final Freeze, Quotes are created as 'used' at Submit.
|
|
794
|
+
* This method is kept for backward compatibility.
|
|
795
|
+
* Use isUsable() or canRetry() to check Quote validity.
|
|
796
|
+
*/
|
|
797
|
+
async validateAndConsume(quoteId: string, transaction?: any): Promise<PriceQuote> {
|
|
798
|
+
// Use FOR UPDATE to lock the row
|
|
799
|
+
const quote = await PriceQuote.findOne({
|
|
800
|
+
where: { id: quoteId },
|
|
801
|
+
lock: transaction ? transaction.LOCK.UPDATE : undefined,
|
|
802
|
+
transaction,
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
if (!quote) {
|
|
806
|
+
throw new NonRetryableError('QUOTE_NOT_FOUND', `Quote ${quoteId} not found`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Final Freeze: Check if quote can be used (used or payment_failed)
|
|
810
|
+
if (!quote.canRetry()) {
|
|
811
|
+
throw new NonRetryableError('QUOTE_INVALID', `Quote ${quoteId} cannot be used (status: ${quote.status})`);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// In Final Freeze, quote is already 'used' - just return it
|
|
815
|
+
return quote;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Mark quote as paid
|
|
820
|
+
* Idempotent: if quote not found or already paid, logs and returns silently
|
|
821
|
+
*/
|
|
822
|
+
async markAsPaid(quoteId: string, transaction?: any): Promise<void> {
|
|
823
|
+
const quote = await PriceQuote.findByPk(quoteId, { transaction });
|
|
824
|
+
if (!quote) {
|
|
825
|
+
logger.warn('Quote not found when marking as paid', { quoteId });
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
await quote.markAsPaid(transaction);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Mark all quotes associated with an invoice as paid
|
|
834
|
+
* Idempotent: already paid quotes are skipped silently
|
|
835
|
+
*/
|
|
836
|
+
async markQuotesAsPaidByInvoice(invoiceId: string, transaction?: any): Promise<string[]> {
|
|
837
|
+
const quotes = await PriceQuote.findAll({
|
|
838
|
+
where: { invoice_id: invoiceId },
|
|
839
|
+
transaction,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
if (quotes.length === 0) {
|
|
843
|
+
return [];
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const markedIds: string[] = [];
|
|
847
|
+
await Promise.all(
|
|
848
|
+
quotes.map(async (quote) => {
|
|
849
|
+
if (quote.status !== 'paid') {
|
|
850
|
+
await quote.markAsPaid(transaction);
|
|
851
|
+
markedIds.push(quote.id);
|
|
852
|
+
}
|
|
853
|
+
})
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
if (markedIds.length > 0) {
|
|
857
|
+
logger.info('Marked quotes as paid by invoice', { invoiceId, quoteIds: markedIds });
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return markedIds;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* @deprecated In Final Freeze, Quotes don't expire by time.
|
|
865
|
+
* This method is a no-op for backward compatibility.
|
|
866
|
+
*/
|
|
867
|
+
expireOldQuotes(): number {
|
|
868
|
+
// Final Freeze: Quotes don't expire by time, this is a no-op
|
|
869
|
+
logger.info('expireOldQuotes called but quotes no longer expire (Final Freeze)');
|
|
870
|
+
return 0;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Create Quote at Submit time with dual-layer validation (Final Freeze Architecture)
|
|
875
|
+
*
|
|
876
|
+
* This is the ONLY way to create Quotes in the new architecture.
|
|
877
|
+
* Quote is created directly as 'used' status.
|
|
878
|
+
*
|
|
879
|
+
* Validation layers:
|
|
880
|
+
* 1. System-level: Rate trustworthiness (non-bypassable)
|
|
881
|
+
* 2. User-level: Slippage protection (bypassable with confirmation)
|
|
882
|
+
*
|
|
883
|
+
* @see Intent: blocklets/core/ai/intent/20260112-dynamic-price.md
|
|
884
|
+
*/
|
|
885
|
+
async createUsedQuoteWithValidation(params: {
|
|
886
|
+
price_id: string;
|
|
887
|
+
session_id?: string;
|
|
888
|
+
invoice_id?: string;
|
|
889
|
+
idempotency_key: string;
|
|
890
|
+
target_currency_id: string;
|
|
891
|
+
quantity: number;
|
|
892
|
+
preview_rate?: string;
|
|
893
|
+
slippage_percent?: number;
|
|
894
|
+
price_confirmed?: boolean;
|
|
895
|
+
}): Promise<{
|
|
896
|
+
quote: PriceQuote;
|
|
897
|
+
validation_passed: boolean;
|
|
898
|
+
error_code?: 'PRICE_UNAVAILABLE' | 'PRICE_UNSTABLE' | 'PRICE_CHANGED';
|
|
899
|
+
error_message?: string;
|
|
900
|
+
change_percent?: number;
|
|
901
|
+
}> {
|
|
902
|
+
const {
|
|
903
|
+
price_id: priceId,
|
|
904
|
+
session_id: sessionId,
|
|
905
|
+
invoice_id: invoiceId,
|
|
906
|
+
idempotency_key: idempotencyKey,
|
|
907
|
+
target_currency_id: targetCurrencyId,
|
|
908
|
+
quantity,
|
|
909
|
+
preview_rate: previewRate,
|
|
910
|
+
slippage_percent: slippagePercentInput,
|
|
911
|
+
price_confirmed: priceConfirmed = false,
|
|
912
|
+
} = params;
|
|
913
|
+
|
|
914
|
+
// Check idempotency: return existing quote if found
|
|
915
|
+
const existingQuote = await PriceQuote.findByIdempotencyKey(idempotencyKey);
|
|
916
|
+
if (existingQuote) {
|
|
917
|
+
logger.info('Idempotent submit: returning existing quote', {
|
|
918
|
+
quoteId: existingQuote.id,
|
|
919
|
+
idempotencyKey,
|
|
920
|
+
status: existingQuote.status,
|
|
921
|
+
});
|
|
922
|
+
return {
|
|
923
|
+
quote: existingQuote,
|
|
924
|
+
validation_passed: true,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Load price
|
|
929
|
+
const price = await Price.findByPk(priceId);
|
|
930
|
+
if (!price) {
|
|
931
|
+
throw new NonRetryableError('PRICE_NOT_FOUND', `Price ${priceId} not found`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (price.pricing_type !== 'dynamic') {
|
|
935
|
+
throw new NonRetryableError('INVALID_PRICE_TYPE', `Price ${priceId} is not a dynamic price`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (!price.base_currency || !price.base_amount) {
|
|
939
|
+
throw new NonRetryableError('INVALID_PRICE_CONFIG', `Price ${priceId} missing base_currency or base_amount`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Load target currency
|
|
943
|
+
const currency = (await PaymentCurrency.findByPk(targetCurrencyId, {
|
|
944
|
+
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
945
|
+
})) as PaymentCurrency & { payment_method: PaymentMethod };
|
|
946
|
+
|
|
947
|
+
if (!currency) {
|
|
948
|
+
throw new NonRetryableError('CURRENCY_NOT_FOUND', `Currency ${targetCurrencyId} not found`);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (currency.payment_method?.type === 'stripe') {
|
|
952
|
+
throw new NonRetryableError(
|
|
953
|
+
'CURRENCY_NOT_SUPPORTED',
|
|
954
|
+
`Currency ${targetCurrencyId} is not supported for dynamic pricing`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Fetch current exchange rate
|
|
959
|
+
const exchangeRateService = getExchangeRateService();
|
|
960
|
+
const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
|
|
961
|
+
|
|
962
|
+
let rateResult;
|
|
963
|
+
try {
|
|
964
|
+
rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
965
|
+
} catch (error: any) {
|
|
966
|
+
logger.error('Failed to fetch exchange rate for submit', {
|
|
967
|
+
priceId,
|
|
968
|
+
currencyId: targetCurrencyId,
|
|
969
|
+
error: error.message,
|
|
970
|
+
});
|
|
971
|
+
// Return validation failure with PRICE_UNAVAILABLE
|
|
972
|
+
return {
|
|
973
|
+
quote: null as any,
|
|
974
|
+
validation_passed: false,
|
|
975
|
+
error_code: 'PRICE_UNAVAILABLE',
|
|
976
|
+
error_message: 'Unable to fetch exchange rate. Please try again later.',
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Dual-layer validation
|
|
981
|
+
const slippagePercent = normalizeSlippagePercent(slippagePercentInput);
|
|
982
|
+
const validationResult = validateForSubmit(
|
|
983
|
+
{
|
|
984
|
+
rate: rateResult.rate,
|
|
985
|
+
timestamp_ms: rateResult.timestamp_ms,
|
|
986
|
+
provider_id: rateResult.provider_id,
|
|
987
|
+
provider_name: rateResult.provider_name,
|
|
988
|
+
degraded: rateResult.degraded,
|
|
989
|
+
degraded_reason: rateResult.degraded_reason,
|
|
990
|
+
providers: rateResult.providers,
|
|
991
|
+
consensus_method: rateResult.consensus_method,
|
|
992
|
+
},
|
|
993
|
+
rateSymbol,
|
|
994
|
+
previewRate,
|
|
995
|
+
slippagePercent,
|
|
996
|
+
priceConfirmed
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
if (!validationResult.passed) {
|
|
1000
|
+
logger.info('Submit validation failed', {
|
|
1001
|
+
priceId,
|
|
1002
|
+
currencyId: targetCurrencyId,
|
|
1003
|
+
errorCode: validationResult.error_code,
|
|
1004
|
+
errorMessage: validationResult.error_message,
|
|
1005
|
+
changePercent: validationResult.slippage_validation?.change_percent,
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
return {
|
|
1009
|
+
quote: null as any,
|
|
1010
|
+
validation_passed: false,
|
|
1011
|
+
error_code: validationResult.error_code,
|
|
1012
|
+
error_message: validationResult.error_message,
|
|
1013
|
+
change_percent: validationResult.slippage_validation?.change_percent,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Calculate quoted amount
|
|
1018
|
+
const USD_DECIMALS = 8;
|
|
1019
|
+
const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
|
|
1020
|
+
const quantityBN = new BN(quantity);
|
|
1021
|
+
const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
|
|
1022
|
+
const totalBaseAmountStr = fromUnitToToken(totalBaseAmountBN.toString(), USD_DECIMALS);
|
|
1023
|
+
|
|
1024
|
+
const rateBN = fromTokenToUnit(trimDecimals(rateResult.rate, USD_DECIMALS), USD_DECIMALS);
|
|
1025
|
+
const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(currency.decimal)));
|
|
1026
|
+
const denominator = rateBN;
|
|
1027
|
+
const quotedAmountRaw = numerator.add(denominator).sub(new BN(1)).div(denominator);
|
|
1028
|
+
const quotedAmount = limitTokenPrecision(quotedAmountRaw, currency.decimal, 10);
|
|
1029
|
+
|
|
1030
|
+
// Calculate slippage fields using basis points (万分位) for precision
|
|
1031
|
+
// slippagePercent = 0.5 means 0.5%, which is 50 basis points
|
|
1032
|
+
const SLIPPAGE_BPS_BASE = 10000;
|
|
1033
|
+
const slippageBps = Math.round(slippagePercent * 100); // 0.5% -> 50 bps
|
|
1034
|
+
const maxPayableToken = quotedAmount
|
|
1035
|
+
.mul(new BN(SLIPPAGE_BPS_BASE + slippageBps))
|
|
1036
|
+
.add(new BN(SLIPPAGE_BPS_BASE - 1)) // 向上取整
|
|
1037
|
+
.div(new BN(SLIPPAGE_BPS_BASE));
|
|
1038
|
+
const minAcceptableRate = rateBN.mul(new BN(SLIPPAGE_BPS_BASE - slippageBps)).div(new BN(SLIPPAGE_BPS_BASE));
|
|
1039
|
+
|
|
1040
|
+
// Create Quote directly as 'used' (Final Freeze)
|
|
1041
|
+
const quoteInput: CreateQuoteInput = {
|
|
1042
|
+
price_id: priceId,
|
|
1043
|
+
session_id: sessionId,
|
|
1044
|
+
invoice_id: invoiceId,
|
|
1045
|
+
idempotency_key: idempotencyKey,
|
|
1046
|
+
base_currency: price.base_currency,
|
|
1047
|
+
base_amount: totalBaseAmountStr,
|
|
1048
|
+
target_currency_id: targetCurrencyId,
|
|
1049
|
+
rate_currency_symbol: currency.symbol,
|
|
1050
|
+
exchange_rate: rateResult.rate,
|
|
1051
|
+
quoted_amount: quotedAmount.toString(),
|
|
1052
|
+
rate_provider_id: rateResult.provider_id,
|
|
1053
|
+
rate_provider_name: rateResult.provider_name,
|
|
1054
|
+
rate_timestamp_ms: rateResult.timestamp_ms,
|
|
1055
|
+
slippage_percent: slippagePercent,
|
|
1056
|
+
max_payable_token: maxPayableToken.toString(),
|
|
1057
|
+
min_acceptable_rate: fromUnitToToken(minAcceptableRate.toString(), USD_DECIMALS),
|
|
1058
|
+
metadata: {
|
|
1059
|
+
calculation: {
|
|
1060
|
+
total_base_amount_scaled: totalBaseAmountBN.toString(),
|
|
1061
|
+
rate_scaled: rateBN.toString(),
|
|
1062
|
+
quoted_amount_unit: quotedAmount.toString(),
|
|
1063
|
+
},
|
|
1064
|
+
rounding: {
|
|
1065
|
+
mode: 'ceil',
|
|
1066
|
+
token_decimals: currency.decimal,
|
|
1067
|
+
},
|
|
1068
|
+
risk: {
|
|
1069
|
+
anomaly_detected: false,
|
|
1070
|
+
degraded: rateResult.degraded,
|
|
1071
|
+
degraded_reason: rateResult.degraded_reason || null,
|
|
1072
|
+
},
|
|
1073
|
+
rate: {
|
|
1074
|
+
consensus_method: resolveConsensusMethod(rateResult),
|
|
1075
|
+
providers: rateResult.providers || [],
|
|
1076
|
+
},
|
|
1077
|
+
slippage: {
|
|
1078
|
+
percent: slippagePercent,
|
|
1079
|
+
max_payable_token: maxPayableToken.toString(),
|
|
1080
|
+
min_acceptable_rate: fromUnitToToken(minAcceptableRate.toString(), USD_DECIMALS),
|
|
1081
|
+
derived_at_ms: Date.now(),
|
|
1082
|
+
},
|
|
1083
|
+
context: {
|
|
1084
|
+
quantity,
|
|
1085
|
+
base_amount_per_unit: price.base_amount,
|
|
1086
|
+
preview_rate: previewRate,
|
|
1087
|
+
price_confirmed: priceConfirmed,
|
|
1088
|
+
},
|
|
1089
|
+
} as QuoteMetadata,
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const quote = await PriceQuote.createUsedQuote(quoteInput);
|
|
1093
|
+
|
|
1094
|
+
logger.info('Created used quote at submit (Final Freeze)', {
|
|
1095
|
+
quoteId: quote.id,
|
|
1096
|
+
priceId,
|
|
1097
|
+
currencyId: targetCurrencyId,
|
|
1098
|
+
baseAmount: totalBaseAmountStr,
|
|
1099
|
+
rate: rateResult.rate,
|
|
1100
|
+
quotedAmount: quotedAmount.toString(),
|
|
1101
|
+
status: quote.status,
|
|
1102
|
+
idempotencyKey,
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
quote,
|
|
1107
|
+
validation_passed: true,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Mark quote as payment_failed (Final Freeze)
|
|
1113
|
+
*/
|
|
1114
|
+
async markAsPaymentFailed(quoteId: string, transaction?: any): Promise<void> {
|
|
1115
|
+
const quote = await PriceQuote.findByPk(quoteId, { transaction });
|
|
1116
|
+
if (!quote) {
|
|
1117
|
+
throw new Error(`Quote ${quoteId} not found`);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
await quote.markAsPaymentFailed(transaction);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Singleton instance
|
|
1125
|
+
let serviceInstance: QuoteService | null = null;
|
|
1126
|
+
|
|
1127
|
+
export function getQuoteService(): QuoteService {
|
|
1128
|
+
if (!serviceInstance) {
|
|
1129
|
+
serviceInstance = new QuoteService();
|
|
1130
|
+
}
|
|
1131
|
+
return serviceInstance;
|
|
1132
|
+
}
|