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.
Files changed (116) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +3 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/refund.ts +41 -9
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/payment-links.ts +13 -0
  66. package/api/src/routes/prices.ts +84 -2
  67. package/api/src/routes/subscriptions.ts +526 -15
  68. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  69. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  70. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  71. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  72. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  73. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  74. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  75. package/api/src/store/models/auto-recharge-config.ts +12 -0
  76. package/api/src/store/models/checkout-session.ts +7 -0
  77. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  78. package/api/src/store/models/index.ts +6 -0
  79. package/api/src/store/models/payment-intent.ts +6 -0
  80. package/api/src/store/models/price-quote.ts +284 -0
  81. package/api/src/store/models/price.ts +53 -5
  82. package/api/src/store/models/subscription.ts +11 -0
  83. package/api/src/store/models/types.ts +61 -1
  84. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  85. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  86. package/api/tests/libs/quote-service.spec.ts +199 -0
  87. package/api/tests/libs/session.spec.ts +464 -0
  88. package/api/tests/libs/slippage.spec.ts +109 -0
  89. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  90. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  91. package/api/tests/models/price-dynamic.spec.ts +100 -0
  92. package/api/tests/models/price-quote.spec.ts +112 -0
  93. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  94. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  95. package/blocklet.yml +1 -1
  96. package/package.json +7 -6
  97. package/src/components/customer/credit-overview.tsx +14 -0
  98. package/src/components/discount/discount-info.tsx +8 -2
  99. package/src/components/invoice/list.tsx +146 -16
  100. package/src/components/invoice/table.tsx +276 -71
  101. package/src/components/invoice-pdf/template.tsx +3 -7
  102. package/src/components/metadata/form.tsx +6 -8
  103. package/src/components/price/form.tsx +519 -149
  104. package/src/components/promotion/active-redemptions.tsx +5 -3
  105. package/src/components/quote/info.tsx +234 -0
  106. package/src/hooks/subscription.ts +132 -2
  107. package/src/locales/en.tsx +145 -0
  108. package/src/locales/zh.tsx +143 -1
  109. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  110. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  111. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  112. package/src/pages/admin/products/index.tsx +12 -1
  113. package/src/pages/customer/invoice/detail.tsx +36 -12
  114. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  115. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  116. 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
+ }