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.
Files changed (117) hide show
  1. package/api/src/crons/overdue-detection.ts +10 -1
  2. package/api/src/index.ts +3 -0
  3. package/api/src/libs/credit-utils.ts +21 -0
  4. package/api/src/libs/discount/discount.ts +13 -0
  5. package/api/src/libs/env.ts +5 -0
  6. package/api/src/libs/error.ts +14 -0
  7. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  8. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  9. package/api/src/libs/exchange-rate/index.ts +5 -0
  10. package/api/src/libs/exchange-rate/service.ts +583 -0
  11. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  12. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  13. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  14. package/api/src/libs/exchange-rate/types.ts +114 -0
  15. package/api/src/libs/exchange-rate/validator.ts +319 -0
  16. package/api/src/libs/invoice-quote.ts +158 -0
  17. package/api/src/libs/invoice.ts +143 -7
  18. package/api/src/libs/math-utils.ts +46 -0
  19. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  20. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  21. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  22. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  23. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  24. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  25. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  26. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  27. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  28. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  29. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  30. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  31. package/api/src/libs/payment.ts +1 -1
  32. package/api/src/libs/price.ts +4 -1
  33. package/api/src/libs/queue/index.ts +8 -0
  34. package/api/src/libs/quote-service.ts +1132 -0
  35. package/api/src/libs/quote-validation.ts +388 -0
  36. package/api/src/libs/session.ts +686 -39
  37. package/api/src/libs/slippage.ts +135 -0
  38. package/api/src/libs/subscription.ts +185 -15
  39. package/api/src/libs/util.ts +64 -3
  40. package/api/src/locales/en.ts +50 -0
  41. package/api/src/locales/zh.ts +48 -0
  42. package/api/src/queues/auto-recharge.ts +295 -21
  43. package/api/src/queues/exchange-rate-health.ts +242 -0
  44. package/api/src/queues/invoice.ts +48 -1
  45. package/api/src/queues/notification.ts +190 -3
  46. package/api/src/queues/payment.ts +177 -7
  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/meter-events.ts +3 -0
  66. package/api/src/routes/payment-links.ts +13 -0
  67. package/api/src/routes/prices.ts +84 -2
  68. package/api/src/routes/subscriptions.ts +526 -15
  69. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  70. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  71. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  72. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  73. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  74. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  75. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  76. package/api/src/store/models/auto-recharge-config.ts +12 -0
  77. package/api/src/store/models/checkout-session.ts +7 -0
  78. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  79. package/api/src/store/models/index.ts +6 -0
  80. package/api/src/store/models/payment-intent.ts +6 -0
  81. package/api/src/store/models/price-quote.ts +284 -0
  82. package/api/src/store/models/price.ts +53 -5
  83. package/api/src/store/models/subscription.ts +11 -0
  84. package/api/src/store/models/types.ts +61 -1
  85. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  86. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  87. package/api/tests/libs/quote-service.spec.ts +199 -0
  88. package/api/tests/libs/session.spec.ts +464 -0
  89. package/api/tests/libs/slippage.spec.ts +109 -0
  90. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  91. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  92. package/api/tests/models/price-dynamic.spec.ts +100 -0
  93. package/api/tests/models/price-quote.spec.ts +112 -0
  94. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  95. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  96. package/blocklet.yml +1 -1
  97. package/package.json +7 -6
  98. package/src/components/customer/credit-overview.tsx +14 -0
  99. package/src/components/discount/discount-info.tsx +8 -2
  100. package/src/components/invoice/list.tsx +146 -16
  101. package/src/components/invoice/table.tsx +276 -71
  102. package/src/components/invoice-pdf/template.tsx +3 -7
  103. package/src/components/metadata/form.tsx +6 -8
  104. package/src/components/price/form.tsx +519 -149
  105. package/src/components/promotion/active-redemptions.tsx +5 -3
  106. package/src/components/quote/info.tsx +234 -0
  107. package/src/hooks/subscription.ts +132 -2
  108. package/src/locales/en.tsx +145 -0
  109. package/src/locales/zh.tsx +143 -1
  110. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  111. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  112. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  113. package/src/pages/admin/products/index.tsx +12 -1
  114. package/src/pages/customer/invoice/detail.tsx +36 -12
  115. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  116. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  117. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -6,6 +6,7 @@ import { createEvent } from '../libs/audit';
6
6
  import { ensurePassportRevoked } from '../integrations/blocklet/passport';
7
7
  import { batchHandleStripeSubscriptions } from '../integrations/stripe/resource';
8
8
  import { wallet } from '../libs/auth';
9
+ import { getExchangeRateService } from '../libs/exchange-rate';
9
10
  import dayjs from '../libs/dayjs';
10
11
  import { events } from '../libs/event';
11
12
  import { getLock } from '../libs/lock';
@@ -13,6 +14,7 @@ import logger from '../libs/logger';
13
14
  import { getGasPayerExtra, isDelegationSufficientForPayment } from '../libs/payment';
14
15
  import createQueue from '../libs/queue';
15
16
  import { getStatementDescriptor } from '../libs/session';
17
+ import { NonRetryableError } from '../libs/error';
16
18
  import {
17
19
  checkRemainingStake,
18
20
  checkUsageReportEmpty,
@@ -30,9 +32,11 @@ import {
30
32
  } from '../libs/subscription';
31
33
  import { resetPeriodGrantCounter } from '../libs/credit-schedule';
32
34
  import { ensureInvoiceAndItems, migrateSubscriptionPaymentMethodInvoice } from '../libs/invoice';
35
+ import { getQuoteService } from '../libs/quote-service';
36
+ import { isRateBelowMinAcceptableRate } from '../libs/slippage';
33
37
  import { PaymentCurrency, PaymentIntent, PaymentMethod, Refund, SetupIntent, UsageRecord } from '../store/models';
34
38
  import { Customer } from '../store/models/customer';
35
- import { Invoice } from '../store/models/invoice';
39
+ import { Invoice, nextInvoiceId } from '../store/models/invoice';
36
40
  import { Price } from '../store/models/price';
37
41
  import { Subscription } from '../store/models/subscription';
38
42
  import { SubscriptionItem } from '../store/models/subscription-item';
@@ -40,6 +44,8 @@ import { getValidDiscountsForSubscriptionBilling } from '../libs/discount/coupon
40
44
  import { invoiceQueue } from './invoice';
41
45
  import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-canceled';
42
46
  import { applySubscriptionDiscount } from '../libs/discount/discount';
47
+ import type { ChainType, TLineItemExpanded } from '../store/models';
48
+ import { getExchangeRateSymbol } from '../libs/exchange-rate/token-address-mapping';
43
49
 
44
50
  type SubscriptionJob = {
45
51
  subscriptionId: string;
@@ -48,6 +54,105 @@ type SubscriptionJob = {
48
54
 
49
55
  const EXPECTED_SUBSCRIPTION_STATUS = ['trialing', 'active', 'paused', 'past_due'];
50
56
 
57
+ async function attachQuotesForInvoice(
58
+ lineItems: TLineItemExpanded[],
59
+ currencyId: string,
60
+ invoiceId: string,
61
+ slippageConfig?: { percent?: number; min_acceptable_rate?: string }
62
+ ): Promise<{
63
+ lineItems: TLineItemExpanded[];
64
+ quotes: any[];
65
+ slippageCheck?: { belowThreshold: boolean; currentRate?: string; minAcceptableRate?: string };
66
+ }> {
67
+ // Filter dynamic items, excluding items with zero or negative quantity
68
+ // This matches the logic in invoice-quote.ts:generateQuotesForInvoiceItems
69
+ // to ensure quote creation is consistent with actual payment amount
70
+ const dynamicItems = lineItems.filter((item) => {
71
+ const price = (item as any)?.upsell_price || (item as any)?.price;
72
+ if (price?.pricing_type !== 'dynamic') {
73
+ return false;
74
+ }
75
+ // Skip items with zero or negative quantity (no usage, no payment needed)
76
+ if (item.quantity <= 0) {
77
+ return false;
78
+ }
79
+ return true;
80
+ });
81
+ if (!dynamicItems.length) {
82
+ return { lineItems, quotes: [] };
83
+ }
84
+
85
+ const currency = (await PaymentCurrency.findByPk(currencyId, {
86
+ include: [{ model: PaymentMethod, as: 'payment_method' }],
87
+ })) as PaymentCurrency & { payment_method: PaymentMethod };
88
+ if (!currency) {
89
+ throw new NonRetryableError('CURRENCY_NOT_FOUND', `Currency ${currencyId} not found`);
90
+ }
91
+
92
+ if (currency.payment_method?.type === 'stripe') {
93
+ return { lineItems, quotes: [] };
94
+ }
95
+
96
+ const exchangeRateService = getExchangeRateService();
97
+ const quoteService = getQuoteService();
98
+ // For ArcBlock payment method, always use ABT for exchange rate
99
+ const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
100
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
101
+
102
+ const rateBelowSlippage = isRateBelowMinAcceptableRate(rateResult.rate, slippageConfig?.min_acceptable_rate);
103
+
104
+ const quoteResults = await Promise.all(
105
+ dynamicItems.map(async (item) => {
106
+ const targetPrice: any = (item as any).upsell_price || (item as any).price;
107
+ const quoteResponse = await quoteService.createQuoteWithRate({
108
+ price_id: targetPrice.id,
109
+ invoice_id: invoiceId,
110
+ target_currency_id: currencyId,
111
+ quantity: item.quantity,
112
+ rateResult,
113
+ slippage_percent: slippageConfig?.percent,
114
+ min_acceptable_rate: undefined,
115
+ });
116
+ return { item, quoteResponse };
117
+ })
118
+ );
119
+
120
+ const quoteMap = new Map<string, (typeof quoteResults)[number]>();
121
+ quoteResults.forEach((result) => {
122
+ const targetPrice: any = (result.item as any).upsell_price || (result.item as any).price;
123
+ quoteMap.set(targetPrice.id, result);
124
+ });
125
+
126
+ const enriched = lineItems.map((item) => {
127
+ const targetPrice: any = (item as any).upsell_price || (item as any).price;
128
+ const hit = quoteMap.get(targetPrice?.id);
129
+ if (!hit) {
130
+ return item;
131
+ }
132
+ const { quoteResponse } = hit;
133
+ return {
134
+ ...item,
135
+ quote_id: quoteResponse.quote.id,
136
+ quoted_amount: quoteResponse.computed_unit_amount,
137
+ expires_at: quoteResponse.expires_at,
138
+ exchange_rate: quoteResponse.quote.exchange_rate,
139
+ rate_provider_name: quoteResponse.quote.rate_provider_name,
140
+ rate_provider_id: quoteResponse.quote.rate_provider_id,
141
+ custom_amount: quoteResponse.computed_unit_amount,
142
+ } as any;
143
+ });
144
+
145
+ return {
146
+ lineItems: enriched,
147
+ quotes: quoteResults.map((x) => x.quoteResponse.quote),
148
+ slippageCheck: {
149
+ belowThreshold: rateBelowSlippage,
150
+ currentRate: rateResult.rate,
151
+ minAcceptableRate: slippageConfig?.min_acceptable_rate,
152
+ },
153
+ };
154
+ }
155
+
51
156
  const doHandleSubscriptionInvoice = async ({
52
157
  subscription,
53
158
  filter,
@@ -194,6 +299,21 @@ const doHandleSubscriptionInvoice = async ({
194
299
  return null;
195
300
  }
196
301
 
302
+ const invoiceId = nextInvoiceId();
303
+ // Get slippage config from subscription
304
+ const slippageConfig = subscription.slippage_config as { percent?: number; min_acceptable_rate?: string } | undefined;
305
+ const {
306
+ lineItems: itemsWithQuotes,
307
+ quotes,
308
+ slippageCheck,
309
+ } = await attachQuotesForInvoice(
310
+ expandedItems as TLineItemExpanded[],
311
+ subscription.currency_id,
312
+ invoiceId,
313
+ slippageConfig
314
+ );
315
+ expandedItems = itemsWithQuotes as any[];
316
+
197
317
  // Get valid discounts for this subscription billing period
198
318
  const { validDiscounts, expiredDiscounts } = await getValidDiscountsForSubscriptionBilling({
199
319
  subscriptionId: subscription.id,
@@ -279,6 +399,9 @@ const doHandleSubscriptionInvoice = async ({
279
399
  });
280
400
  }
281
401
 
402
+ const shouldMarkOverdue = slippageCheck?.belowThreshold === true;
403
+ const invoiceStatus = shouldMarkOverdue && status === 'open' ? 'uncollectible' : status;
404
+
282
405
  const { invoice } = await ensureInvoiceAndItems({
283
406
  customer,
284
407
  currency,
@@ -287,15 +410,17 @@ const doHandleSubscriptionInvoice = async ({
287
410
  metered: true,
288
411
  lineItems: enhancedLineItems,
289
412
  props: {
413
+ id: invoiceId,
290
414
  livemode: subscription.livemode,
291
415
  description: `Subscription ${reason}`,
292
416
  statement_descriptor: getStatementDescriptor(expandedItems),
293
417
  period_start: start,
294
418
  period_end: end,
295
419
  auto_advance: true,
296
- status,
420
+ status: invoiceStatus,
297
421
  billing_reason: `subscription_${reason}`,
298
422
  currency_id: subscription.currency_id,
423
+ quote_id: quotes[0]?.id,
299
424
  // Set correct subtotal (original amount) and total (after discount)
300
425
  subtotal: baseAmount.total,
301
426
  total: safeFinalTotal,
@@ -306,10 +431,83 @@ const doHandleSubscriptionInvoice = async ({
306
431
  total_discount_amounts: discountBreakdownForInvoice,
307
432
  metadata: {
308
433
  ...metadata,
434
+ ...(slippageCheck
435
+ ? {
436
+ slippage: {
437
+ below_threshold: slippageCheck.belowThreshold,
438
+ min_acceptable_rate: slippageCheck.minAcceptableRate,
439
+ rate_at_invoice: slippageCheck.currentRate,
440
+ },
441
+ }
442
+ : {}),
443
+ quote_ids: quotes.map((q) => q.id),
309
444
  },
310
445
  } as unknown as Invoice,
311
446
  });
312
447
 
448
+ // When slippage exceeded, create PaymentIntent with requires_action status for manual payment
449
+ if (shouldMarkOverdue && invoice) {
450
+ const paymentIntent = await PaymentIntent.create({
451
+ livemode: !!invoice.livemode,
452
+ amount: invoice.amount_remaining || safeFinalTotal,
453
+ amount_received: '0',
454
+ amount_capturable: '0',
455
+ customer_id: invoice.customer_id,
456
+ description: 'Slippage exceeded - manual payment required',
457
+ currency_id: invoice.currency_id,
458
+ payment_method_id: invoice.default_payment_method_id,
459
+ invoice_id: invoice.id,
460
+ status: 'requires_action',
461
+ capture_method: 'manual',
462
+ confirmation_method: 'manual',
463
+ payment_method_types: [],
464
+ receipt_email: customer.email,
465
+ statement_descriptor: invoice.statement_descriptor,
466
+ statement_descriptor_suffix: '',
467
+ setup_future_usage: 'on_session',
468
+ metadata: {
469
+ slippage_exceeded: true,
470
+ current_rate: slippageCheck?.currentRate,
471
+ min_acceptable_rate: slippageCheck?.minAcceptableRate,
472
+ },
473
+ });
474
+
475
+ await invoice.update({ payment_intent_id: paymentIntent.id });
476
+
477
+ logger.info('PaymentIntent created for slippage exceeded invoice', {
478
+ invoiceId: invoice.id,
479
+ paymentIntentId: paymentIntent.id,
480
+ amount: paymentIntent.amount,
481
+ currentRate: slippageCheck?.currentRate,
482
+ minAcceptableRate: slippageCheck?.minAcceptableRate,
483
+ });
484
+
485
+ // Send notification for slippage exceeded
486
+ events.emit('subscription.slippage_exceeded', subscription, {
487
+ invoiceId: invoice.id,
488
+ paymentIntentId: paymentIntent.id,
489
+ currentRate: slippageCheck?.currentRate,
490
+ minAcceptableRate: slippageCheck?.minAcceptableRate,
491
+ });
492
+ }
493
+
494
+ if (shouldMarkOverdue && !['past_due', 'canceled', 'incomplete_expired'].includes(subscription.status)) {
495
+ await subscription.update({
496
+ status: 'past_due',
497
+ cancelation_details: {
498
+ comment: 'past_due',
499
+ feedback: 'other',
500
+ reason: 'slippage_below_threshold',
501
+ },
502
+ });
503
+ logger.warn('Subscription moved to past_due due to slippage threshold', {
504
+ subscription: subscription.id,
505
+ invoice: invoice.id,
506
+ currentRate: slippageCheck?.currentRate,
507
+ minAcceptableRate: slippageCheck?.minAcceptableRate,
508
+ });
509
+ }
510
+
313
511
  logger.info('Invoice created for subscription', { invoice: invoice.id, subscription: subscription.id });
314
512
 
315
513
  return invoice;
@@ -318,9 +516,26 @@ const doHandleSubscriptionInvoice = async ({
318
516
  export async function handleSubscriptionInvoice(args: Parameters<typeof doHandleSubscriptionInvoice>[0]) {
319
517
  const lock = getLock(`${args.subscription.id}-invoice`);
320
518
  await lock.acquire();
321
- const result = await doHandleSubscriptionInvoice(args);
322
- lock.release();
323
- return result;
519
+ try {
520
+ const result = await doHandleSubscriptionInvoice(args);
521
+ return result;
522
+ } catch (error: any) {
523
+ // Log non-retryable errors with more context
524
+ if (error instanceof NonRetryableError) {
525
+ logger.error('Non-retryable error in subscription invoice generation', {
526
+ subscriptionId: args.subscription.id,
527
+ errorCode: error.code,
528
+ errorMessage: error.message,
529
+ reason: args.reason,
530
+ periodStart: args.start,
531
+ periodEnd: args.end,
532
+ });
533
+ }
534
+ throw error;
535
+ } finally {
536
+ // Always release lock, even if error occurs
537
+ lock.release();
538
+ }
324
539
  }
325
540
 
326
541
  const handleSubscriptionBeforeCancel = async (subscription: Subscription) => {
@@ -1513,11 +1728,24 @@ export async function addSubscriptionJob(
1513
1728
  }
1514
1729
  if (action === 'cycle') {
1515
1730
  if (!cycleJob || (replace && cycleJob)) {
1731
+ const renewalTime = runAt || subscription.current_period_end;
1516
1732
  await subscriptionQueue[fn]({
1517
1733
  id: jobId,
1518
1734
  job: { subscriptionId: subscription.id, action },
1519
- runAt: runAt || subscription.current_period_end,
1735
+ runAt: renewalTime,
1520
1736
  });
1737
+
1738
+ // Schedule slippage pre-check 1 hour before renewal for dynamic pricing subscriptions
1739
+ try {
1740
+ await scheduleSlippagePreCheck(subscription, renewalTime);
1741
+ } catch (error) {
1742
+ // Log but don't fail the main job scheduling
1743
+ logger.error('Failed to schedule slippage pre-check', {
1744
+ subscriptionId: subscription.id,
1745
+ renewalTime,
1746
+ error,
1747
+ });
1748
+ }
1521
1749
  }
1522
1750
  } else {
1523
1751
  await subscriptionQueue.delete(jobId);
@@ -1526,6 +1754,18 @@ export async function addSubscriptionJob(
1526
1754
  job: { subscriptionId: subscription.id, action },
1527
1755
  runAt: runAt || subscription.current_period_end,
1528
1756
  });
1757
+
1758
+ // Cancel slippage pre-check when subscription is being cancelled
1759
+ if (action === 'cancel') {
1760
+ try {
1761
+ await cancelSlippagePreCheck(subscription.id);
1762
+ } catch (error) {
1763
+ logger.error('Failed to cancel slippage pre-check', {
1764
+ subscriptionId: subscription.id,
1765
+ error,
1766
+ });
1767
+ }
1768
+ }
1529
1769
  }
1530
1770
  }
1531
1771
 
@@ -1724,3 +1964,193 @@ events.on('setup_intent.succeeded', async (setupIntent: SetupIntent) => {
1724
1964
  }
1725
1965
  }
1726
1966
  });
1967
+
1968
+ // ============= Slippage Pre-Check Job =============
1969
+ // Checks slippage 1 hour before subscription renewal and sends warning notification if below threshold
1970
+
1971
+ type SlippagePreCheckJob = {
1972
+ subscriptionId: string;
1973
+ renewalTime: number;
1974
+ };
1975
+
1976
+ /**
1977
+ * Handle slippage pre-check: Check if current exchange rate is below min acceptable rate
1978
+ * before subscription renewal and send warning notification
1979
+ */
1980
+ async function handleSlippagePreCheck(job: SlippagePreCheckJob): Promise<void> {
1981
+ const { subscriptionId, renewalTime } = job;
1982
+
1983
+ const subscription = await Subscription.findByPk(subscriptionId);
1984
+ if (!subscription) {
1985
+ logger.warn('Slippage pre-check: Subscription not found', { subscriptionId });
1986
+ return;
1987
+ }
1988
+
1989
+ // Skip if subscription is not in active status
1990
+ if (!['active', 'trialing'].includes(subscription.status)) {
1991
+ logger.info('Slippage pre-check: Skipping non-active subscription', {
1992
+ subscriptionId,
1993
+ status: subscription.status,
1994
+ });
1995
+ return;
1996
+ }
1997
+
1998
+ // Skip if no slippage config - having slippage_config means it's a dynamic pricing subscription
1999
+ const slippageConfig = subscription.slippage_config;
2000
+ if (!slippageConfig || !slippageConfig.min_acceptable_rate) {
2001
+ logger.info('Slippage pre-check: No slippage config or min_acceptable_rate', { subscriptionId });
2002
+ return;
2003
+ }
2004
+
2005
+ // Get payment currency and payment method
2006
+ const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
2007
+ if (!paymentCurrency) {
2008
+ logger.info('Slippage pre-check: Payment currency not found', { subscriptionId });
2009
+ return;
2010
+ }
2011
+
2012
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
2013
+ if (!paymentMethod) {
2014
+ logger.info('Slippage pre-check: Payment method not found', { subscriptionId });
2015
+ return;
2016
+ }
2017
+
2018
+ // Get current exchange rate
2019
+ try {
2020
+ const exchangeRateService = getExchangeRateService();
2021
+ const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, paymentMethod.type as any);
2022
+
2023
+ if (!rateSymbol) {
2024
+ logger.info('Slippage pre-check: No exchange rate symbol for currency', {
2025
+ subscriptionId,
2026
+ currencyId: paymentCurrency.id,
2027
+ symbol: paymentCurrency.symbol,
2028
+ });
2029
+ return;
2030
+ }
2031
+
2032
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
2033
+ const currentRate = rateResult?.rate;
2034
+
2035
+ if (!currentRate) {
2036
+ logger.warn('Slippage pre-check: Failed to get current exchange rate', {
2037
+ subscriptionId,
2038
+ rateSymbol,
2039
+ });
2040
+ return;
2041
+ }
2042
+
2043
+ const belowThreshold = isRateBelowMinAcceptableRate(String(currentRate), slippageConfig.min_acceptable_rate);
2044
+
2045
+ if (belowThreshold) {
2046
+ // Emit warning notification event
2047
+ events.emit('subscription.slippage_warning', subscription, {
2048
+ currentRate: String(currentRate),
2049
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
2050
+ renewalTime,
2051
+ });
2052
+
2053
+ logger.info('Slippage pre-check: Warning sent due to rate below threshold', {
2054
+ subscriptionId,
2055
+ currentRate,
2056
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
2057
+ renewalTime,
2058
+ });
2059
+ } else {
2060
+ logger.info('Slippage pre-check: Rate is acceptable', {
2061
+ subscriptionId,
2062
+ currentRate,
2063
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
2064
+ });
2065
+ }
2066
+ } catch (error) {
2067
+ logger.error('Slippage pre-check: Failed to check exchange rate', {
2068
+ subscriptionId,
2069
+ error,
2070
+ });
2071
+ }
2072
+ }
2073
+
2074
+ export const slippagePreCheckQueue = createQueue<SlippagePreCheckJob>({
2075
+ name: 'slippage_pre_check',
2076
+ onJob: handleSlippagePreCheck,
2077
+ options: {
2078
+ concurrency: 10,
2079
+ maxRetries: 3,
2080
+ retryDelay: 60000, // 1 minute
2081
+ maxTimeout: 60000,
2082
+ enableScheduledJob: true,
2083
+ },
2084
+ });
2085
+
2086
+ /**
2087
+ * Schedule slippage pre-check job 1 hour before subscription renewal
2088
+ * Should be called when scheduling the cycle job
2089
+ */
2090
+ export async function scheduleSlippagePreCheck(subscription: Subscription, renewalTime: number): Promise<void> {
2091
+ // Only schedule for subscriptions with slippage config and min_acceptable_rate
2092
+ // Having slippage_config means it's a dynamic pricing subscription
2093
+ const slippageConfig = subscription.slippage_config;
2094
+ if (!slippageConfig || !slippageConfig.min_acceptable_rate) {
2095
+ return;
2096
+ }
2097
+
2098
+ // Schedule pre-check 1 hour before renewal
2099
+ const preCheckTime = renewalTime - 3600; // 1 hour before
2100
+ const now = Math.floor(Date.now() / 1000);
2101
+
2102
+ // Only schedule if pre-check time is in the future
2103
+ if (preCheckTime <= now) {
2104
+ logger.info('Slippage pre-check: Pre-check time is in the past, skipping', {
2105
+ subscriptionId: subscription.id,
2106
+ renewalTime,
2107
+ preCheckTime,
2108
+ now,
2109
+ });
2110
+ return;
2111
+ }
2112
+
2113
+ const jobId = `slippage-pre-check-${subscription.id}-${renewalTime}`;
2114
+
2115
+ // Delete existing job if any
2116
+ await slippagePreCheckQueue.delete(jobId);
2117
+
2118
+ await slippagePreCheckQueue.push({
2119
+ id: jobId,
2120
+ job: {
2121
+ subscriptionId: subscription.id,
2122
+ renewalTime,
2123
+ },
2124
+ runAt: preCheckTime,
2125
+ });
2126
+
2127
+ logger.info('Slippage pre-check: Job scheduled', {
2128
+ subscriptionId: subscription.id,
2129
+ renewalTime,
2130
+ preCheckTime,
2131
+ jobId,
2132
+ });
2133
+ }
2134
+
2135
+ /**
2136
+ * Cancel slippage pre-check job for a subscription
2137
+ */
2138
+ export async function cancelSlippagePreCheck(subscriptionId: string, renewalTime?: number): Promise<void> {
2139
+ if (renewalTime) {
2140
+ const jobId = `slippage-pre-check-${subscriptionId}-${renewalTime}`;
2141
+ await slippagePreCheckQueue.delete(jobId);
2142
+ logger.info('Slippage pre-check: Specific job cancelled', { subscriptionId, renewalTime, jobId });
2143
+ } else {
2144
+ // Delete all pre-check jobs for this subscription
2145
+ // Note: This is a fallback that searches through the queue
2146
+ const jobs = await slippagePreCheckQueue.store.findJobs({});
2147
+ // eslint-disable-next-line no-await-in-loop
2148
+ for (const job of jobs) {
2149
+ if (job.job?.subscriptionId === subscriptionId) {
2150
+ // eslint-disable-next-line no-await-in-loop
2151
+ await slippagePreCheckQueue.delete(job.id);
2152
+ logger.info('Slippage pre-check: Job cancelled', { subscriptionId, jobId: job.id });
2153
+ }
2154
+ }
2155
+ }
2156
+ }
@@ -6,6 +6,7 @@ import { Op } from 'sequelize';
6
6
  import { sessionMiddleware } from '@blocklet/sdk/lib/middlewares/session';
7
7
  import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
8
8
  import { trimDecimals } from '../libs/math-utils';
9
+ import { applySlippageToAmount } from '../libs/slippage';
9
10
  import {
10
11
  AutoRechargeConfig,
11
12
  Customer,
@@ -25,6 +26,20 @@ import logger from '../libs/logger';
25
26
 
26
27
  const router = Router();
27
28
 
29
+ const slippageConfigSchema = Joi.object({
30
+ mode: Joi.string().valid('percent', 'rate').optional(),
31
+ // In rate mode, percent is calculated from min_acceptable_rate and can exceed 100%
32
+ // In percent mode, percent is user-specified and should be 0-100
33
+ percent: Joi.when('mode', {
34
+ is: 'rate',
35
+ then: Joi.number().min(0).optional(),
36
+ otherwise: Joi.number().min(0).max(100).optional(),
37
+ }),
38
+ min_acceptable_rate: Joi.string().optional(),
39
+ base_currency: Joi.string().optional(),
40
+ updated_at_ms: Joi.number().optional(),
41
+ }).allow(null);
42
+
28
43
  const createConfigSchema = Joi.object({
29
44
  customer_id: Joi.string().required(),
30
45
  enabled: Joi.boolean().default(false),
@@ -39,6 +54,7 @@ const createConfigSchema = Joi.object({
39
54
  max_attempts: Joi.number().integer().min(0).default(0).optional(),
40
55
  max_amount: Joi.number().min(0).default(0).optional(),
41
56
  }).optional(),
57
+ slippage_config: slippageConfigSchema.optional(),
42
58
  });
43
59
 
44
60
  const getConfigSchema = Joi.object({
@@ -80,6 +96,23 @@ async function ensurePaymentMethodExists(paymentMethodId: string): Promise<Payme
80
96
  return paymentMethod;
81
97
  }
82
98
 
99
+ // Check if slippage_config has changed in a way that requires reauthorization
100
+ function hasSlippageConfigChanged(
101
+ oldConfig: AutoRechargeConfig['slippage_config'],
102
+ newConfig: AutoRechargeConfig['slippage_config']
103
+ ): boolean {
104
+ // If both are empty, no change
105
+ if (!oldConfig && !newConfig) return false;
106
+ // If one is empty and the other is not, there's a change
107
+ if (!oldConfig || !newConfig) return true;
108
+ // Compare key fields that affect authorization amount
109
+ return (
110
+ oldConfig.mode !== newConfig.mode ||
111
+ oldConfig.percent !== newConfig.percent ||
112
+ oldConfig.min_acceptable_rate !== newConfig.min_acceptable_rate
113
+ );
114
+ }
115
+
83
116
  // Apply balance check results to configuration
84
117
  async function applyBalanceCheckResults(
85
118
  config: AutoRechargeConfig,
@@ -199,7 +232,10 @@ router.get('/customer/:customerId', sessionMiddleware({ accessKey: true }), asyn
199
232
  return res.json({
200
233
  ...newConfig.toJSON(),
201
234
  currency,
202
- price,
235
+ price: {
236
+ ...price.toJSON(),
237
+ pricing_type: price.pricing_type || 'fixed',
238
+ },
203
239
  threshold: fromUnitToToken(minimumThreshold, currency.decimal),
204
240
  customer,
205
241
  });
@@ -221,7 +257,10 @@ router.get('/customer/:customerId', sessionMiddleware({ accessKey: true }), asyn
221
257
  max_amount: fromUnitToToken(config.daily_limits?.max_amount || '0', config.rechargeCurrency?.decimal || 2),
222
258
  },
223
259
  currency,
224
- price,
260
+ price: {
261
+ ...price.toJSON(),
262
+ pricing_type: price.pricing_type || 'fixed',
263
+ },
225
264
  threshold: fromUnitToToken(configThreshold, currency.decimal),
226
265
  customer,
227
266
  });
@@ -261,7 +300,20 @@ async function checkSufficientBalance({
261
300
  forceReauthorize?: boolean;
262
301
  }) {
263
302
  const priceAmount = await getPriceUintAmountByCurrency(price, rechargeCurrency.id);
264
- const amount = new BN(priceAmount).mul(new BN(quantity));
303
+ let amount = new BN(priceAmount).mul(new BN(quantity));
304
+
305
+ // Apply slippage buffer for dynamic pricing authorization
306
+ // This ensures the delegation covers the maximum possible payment amount
307
+ const slippagePercent = autoRechargeConfig.slippage_config?.percent;
308
+ if (slippagePercent && slippagePercent > 0) {
309
+ amount = applySlippageToAmount(amount, slippagePercent);
310
+ logger.info('Applied slippage to authorization amount', {
311
+ baseAmount: new BN(priceAmount).mul(new BN(quantity)).toString(),
312
+ slippagePercent,
313
+ authorizedAmount: amount.toString(),
314
+ });
315
+ }
316
+
265
317
  const paymentMethod = await PaymentMethod.findByPk(rechargeCurrency.payment_method_id);
266
318
 
267
319
  if (!paymentMethod) {
@@ -425,7 +477,7 @@ router.post('/submit', async (req, res) => {
425
477
  });
426
478
  }
427
479
  // if exist, update it
428
- await existingConfig.update({
480
+ const updateData: any = {
429
481
  ...configData,
430
482
  currency_id: value.currency_id,
431
483
  recharge_currency_id: value.recharge_currency_id,
@@ -433,7 +485,19 @@ router.post('/submit', async (req, res) => {
433
485
  payment_method_id: rechargeCurrency.payment_method_id,
434
486
  threshold: threshold ?? existingConfig.threshold,
435
487
  daily_limits: dailyLimits,
436
- });
488
+ };
489
+ // Only update slippage_config if explicitly provided (including null to clear it)
490
+ if ('slippage_config' in value) {
491
+ updateData.slippage_config = value.slippage_config;
492
+ }
493
+
494
+ // Check if slippage_config changed BEFORE updating (to compare old vs new)
495
+ const slippageConfigChanged =
496
+ 'slippage_config' in value && hasSlippageConfigChanged(existingConfig.slippage_config, value.slippage_config);
497
+
498
+ await existingConfig.update(updateData);
499
+
500
+ // If slippage_config changed, need to reauthorize because authorization amount may change
437
501
  const balanceResult = await checkSufficientBalance({
438
502
  price: price as unknown as Price,
439
503
  quantity: value.quantity,
@@ -441,7 +505,7 @@ router.post('/submit', async (req, res) => {
441
505
  userDid: customer.did,
442
506
  customer,
443
507
  autoRechargeConfig: existingConfig,
444
- forceReauthorize: value.change_payment_method,
508
+ forceReauthorize: value.change_payment_method || slippageConfigChanged,
445
509
  });
446
510
  // Update payment details and settings based on balance check result
447
511
  await applyBalanceCheckResults(existingConfig, balanceResult, paymentMethod);
@@ -464,6 +528,7 @@ router.post('/submit', async (req, res) => {
464
528
  quantity: Number(configData.quantity || '1'),
465
529
  payment_method_id: rechargeCurrency.payment_method_id,
466
530
  daily_limits: dailyLimits,
531
+ slippage_config: value.slippage_config,
467
532
  });
468
533
 
469
534
  logger.info('Auto recharge config created', {