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
@@ -4,6 +4,7 @@ import { Op } from 'sequelize';
4
4
  import createQueue from '../libs/queue';
5
5
  import {
6
6
  AutoRechargeConfig,
7
+ ChainType,
7
8
  CreditGrant,
8
9
  Customer,
9
10
  Invoice,
@@ -11,6 +12,7 @@ import {
11
12
  PaymentCurrency,
12
13
  PaymentMethod,
13
14
  Price,
15
+ PriceQuote,
14
16
  Product,
15
17
  TPriceExpanded,
16
18
  } from '../store/models';
@@ -21,6 +23,11 @@ import { ensureInvoiceAndItems } from '../libs/invoice';
21
23
  import dayjs from '../libs/dayjs';
22
24
  import { invoiceQueue } from './invoice';
23
25
  import { getPriceUintAmountByCurrency } from '../libs/price';
26
+ import { getExchangeRateService } from '../libs/exchange-rate/service';
27
+ import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
28
+ import { isRateBelowMinAcceptableRate } from '../libs/slippage';
29
+ import { getQuoteService } from '../libs/quote-service';
30
+ import { events } from '../libs/event';
24
31
  import { isDelegationSufficientForPayment } from '../libs/payment';
25
32
 
26
33
  export interface AutoRechargeJobData {
@@ -136,8 +143,209 @@ export async function processAutoRecharge(job: AutoRechargeJobData) {
136
143
  return;
137
144
  }
138
145
 
139
- // 4. execute auto recharge
140
- await executeAutoRecharge(customer, config, currency);
146
+ // 4. Check dynamic pricing constraints
147
+ const isDynamicPricing = config.price.pricing_type === 'dynamic';
148
+ let rateResult: DynamicPricingCheckResult['rateResult'];
149
+
150
+ if (isDynamicPricing) {
151
+ const dynamicPricingResult = await checkDynamicPricingConstraints({
152
+ customer,
153
+ config,
154
+ });
155
+
156
+ if (!dynamicPricingResult.canProceed) {
157
+ // Emit event for notification
158
+ events.emit('auto_recharge.skipped', {
159
+ config_id: config.id,
160
+ customer_id: customerId,
161
+ reason: dynamicPricingResult.reason,
162
+ current_rate: dynamicPricingResult.currentRate,
163
+ min_acceptable_rate: config.slippage_config?.min_acceptable_rate,
164
+ // Additional context for notification
165
+ payment_currency_symbol: config.rechargeCurrency.symbol,
166
+ payment_currency_name: config.rechargeCurrency.name,
167
+ credit_currency_name: currency.name,
168
+ });
169
+
170
+ logger.info('Auto recharge skipped due to dynamic pricing constraints', {
171
+ customerId,
172
+ currencyId,
173
+ reason: dynamicPricingResult.reason,
174
+ currentRate: dynamicPricingResult.currentRate,
175
+ minAcceptableRate: config.slippage_config?.min_acceptable_rate,
176
+ });
177
+ return;
178
+ }
179
+
180
+ rateResult = dynamicPricingResult.rateResult;
181
+ }
182
+
183
+ // 5. execute auto recharge
184
+ await executeAutoRecharge(customer, config, currency, rateResult);
185
+ }
186
+
187
+ interface DynamicPricingCheckResult {
188
+ canProceed: boolean;
189
+ reason?: 'slippage_exceeded' | 'exchange_rate_not_supported' | 'exchange_rate_fetch_failed';
190
+ currentRate?: string;
191
+ rateResult?: {
192
+ rate: string;
193
+ provider_id: string;
194
+ provider_name: string;
195
+ timestamp_ms: number;
196
+ degraded: boolean;
197
+ consensus_method?: string;
198
+ providers?: Array<{
199
+ provider_id: string;
200
+ provider_name: string;
201
+ rate: string;
202
+ timestamp_ms: number;
203
+ degraded: boolean;
204
+ }>;
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Check if dynamic pricing constraints are met before creating quotes
210
+ * This only validates exchange rate availability and slippage threshold
211
+ * Quotes are created later when building the invoice
212
+ */
213
+ async function checkDynamicPricingConstraints({
214
+ customer,
215
+ config,
216
+ }: {
217
+ customer: Customer;
218
+ config: AutoRechargeConfig & {
219
+ paymentMethod: PaymentMethod;
220
+ price: TPriceExpanded;
221
+ rechargeCurrency: PaymentCurrency;
222
+ };
223
+ }): Promise<DynamicPricingCheckResult> {
224
+ const methodType = config.paymentMethod.type as ChainType;
225
+
226
+ // Check if exchange rate is supported
227
+ if (!hasTokenAddress(config.rechargeCurrency.symbol, methodType)) {
228
+ return {
229
+ canProceed: false,
230
+ reason: 'exchange_rate_not_supported',
231
+ };
232
+ }
233
+
234
+ // Get current exchange rate
235
+ let rateResult;
236
+ try {
237
+ const exchangeRateService = getExchangeRateService();
238
+ const rateSymbol = getExchangeRateSymbol(config.rechargeCurrency.symbol, methodType);
239
+ rateResult = await exchangeRateService.getRate(rateSymbol);
240
+ } catch (error: any) {
241
+ logger.warn('Failed to fetch exchange rate for auto-recharge', {
242
+ error: error.message,
243
+ symbol: config.rechargeCurrency.symbol,
244
+ customerId: customer.id,
245
+ });
246
+ return {
247
+ canProceed: false,
248
+ reason: 'exchange_rate_fetch_failed',
249
+ };
250
+ }
251
+
252
+ // Check slippage if config exists
253
+ if (config.slippage_config?.min_acceptable_rate) {
254
+ const rateBelowMin = isRateBelowMinAcceptableRate(rateResult.rate, config.slippage_config.min_acceptable_rate);
255
+
256
+ if (rateBelowMin) {
257
+ return {
258
+ canProceed: false,
259
+ reason: 'slippage_exceeded',
260
+ currentRate: rateResult.rate,
261
+ };
262
+ }
263
+ }
264
+
265
+ return {
266
+ canProceed: true,
267
+ currentRate: rateResult.rate,
268
+ rateResult,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Create quotes for each line item and enrich them with quote info
274
+ * This follows the same pattern as attachQuotesForInvoice in subscription.ts
275
+ */
276
+ async function attachQuotesForAutoRecharge(
277
+ lineItems: any[],
278
+ invoiceId: string,
279
+ config: AutoRechargeConfig & {
280
+ rechargeCurrency: PaymentCurrency;
281
+ slippage_config?: { percent?: number; min_acceptable_rate?: string };
282
+ },
283
+ rateResult: DynamicPricingCheckResult['rateResult']
284
+ ): Promise<{
285
+ enrichedLineItems: any[];
286
+ quotes: PriceQuote[];
287
+ totalAmount: BN;
288
+ }> {
289
+ const quoteService = getQuoteService();
290
+ const slippagePercent = config.slippage_config?.percent;
291
+
292
+ // Create quotes for each line item
293
+ const quoteResults = await Promise.all(
294
+ lineItems.map(async (item) => {
295
+ const quoteResponse = await quoteService.createQuoteWithRate({
296
+ price_id: item.price_id,
297
+ invoice_id: invoiceId,
298
+ target_currency_id: config.recharge_currency_id!,
299
+ quantity: item.quantity,
300
+ rateResult: rateResult!,
301
+ slippage_percent: slippagePercent,
302
+ });
303
+ return { item, quoteResponse };
304
+ })
305
+ );
306
+
307
+ // Build quote map for lookup
308
+ const quoteMap = new Map<string, (typeof quoteResults)[number]>();
309
+ quoteResults.forEach((result) => {
310
+ quoteMap.set(result.item.price_id, result);
311
+ });
312
+
313
+ // Enrich line items with quote info
314
+ // Only store quote_id in metadata - full quote info is attached dynamically when querying
315
+ // See: invoices.ts attachQuoteMetadataToLines()
316
+ const enrichedLineItems = lineItems.map((item) => {
317
+ const hit = quoteMap.get(item.price_id);
318
+ if (!hit) {
319
+ return item;
320
+ }
321
+ const { quoteResponse } = hit;
322
+ return {
323
+ ...item,
324
+ quote_id: quoteResponse.quote.id,
325
+ custom_amount: quoteResponse.computed_unit_amount,
326
+ // Only store quote_id in metadata, not the full quote object
327
+ metadata: {
328
+ ...(item.metadata || {}),
329
+ quote_id: quoteResponse.quote.id,
330
+ },
331
+ };
332
+ });
333
+
334
+ // Calculate total amount from quotes
335
+ const totalAmount = quoteResults.reduce((sum, result) => {
336
+ return sum.add(new BN(result.quoteResponse.computed_unit_amount));
337
+ }, new BN(0));
338
+
339
+ const quotes = quoteResults.map((r) => r.quoteResponse.quote);
340
+
341
+ logger.info('Created quotes for auto-recharge line items', {
342
+ configId: config.id,
343
+ quoteIds: quotes.map((q) => q.id),
344
+ totalAmount: totalAmount.toString(),
345
+ slippagePercent,
346
+ });
347
+
348
+ return { enrichedLineItems, quotes, totalAmount };
141
349
  }
142
350
 
143
351
  async function createInvoiceForAutoRecharge({
@@ -145,26 +353,31 @@ async function createInvoiceForAutoRecharge({
145
353
  config,
146
354
  currency,
147
355
  price,
148
- totalAmount,
149
356
  paymentMethod,
150
357
  rechargeCurrency,
358
+ isDynamicPricing,
359
+ rateResult,
151
360
  }: {
152
361
  customer: Customer;
153
362
  config: AutoRechargeConfig;
154
363
  currency: PaymentCurrency;
155
364
  price: TPriceExpanded;
156
- totalAmount: BN;
157
365
  paymentMethod: PaymentMethod;
158
366
  rechargeCurrency: PaymentCurrency;
367
+ isDynamicPricing: boolean;
368
+ rateResult?: DynamicPricingCheckResult['rateResult'];
159
369
  }) {
160
370
  const now = dayjs().unix();
161
371
  const expandedItems = await Price.expand([{ price_id: price.id, quantity: config.quantity }], {
162
372
  product: true,
163
373
  });
374
+
164
375
  let status = 'open';
165
376
  if (paymentMethod.type === 'stripe') {
166
377
  status = 'draft'; // stripe invoice will be finalized and paid automatically
167
378
  }
379
+
380
+ // Check for existing invoice
168
381
  const existInvoice = await Invoice.findOne({
169
382
  where: {
170
383
  customer_id: customer.id,
@@ -180,14 +393,55 @@ async function createInvoiceForAutoRecharge({
180
393
  customerId: customer.id,
181
394
  configId: config.id,
182
395
  });
183
- return existInvoice;
396
+ return { invoice: existInvoice, quotes: [] };
184
397
  }
398
+
399
+ // For dynamic pricing, create quotes for each line item
400
+ let lineItems = expandedItems;
401
+ let totalAmount: BN;
402
+ let quotes: PriceQuote[] = [];
403
+
404
+ if (isDynamicPricing && rateResult) {
405
+ // Generate a temporary invoice ID for quote creation
406
+ // We'll update the quotes with the actual invoice ID after creation
407
+ const tempInvoiceId = `temp-auto-recharge-${config.id}-${Date.now()}`;
408
+ const quoteResult = await attachQuotesForAutoRecharge(expandedItems, tempInvoiceId, config as any, rateResult);
409
+ lineItems = quoteResult.enrichedLineItems;
410
+ totalAmount = quoteResult.totalAmount;
411
+ quotes = quoteResult.quotes;
412
+ } else {
413
+ // Fixed pricing: calculate from price config
414
+ const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
415
+ totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 1));
416
+ }
417
+
418
+ // Build metadata
419
+ const invoiceMetadata: Record<string, any> = {
420
+ auto_recharge: {
421
+ config_id: config.id,
422
+ currency_id: rechargeCurrency.id,
423
+ },
424
+ };
425
+
426
+ const firstQuote = quotes.length > 0 ? quotes[0] : undefined;
427
+ if (firstQuote) {
428
+ invoiceMetadata.quote_ids = quotes.map((q) => q.id);
429
+ // Also store the first quote's info for backwards compatibility
430
+ invoiceMetadata.quote = {
431
+ quote_id: firstQuote.id,
432
+ exchange_rate: firstQuote.exchange_rate,
433
+ quoted_amount: firstQuote.quoted_amount,
434
+ max_payable_token: firstQuote.max_payable_token,
435
+ slippage_percent: firstQuote.slippage_percent,
436
+ };
437
+ }
438
+
185
439
  const { invoice } = await ensureInvoiceAndItems({
186
440
  customer,
187
441
  currency: rechargeCurrency,
188
442
  trialing: false,
189
443
  metered: false,
190
- lineItems: expandedItems,
444
+ lineItems,
191
445
  applyCredit: false,
192
446
  props: {
193
447
  status,
@@ -204,19 +458,26 @@ async function createInvoiceForAutoRecharge({
204
458
  custom_fields: [],
205
459
  footer: '',
206
460
  payment_settings: config.payment_settings,
207
- metadata: {
208
- auto_recharge: {
209
- config_id: config.id,
210
- currency_id: rechargeCurrency.id,
211
- },
212
- },
461
+ metadata: invoiceMetadata,
213
462
  } as unknown as Invoice,
214
463
  });
464
+
465
+ // Link all quotes to the actual invoice
466
+ if (quotes.length > 0) {
467
+ await Promise.all(quotes.map((q) => q.update({ invoice_id: invoice.id })));
468
+ logger.info('Quotes linked to auto recharge invoice', {
469
+ quoteIds: quotes.map((q) => q.id),
470
+ invoiceId: invoice.id,
471
+ });
472
+ }
473
+
215
474
  logger.info('New invoice created for auto recharge', {
216
475
  customerId: customer.id,
217
476
  configId: config.id,
218
477
  invoiceId: invoice.id,
219
- amount: totalAmount,
478
+ amount: totalAmount.toString(),
479
+ quoteIds: quotes.map((q) => q.id),
480
+ isDynamicPricing: quotes.length > 0,
220
481
  });
221
482
  if (status !== 'draft') {
222
483
  invoiceQueue.push({
@@ -225,7 +486,7 @@ async function createInvoiceForAutoRecharge({
225
486
  });
226
487
  logger.info('Invoice job scheduled for auto recharge', { invoice: invoice.id, customerId: customer.id });
227
488
  }
228
- return invoice;
489
+ return { invoice, quotes };
229
490
  }
230
491
 
231
492
  async function executeAutoRecharge(
@@ -235,15 +496,15 @@ async function executeAutoRecharge(
235
496
  price: TPriceExpanded;
236
497
  rechargeCurrency: PaymentCurrency;
237
498
  },
238
- currency: PaymentCurrency
499
+ currency: PaymentCurrency,
500
+ rateResult?: DynamicPricingCheckResult['rateResult']
239
501
  ) {
240
502
  const paymentMethod = config.paymentMethod!;
241
503
  const price = config.price! as TPriceExpanded;
242
504
  const rechargeCurrency = config.rechargeCurrency!;
505
+ const isDynamicPricing = price.pricing_type === 'dynamic';
243
506
 
244
507
  try {
245
- const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
246
- const totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 0));
247
508
  const paymentSettings = config.payment_settings;
248
509
  if (!paymentSettings) {
249
510
  throw new Error('No payment settings found for auto recharge');
@@ -253,12 +514,20 @@ async function executeAutoRecharge(
253
514
  throw new Error('No payer found for auto recharge');
254
515
  }
255
516
 
517
+ // Calculate totalAmount for balance check
518
+ const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
519
+ if (!priceAmount) {
520
+ throw new Error('Price amount is not valid');
521
+ }
522
+ const totalAmount = new BN(priceAmount).mul(new BN(config.quantity ?? 1)).toString();
523
+
524
+ // Check delegation balance for non-stripe payment methods
256
525
  if (paymentMethod.type !== 'stripe') {
257
526
  const balanceCheck = await isDelegationSufficientForPayment({
258
527
  paymentMethod,
259
528
  paymentCurrency: rechargeCurrency,
260
529
  userDid: payer,
261
- amount: totalAmount.toString(),
530
+ amount: totalAmount,
262
531
  });
263
532
 
264
533
  if (!balanceCheck.sufficient) {
@@ -266,22 +535,27 @@ async function executeAutoRecharge(
266
535
  customerId: customer.id,
267
536
  configId: config.id,
268
537
  reason: balanceCheck.reason,
269
- totalAmount: totalAmount.toString(),
538
+ totalAmount,
270
539
  payer,
271
540
  });
272
541
  throw new Error(`Insufficient balance: ${balanceCheck.reason}`);
273
542
  }
274
543
  }
275
544
 
276
- const invoice = await createInvoiceForAutoRecharge({
545
+ const result = await createInvoiceForAutoRecharge({
277
546
  customer,
278
547
  config,
279
548
  currency,
280
549
  price,
281
- totalAmount,
282
550
  paymentMethod,
283
551
  rechargeCurrency,
552
+ isDynamicPricing,
553
+ rateResult,
284
554
  });
555
+
556
+ // Handle both return types (existing invoice or new invoice)
557
+ const invoice = 'invoice' in result ? result.invoice : result;
558
+
285
559
  if (paymentMethod.type === 'stripe') {
286
560
  await createStripeInvoiceForAutoRecharge({
287
561
  autoRechargeConfig: config,
@@ -0,0 +1,242 @@
1
+ import createQueue from '../libs/queue';
2
+ import logger from '../libs/logger';
3
+ import { ExchangeRateProvider } from '../store/models/exchange-rate-provider';
4
+ import { getExchangeRateService } from '../libs/exchange-rate';
5
+ import { createEvent } from '../libs/audit';
6
+
7
+ // Representative tokens to test for health checks
8
+ const REPRESENTATIVE_TOKENS = ['ABT', 'ETH', 'USDC'];
9
+
10
+ interface HealthCheckJob {
11
+ type: 'health_check';
12
+ timestamp: number;
13
+ }
14
+
15
+ interface HealthCheckResult {
16
+ providerId: string;
17
+ providerName: string;
18
+ success: boolean;
19
+ testedTokens: string[];
20
+ failedTokens: string[];
21
+ error?: string;
22
+ }
23
+
24
+ /**
25
+ * Perform health check on all active exchange rate providers
26
+ */
27
+ async function performHealthCheck(): Promise<HealthCheckResult[]> {
28
+ logger.info('Starting exchange rate provider health check');
29
+
30
+ const providers = await ExchangeRateProvider.findAll({
31
+ where: { enabled: true },
32
+ order: [['priority', 'ASC']],
33
+ });
34
+
35
+ if (providers.length === 0) {
36
+ logger.warn('No enabled exchange rate providers found');
37
+ return [];
38
+ }
39
+
40
+ const results: HealthCheckResult[] = [];
41
+ const exchangeRateService = getExchangeRateService();
42
+
43
+ // Note: Sequential provider testing is intentional for health check isolation
44
+ // eslint-disable-next-line no-restricted-syntax
45
+ for (const provider of providers) {
46
+ const result: HealthCheckResult = {
47
+ providerId: provider.id,
48
+ providerName: provider.name,
49
+ success: true,
50
+ testedTokens: [],
51
+ failedTokens: [],
52
+ };
53
+
54
+ let hasSuccess = false;
55
+
56
+ // eslint-disable-next-line no-restricted-syntax
57
+ for (const symbol of REPRESENTATIVE_TOKENS) {
58
+ try {
59
+ // Clear cache to force fresh fetch
60
+ exchangeRateService.clearCache(symbol);
61
+
62
+ // Test fetching rate
63
+ // eslint-disable-next-line no-await-in-loop
64
+ await exchangeRateService.getRate(symbol);
65
+
66
+ result.testedTokens.push(symbol);
67
+ hasSuccess = true;
68
+
69
+ logger.debug('Health check success', {
70
+ provider: provider.name,
71
+ symbol,
72
+ });
73
+ } catch (error: any) {
74
+ result.failedTokens.push(symbol);
75
+
76
+ logger.warn('Health check failed for token', {
77
+ provider: provider.name,
78
+ symbol,
79
+ error: error.message,
80
+ });
81
+ }
82
+ }
83
+
84
+ result.success = hasSuccess;
85
+ if (!hasSuccess) {
86
+ result.error = `Failed to fetch rates for all representative tokens: ${REPRESENTATIVE_TOKENS.join(', ')}`;
87
+ }
88
+
89
+ results.push(result);
90
+ }
91
+
92
+ return results;
93
+ }
94
+
95
+ /**
96
+ * Send alert notification when all providers are unavailable
97
+ */
98
+ async function sendProviderAlert(results: HealthCheckResult[]): Promise<void> {
99
+ const allProvidersFailed = results.every((r) => !r.success);
100
+
101
+ if (!allProvidersFailed) {
102
+ return;
103
+ }
104
+
105
+ const alertData = {
106
+ severity: 'critical',
107
+ title: 'All Exchange Rate Providers Unavailable',
108
+ message: 'All enabled exchange rate providers have failed health checks. Dynamic pricing is currently unavailable.',
109
+ timestamp: new Date().toISOString(),
110
+ providers: results.map((r) => ({
111
+ id: r.providerId,
112
+ name: r.providerName,
113
+ failedTokens: r.failedTokens,
114
+ error: r.error,
115
+ })),
116
+ impact: 'Users cannot create or view dynamic pricing quotes',
117
+ action: 'Please check provider configurations and network connectivity',
118
+ };
119
+
120
+ // Log critical alert
121
+ logger.error('CRITICAL: All exchange rate providers unavailable', alertData);
122
+
123
+ // Create audit event - this will trigger webhooks and emit event internally
124
+ try {
125
+ await createEvent('System', 'exchange_rate.providers_unavailable', alertData, {});
126
+ } catch (err) {
127
+ logger.error('Failed to create audit event for provider alert', { error: err });
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Log health check results
133
+ *
134
+ * Note: We don't call recordFailure/recordSuccess here because
135
+ * exchangeRateService.getRate() already handles provider status updates internally.
136
+ * Calling them here would cause duplicate updates (failure_count += 2 instead of 1).
137
+ */
138
+ function logHealthCheckResults(results: HealthCheckResult[]): void {
139
+ for (const result of results) {
140
+ if (result.success) {
141
+ logger.info('Provider health check passed', {
142
+ provider: result.providerName,
143
+ testedTokens: result.testedTokens,
144
+ });
145
+ } else {
146
+ logger.warn('Provider health check failed', {
147
+ provider: result.providerName,
148
+ failedTokens: result.failedTokens,
149
+ error: result.error,
150
+ });
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Main health check job handler
157
+ */
158
+ async function handleHealthCheckJob(job: HealthCheckJob): Promise<void> {
159
+ logger.info('Executing exchange rate provider health check job', { job });
160
+
161
+ try {
162
+ const results = await performHealthCheck();
163
+
164
+ // Log health check results (provider status already updated by service.getRate())
165
+ logHealthCheckResults(results);
166
+
167
+ // Send alert if all providers failed
168
+ await sendProviderAlert(results);
169
+
170
+ const failedCount = results.filter((r) => !r.success).length;
171
+ const successCount = results.length - failedCount;
172
+
173
+ logger.info('Health check completed', {
174
+ total: results.length,
175
+ success: successCount,
176
+ failed: failedCount,
177
+ timestamp: job.timestamp,
178
+ });
179
+ } catch (error: any) {
180
+ logger.error('Health check job failed', {
181
+ error: error.message,
182
+ stack: error.stack,
183
+ });
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ // Create the queue with scheduled job support
189
+ export const exchangeRateHealthQueue = createQueue<HealthCheckJob>({
190
+ name: 'exchange-rate-health',
191
+ onJob: handleHealthCheckJob,
192
+ options: {
193
+ concurrency: 1,
194
+ maxRetries: 3,
195
+ retryDelay: 60 * 1000, // 1 minute
196
+ enableScheduledJob: true,
197
+ },
198
+ });
199
+
200
+ // Schedule health checks every 6 hours
201
+ const HEALTH_CHECK_INTERVAL = 6 * 60 * 60; // 6 hours in seconds
202
+
203
+ /**
204
+ * Initialize and schedule periodic health checks
205
+ */
206
+ export function scheduleHealthChecks(): void {
207
+ const scheduleNext = () => {
208
+ const now = Math.floor(Date.now() / 1000);
209
+ const nextRun = now + HEALTH_CHECK_INTERVAL;
210
+
211
+ exchangeRateHealthQueue.push({
212
+ job: {
213
+ type: 'health_check',
214
+ timestamp: Date.now(),
215
+ },
216
+ runAt: nextRun,
217
+ persist: true,
218
+ });
219
+
220
+ logger.info('Scheduled next exchange rate health check', {
221
+ nextRunAt: new Date(nextRun * 1000).toISOString(),
222
+ intervalHours: HEALTH_CHECK_INTERVAL / 3600,
223
+ });
224
+ };
225
+
226
+ // Schedule initial check
227
+ scheduleNext();
228
+
229
+ // Listen for completed checks and schedule next one
230
+ exchangeRateHealthQueue.on('finished', () => {
231
+ scheduleNext();
232
+ });
233
+
234
+ logger.info('Exchange rate health check scheduling initialized', {
235
+ intervalHours: HEALTH_CHECK_INTERVAL / 3600,
236
+ });
237
+ }
238
+
239
+ // Export for explicit initialization in index.ts
240
+ export function startExchangeRateHealthQueue(): void {
241
+ scheduleHealthChecks();
242
+ }