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
@@ -7,7 +7,7 @@ import pick from 'lodash/pick';
7
7
  import uniq from 'lodash/uniq';
8
8
 
9
9
  import { literal, Op, OrderItem } from 'sequelize';
10
- import { BN } from '@ocap/util';
10
+ import { BN, fromTokenToUnit } from '@ocap/util';
11
11
  import { createEvent } from '../libs/audit';
12
12
  import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../integrations/stripe/resource';
13
13
  import { createListParamSchema, getOrder, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
@@ -15,7 +15,15 @@ import dayjs from '../libs/dayjs';
15
15
  import logger from '../libs/logger';
16
16
  import { isDelegationSufficientForPayment } from '../libs/payment';
17
17
  import { authenticate } from '../libs/security';
18
- import { expandLineItems, getFastCheckoutAmount, getSubscriptionCreateSetup, isLineItemAligned } from '../libs/session';
18
+ import {
19
+ expandLineItems,
20
+ getFastCheckoutAmount,
21
+ getSubscriptionCreateSetup,
22
+ isLineItemAligned,
23
+ SlippageOptions,
24
+ } from '../libs/session';
25
+ import { getExchangeRateService } from '../libs/exchange-rate/service';
26
+ import { getExchangeRateSymbol } from '../libs/exchange-rate/token-address-mapping';
19
27
  import {
20
28
  checkRemainingStake,
21
29
  createProration,
@@ -30,6 +38,7 @@ import {
30
38
  isSubscriptionOverdraftProtectionEnabled,
31
39
  } from '../libs/subscription';
32
40
  import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata } from '../libs/util';
41
+ import { trimDecimals, limitTokenPrecision } from '../libs/math-utils';
33
42
  import { invoiceQueue } from '../queues/invoice';
34
43
  import {
35
44
  addSubscriptionJob,
@@ -39,7 +48,7 @@ import {
39
48
  slashStakeQueue,
40
49
  subscriptionQueue,
41
50
  } from '../queues/subscription';
42
- import type { TLineItemExpanded } from '../store/models';
51
+ import type { TLineItemExpanded, ChainType } from '../store/models';
43
52
  import { Customer } from '../store/models/customer';
44
53
  import { Invoice } from '../store/models/invoice';
45
54
  import { InvoiceItem } from '../store/models/invoice-item';
@@ -48,6 +57,7 @@ import { PaymentCurrency } from '../store/models/payment-currency';
48
57
  import { PaymentIntent } from '../store/models/payment-intent';
49
58
  import { PaymentMethod } from '../store/models/payment-method';
50
59
  import { Price } from '../store/models/price';
60
+ import { PriceQuote } from '../store/models/price-quote';
51
61
  import { PricingTable } from '../store/models/pricing-table';
52
62
  import { Product } from '../store/models/product';
53
63
  import { SetupIntent } from '../store/models/setup-intent';
@@ -70,6 +80,7 @@ import { getSubscriptionDiscountStats } from '../libs/discount/redemption';
70
80
  const router = Router();
71
81
  const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
72
82
  const authMine = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'], mine: true });
83
+ const exchangeRateService = getExchangeRateService();
73
84
  const authPortal = authenticate<Subscription>({
74
85
  component: true,
75
86
  embed: true,
@@ -98,6 +109,7 @@ const schema = createListParamSchema<{
98
109
  activeFirst?: boolean;
99
110
  price_id?: string;
100
111
  showTotalCount?: boolean;
112
+ include_latest_invoice_quote?: boolean;
101
113
  }>({
102
114
  status: Joi.string().empty(''),
103
115
  customer_id: Joi.string().empty(''),
@@ -105,8 +117,121 @@ const schema = createListParamSchema<{
105
117
  activeFirst: Joi.boolean().optional(),
106
118
  price_id: Joi.string().empty(''),
107
119
  showTotalCount: Joi.boolean().optional(),
120
+ include_latest_invoice_quote: Joi.boolean().optional(),
108
121
  });
109
122
 
123
+ const buildQuoteMetadata = (quote: PriceQuote) => {
124
+ const slippagePercent = quote.slippage_percent ?? (quote.metadata as any)?.slippage?.percent ?? null;
125
+ const minAcceptableRate = quote.min_acceptable_rate ?? (quote.metadata as any)?.slippage?.min_acceptable_rate ?? null;
126
+ const maxPayableToken = quote.max_payable_token ?? (quote.metadata as any)?.slippage?.max_payable_token ?? null;
127
+ const derivedAtMs = quote.slippage_derived_at_ms ?? (quote.metadata as any)?.slippage?.derived_at_ms ?? null;
128
+ const degraded = (quote.metadata as any)?.risk?.degraded ?? null;
129
+ const degradedReason = (quote.metadata as any)?.risk?.degraded_reason ?? null;
130
+
131
+ return {
132
+ id: quote.id,
133
+ base_currency: quote.base_currency,
134
+ base_amount: quote.base_amount,
135
+ quoted_amount: quote.quoted_amount,
136
+ target_currency_id: quote.target_currency_id,
137
+ rate_currency_symbol: quote.rate_currency_symbol,
138
+ exchange_rate: quote.exchange_rate,
139
+ rate_provider_name: quote.rate_provider_name,
140
+ rate_provider_id: quote.rate_provider_id,
141
+ rate_timestamp_ms: quote.rate_timestamp_ms,
142
+ consensus_method: (quote.metadata as any)?.rate?.consensus_method || null,
143
+ providers: (quote.metadata as any)?.rate?.providers || [],
144
+ degraded,
145
+ degraded_reason: degradedReason,
146
+ slippage_percent: slippagePercent,
147
+ min_acceptable_rate: minAcceptableRate,
148
+ max_payable_token: maxPayableToken,
149
+ derived_at_ms: derivedAtMs,
150
+ status: quote.status,
151
+ };
152
+ };
153
+
154
+ const attachQuoteMetadataToLines = (lines: any[] | undefined, quotesById: Map<string, PriceQuote>) => {
155
+ if (!lines?.length) {
156
+ return;
157
+ }
158
+ lines.forEach((line) => {
159
+ const quoteId = line?.metadata?.quote_id;
160
+ if (!quoteId) {
161
+ return;
162
+ }
163
+ const quote = quotesById.get(quoteId);
164
+ if (!quote) {
165
+ return;
166
+ }
167
+ line.metadata = {
168
+ ...(line.metadata || {}),
169
+ quote: {
170
+ ...(line.metadata?.quote || {}),
171
+ ...buildQuoteMetadata(quote),
172
+ },
173
+ };
174
+ });
175
+ };
176
+
177
+ const attachLatestInvoiceQuotes = async (subscriptions: any[]) => {
178
+ if (!subscriptions?.length) {
179
+ return;
180
+ }
181
+
182
+ const invoiceIds = uniq(subscriptions.map((sub) => sub.latest_invoice_id).filter(Boolean));
183
+ if (invoiceIds.length === 0) {
184
+ return;
185
+ }
186
+
187
+ const invoices = await Invoice.findAll({
188
+ where: { id: invoiceIds },
189
+ include: [
190
+ {
191
+ model: InvoiceItem,
192
+ as: 'lines',
193
+ attributes: ['id', 'metadata', 'amount', 'quantity', 'price_id'],
194
+ },
195
+ ],
196
+ });
197
+
198
+ if (!invoices.length) {
199
+ return;
200
+ }
201
+
202
+ const invoiceDocs = invoices.map((invoice) => invoice.toJSON()) as any[];
203
+ const quoteIds = new Set<string>();
204
+ invoiceDocs.forEach((invoice) => {
205
+ invoice.lines?.forEach((line: any) => {
206
+ const quoteId = line?.metadata?.quote_id;
207
+ if (typeof quoteId === 'string' && quoteId.length > 0) {
208
+ quoteIds.add(quoteId);
209
+ }
210
+ });
211
+ });
212
+
213
+ if (quoteIds.size > 0) {
214
+ const quotes = await PriceQuote.findAll({ where: { id: Array.from(quoteIds) } });
215
+ const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
216
+ invoiceDocs.forEach((invoice) => {
217
+ attachQuoteMetadataToLines(invoice.lines, quotesById);
218
+ });
219
+ }
220
+
221
+ const invoiceById = new Map(invoiceDocs.map((invoice) => [invoice.id, invoice]));
222
+ subscriptions.forEach((subscription) => {
223
+ const invoiceId = subscription.latest_invoice_id;
224
+ if (!invoiceId) {
225
+ return;
226
+ }
227
+ const invoice = invoiceById.get(invoiceId);
228
+ if (!invoice) {
229
+ return;
230
+ }
231
+ subscription.latest_invoice = pick(invoice, ['id', 'status', 'created_at', 'lines']);
232
+ });
233
+ };
234
+
110
235
  // Create subscription directly (for SDK use)
111
236
  const createSchema = Joi.object({
112
237
  customer_id: Joi.string().required(),
@@ -325,10 +450,18 @@ router.post('/', auth, async (req, res) => {
325
450
  });
326
451
 
327
452
  router.get('/', authMine, async (req, res) => {
328
- const { page, pageSize, status, livemode, ...query } = await schema.validateAsync(req.query, {
453
+ const {
454
+ page,
455
+ pageSize,
456
+ status,
457
+ livemode,
458
+ include_latest_invoice_quote: includeLatestInvoiceQuoteParam = false,
459
+ ...query
460
+ } = await schema.validateAsync(req.query, {
329
461
  stripUnknown: false,
330
462
  allowUnknown: true,
331
463
  });
464
+ const includeLatestInvoiceQuote = includeLatestInvoiceQuoteParam === true;
332
465
  const where = getWhereFromKvQuery(query.q);
333
466
 
334
467
  if (status) {
@@ -395,6 +528,9 @@ router.get('/', authMine, async (req, res) => {
395
528
  const docs = list.map((x) => x.toJSON());
396
529
  // @ts-ignore
397
530
  docs.forEach((x) => expandLineItems(x.items, products, prices));
531
+ if (includeLatestInvoiceQuote) {
532
+ await attachLatestInvoiceQuotes(docs);
533
+ }
398
534
 
399
535
  if (query.showTotalCount) {
400
536
  const totalCount = await Subscription.count({
@@ -417,14 +553,25 @@ router.get('/', authMine, async (req, res) => {
417
553
  // search subscriptions
418
554
  const searchSchema = createListParamSchema<{
419
555
  query: string;
556
+ include_latest_invoice_quote?: boolean;
420
557
  }>({
421
558
  query: Joi.string(),
559
+ include_latest_invoice_quote: Joi.boolean().optional(),
422
560
  });
423
561
  router.get('/search', auth, async (req, res) => {
424
- const { page, pageSize, query, livemode, q, o } = await searchSchema.validateAsync(req.query, {
562
+ const {
563
+ page,
564
+ pageSize,
565
+ query,
566
+ livemode,
567
+ q,
568
+ o,
569
+ include_latest_invoice_quote: includeLatestInvoiceQuoteParam = false,
570
+ } = await searchSchema.validateAsync(req.query, {
425
571
  stripUnknown: false,
426
572
  allowUnknown: true,
427
573
  });
574
+ const includeLatestInvoiceQuote = includeLatestInvoiceQuoteParam === true;
428
575
 
429
576
  const where = q != null ? getWhereFromKvQuery(q) : getWhereFromQuery(query);
430
577
  if (typeof livemode === 'boolean') {
@@ -450,6 +597,9 @@ router.get('/search', auth, async (req, res) => {
450
597
  const docs = list.map((x) => x.toJSON());
451
598
  // @ts-ignore
452
599
  docs.forEach((x) => expandLineItems(x.items, products, prices));
600
+ if (includeLatestInvoiceQuote) {
601
+ await attachLatestInvoiceQuotes(docs);
602
+ }
453
603
  res.json({ count, list: docs, paging: { page, pageSize } });
454
604
  });
455
605
 
@@ -1250,7 +1400,64 @@ router.put('/:id', authPortal, async (req, res) => {
1250
1400
  } else {
1251
1401
  // update subscription period settings
1252
1402
  // HINT: if we are adding new items, we need to reset the anchor to now
1253
- const setup = getSubscriptionCreateSetup(newItems, paymentCurrency.id, 0);
1403
+ // For change-plan (proration), we need exact amounts, not authorization amounts with slippage buffer.
1404
+ // So we don't pass minAcceptableRate or slippage percent here.
1405
+ // The custom_amount we set below will be used as-is.
1406
+ const slippageOptions: SlippageOptions = {
1407
+ percent: 0, // No slippage multiplier for actual payment
1408
+ // Don't include minAcceptableRate - it would override custom_amount calculation
1409
+ currencyDecimal: paymentCurrency.decimal,
1410
+ };
1411
+
1412
+ // For dynamic pricing items, calculate custom_amount using current exchange rate
1413
+ // This ensures the total is calculated with current rate, not the stale unit_amount
1414
+ const hasDynamicPricing = newItems.some((x) => (x.upsell_price || x.price).pricing_type === 'dynamic');
1415
+ if (hasDynamicPricing) {
1416
+ const currencyPaymentMethod =
1417
+ (paymentCurrency as any).payment_method ||
1418
+ (await PaymentMethod.findByPk(paymentCurrency.payment_method_id));
1419
+ const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, currencyPaymentMethod?.type as ChainType);
1420
+ try {
1421
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
1422
+ if (rateResult?.rate) {
1423
+ const USD_DECIMALS = 8;
1424
+ const currentRate = rateResult.rate;
1425
+ // Set custom_amount for each dynamic pricing item
1426
+ newItems.forEach((item: any) => {
1427
+ const price = item.upsell_price || item.price;
1428
+ if (price.pricing_type === 'dynamic' && price.base_amount) {
1429
+ // Calculate: base_amount / rate * 10^decimal
1430
+ // Use trimDecimals to avoid "too many decimal places" error
1431
+ const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
1432
+ const rateBN = fromTokenToUnit(trimDecimals(currentRate, USD_DECIMALS), USD_DECIMALS);
1433
+ if (rateBN.gt(new BN(0))) {
1434
+ const amountBN = baseAmountBN
1435
+ .mul(new BN(10).pow(new BN(paymentCurrency.decimal)))
1436
+ .add(rateBN.sub(new BN(1))) // Round up
1437
+ .div(rateBN);
1438
+ // Apply same precision limiting as quote service (10 significant decimal places)
1439
+ const totalAmountBN = amountBN.mul(new BN(item.quantity));
1440
+ item.custom_amount = limitTokenPrecision(totalAmountBN, paymentCurrency.decimal, 10).toString();
1441
+ logger.info('Set custom_amount for dynamic pricing item in subscription update', {
1442
+ subscriptionId: subscription.id,
1443
+ priceId: price.id,
1444
+ baseAmount: price.base_amount,
1445
+ currentRate,
1446
+ customAmount: item.custom_amount,
1447
+ });
1448
+ }
1449
+ }
1450
+ });
1451
+ }
1452
+ } catch (err) {
1453
+ logger.warn('Failed to fetch exchange rate for subscription update, using unit_amount', {
1454
+ subscriptionId: subscription.id,
1455
+ error: err,
1456
+ });
1457
+ }
1458
+ }
1459
+
1460
+ const setup = getSubscriptionCreateSetup(newItems, paymentCurrency.id, 0, 0, slippageOptions);
1254
1461
  // Check if the subscription is currently in trial
1255
1462
  const isInTrial =
1256
1463
  subscription.status === 'trialing' && subscription.trial_end && subscription.trial_end > dayjs().unix();
@@ -1354,17 +1561,49 @@ router.put('/:id', authPortal, async (req, res) => {
1354
1561
 
1355
1562
  // 5. check do we need to connect
1356
1563
  let hasNext = true;
1357
- if (due === '0') {
1564
+ let needsNewStake = false;
1565
+
1566
+ // Check if stake is required and if we need a new one
1567
+ const requiresStake = paymentMethod.type === 'arcblock' && !subscription.billing_thresholds?.no_stake;
1568
+ if (requiresStake) {
1569
+ const existingStakeAddress = subscription.payment_details?.arcblock?.staking?.address;
1570
+ if (existingStakeAddress) {
1571
+ const stakeCheck = await checkRemainingStake(paymentMethod, paymentCurrency, existingStakeAddress, '1');
1572
+ needsNewStake = !stakeCheck.enough;
1573
+ logger.info('Change plan: checking existing stake in API', {
1574
+ subscriptionId: subscription.id,
1575
+ existingStakeAddress,
1576
+ hasValidStake: stakeCheck.enough,
1577
+ needsNewStake,
1578
+ });
1579
+ } else {
1580
+ needsNewStake = true;
1581
+ }
1582
+ }
1583
+
1584
+ if (due === '0' && !needsNewStake) {
1358
1585
  hasNext = false;
1359
1586
  } else {
1360
1587
  const payer = getSubscriptionPaymentAddress(subscription, paymentMethod.type);
1588
+ // For change-plan, we should check if delegation is sufficient for the due amount,
1589
+ // not the full new plan price. The due amount is what user actually needs to pay.
1361
1590
  const delegation = await isDelegationSufficientForPayment({
1362
1591
  paymentMethod,
1363
1592
  paymentCurrency,
1364
1593
  userDid: payer,
1365
- amount: setup.amount.setup,
1594
+ amount: due || '0',
1595
+ });
1596
+ logger.info('delegation sufficient for payment', {
1597
+ subscription: subscription.id,
1598
+ paymentMethod: paymentMethod.type,
1599
+ paymentCurrency: paymentCurrency.id,
1600
+ userDid: payer,
1601
+ amount: due,
1602
+ delegation,
1603
+ needsNewStake,
1366
1604
  });
1367
- if (delegation.sufficient) {
1605
+ if (delegation.sufficient && !needsNewStake) {
1606
+ // Both delegation is sufficient and no new stake needed
1368
1607
  hasNext = false;
1369
1608
  } else if (['NO_DID_WALLET'].includes(delegation.reason as string)) {
1370
1609
  throw new Error('Subscription update can only be done when you do have connected DID Wallet');
@@ -1645,6 +1884,8 @@ router.post('/:id/change-plan', authPortal, async (req, res) => {
1645
1884
  const { newItems } = await validateSubscriptionUpdateRequest(subscription, req.body.items);
1646
1885
 
1647
1886
  // do the simulation
1887
+ // Note: For dynamic pricing, the actual amount is calculated by frontend using current exchange rate
1888
+ // Backend only provides the base structure, frontend handles display with real-time rates
1648
1889
  const setup = getSubscriptionCreateSetup(newItems, subscription.currency_id, 0);
1649
1890
  const result = await createProration(subscription, setup, dayjs().unix());
1650
1891
 
@@ -1756,6 +1997,260 @@ router.get('/:id/change-payment', authPortal, async (req, res) => {
1756
1997
  return res.json({ subscription, setupIntent });
1757
1998
  });
1758
1999
 
2000
+ router.get('/:id/exchange-rate', authPortal, async (req, res) => {
2001
+ try {
2002
+ const subscription = await Subscription.findByPk(req.params.id);
2003
+ if (!subscription) {
2004
+ return res.status(404).json({ error: 'Subscription not found' });
2005
+ }
2006
+
2007
+ const currencyId = (req.query.currency_id as string) || subscription.currency_id;
2008
+ const paymentCurrency = (await PaymentCurrency.findByPk(currencyId, {
2009
+ include: [
2010
+ {
2011
+ model: PaymentMethod,
2012
+ as: 'payment_method',
2013
+ },
2014
+ ],
2015
+ })) as PaymentCurrency & { payment_method: PaymentMethod };
2016
+
2017
+ if (!paymentCurrency) {
2018
+ return res.status(400).json({ error: 'Currency not found' });
2019
+ }
2020
+
2021
+ const paymentMethod =
2022
+ paymentCurrency.payment_method || (await PaymentMethod.findByPk(paymentCurrency.payment_method_id));
2023
+ if (!paymentMethod) {
2024
+ return res.status(400).json({ error: 'Payment method not found' });
2025
+ }
2026
+
2027
+ if (paymentMethod.type === 'stripe') {
2028
+ return res.status(400).json({ error: 'Stripe currency does not require exchange rate.' });
2029
+ }
2030
+
2031
+ const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, paymentMethod.type as any);
2032
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
2033
+ const serverNow = Date.now();
2034
+
2035
+ return res.json({
2036
+ server_now: serverNow,
2037
+ rate: rateResult.rate,
2038
+ timestamp_ms: rateResult.timestamp_ms,
2039
+ fetched_at: rateResult.fetched_at,
2040
+ provider_id: rateResult.provider_id,
2041
+ provider_name: rateResult.provider_name,
2042
+ providers: rateResult.providers,
2043
+ consensus_method: rateResult.consensus_method,
2044
+ degraded: rateResult.degraded,
2045
+ degraded_reason: rateResult.degraded_reason,
2046
+ currency: paymentCurrency.symbol,
2047
+ base_currency: 'USD',
2048
+ });
2049
+ } catch (err: any) {
2050
+ logger.error('Failed to fetch exchange rate for subscription change payment', {
2051
+ subscriptionId: req.params.id,
2052
+ error: err.message,
2053
+ });
2054
+ return res.status(400).json({ error: err.message });
2055
+ }
2056
+ });
2057
+
2058
+ router.put('/:id/slippage', authPortal, async (req, res) => {
2059
+ try {
2060
+ const subscription = await Subscription.findByPk(req.params.id);
2061
+ if (!subscription) {
2062
+ return res.status(404).json({ error: 'Subscription not found' });
2063
+ }
2064
+
2065
+ const { slippage_percent: slippagePercent } = req.body;
2066
+ const rawConfig = req.body?.slippage_config || req.body?.slippage || null;
2067
+ const normalizePercent = (value: any) => {
2068
+ const normalized = typeof value === 'string' ? Number(value) : value;
2069
+ // Only validate that it's a non-negative finite number, no upper limit
2070
+ if (!Number.isFinite(normalized) || normalized < 0) {
2071
+ return null;
2072
+ }
2073
+ return normalized;
2074
+ };
2075
+
2076
+ // Helper: get current exchange rate for subscription currency
2077
+ const getCurrentRate = async (): Promise<{ rate: string; baseCurrency: string } | null> => {
2078
+ const currency = (await PaymentCurrency.findByPk(subscription.currency_id, {
2079
+ include: [{ model: PaymentMethod, as: 'payment_method' }],
2080
+ })) as PaymentCurrency & { payment_method: PaymentMethod };
2081
+ if (!currency || currency.payment_method?.type === 'stripe') {
2082
+ return null;
2083
+ }
2084
+ const rateService = getExchangeRateService();
2085
+ const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
2086
+ const rateResult = await rateService.getRate(rateSymbol);
2087
+ return { rate: rateResult.rate, baseCurrency: 'USD' };
2088
+ };
2089
+
2090
+ // Helper: calculate min_acceptable_rate from percent
2091
+ const calcMinRateFromPercent = (percent: number, currentRate: string): string => {
2092
+ const rateNum = Number(currentRate);
2093
+ if (!Number.isFinite(rateNum) || rateNum <= 0) {
2094
+ return '0';
2095
+ }
2096
+ // min_acceptable_rate = current_rate / (1 + percent/100)
2097
+ const minRate = rateNum / (1 + percent / 100);
2098
+ return minRate.toFixed(8);
2099
+ };
2100
+
2101
+ let config: any = null;
2102
+ if (rawConfig && typeof rawConfig === 'object') {
2103
+ const mode = rawConfig.mode === 'rate' ? 'rate' : 'percent';
2104
+ const minRate = rawConfig.min_acceptable_rate ?? rawConfig.minAcceptableRate;
2105
+
2106
+ // For rate mode, min_acceptable_rate is required; percent is derived
2107
+ if (mode === 'rate') {
2108
+ if (minRate === undefined || minRate === null || minRate === '') {
2109
+ return res.status(400).json({ error: 'min_acceptable_rate is required for rate mode' });
2110
+ }
2111
+ // Accept any non-negative percent value for rate mode (calculated from rate)
2112
+ const percent = normalizePercent(rawConfig.percent);
2113
+ const baseCurrency = rawConfig.base_currency ?? rawConfig.baseCurrency ?? 'USD';
2114
+ const rateInfo = await getCurrentRate();
2115
+ config = {
2116
+ mode,
2117
+ percent: percent ?? 0,
2118
+ min_acceptable_rate: String(minRate),
2119
+ base_currency: String(baseCurrency),
2120
+ ...(rateInfo ? { rate_at_config_time: rateInfo.rate } : {}),
2121
+ updated_at_ms: Date.now(),
2122
+ };
2123
+ } else {
2124
+ // Percent mode: validate percent
2125
+ const percent = normalizePercent(rawConfig.percent);
2126
+ if (percent === null) {
2127
+ return res.status(400).json({ error: 'slippage_percent must be a non-negative number' });
2128
+ }
2129
+ const baseCurrency = rawConfig.base_currency ?? rawConfig.baseCurrency ?? 'USD';
2130
+ // Use min_acceptable_rate from frontend if provided, otherwise calculate it
2131
+ const frontendMinRate = rawConfig.min_acceptable_rate ?? rawConfig.minAcceptableRate;
2132
+ let minAcceptableRate: string | undefined;
2133
+ let rateAtConfigTime: string | undefined;
2134
+ if (frontendMinRate) {
2135
+ // Frontend already calculated it - use directly
2136
+ minAcceptableRate = String(frontendMinRate);
2137
+ } else {
2138
+ // Frontend didn't provide - calculate using same algorithm
2139
+ const rateInfo = await getCurrentRate();
2140
+ if (rateInfo) {
2141
+ minAcceptableRate = calcMinRateFromPercent(percent, rateInfo.rate);
2142
+ rateAtConfigTime = rateInfo.rate;
2143
+ }
2144
+ }
2145
+ config = {
2146
+ mode,
2147
+ percent,
2148
+ base_currency: String(baseCurrency),
2149
+ ...(minAcceptableRate ? { min_acceptable_rate: minAcceptableRate } : {}),
2150
+ ...(rateAtConfigTime ? { rate_at_config_time: rateAtConfigTime } : {}),
2151
+ updated_at_ms: Date.now(),
2152
+ };
2153
+ }
2154
+ } else if (slippagePercent !== undefined && slippagePercent !== null) {
2155
+ const value = normalizePercent(slippagePercent);
2156
+ if (value === null) {
2157
+ return res.status(400).json({ error: 'slippage_percent must be a non-negative number' });
2158
+ }
2159
+ const rateInfo = await getCurrentRate();
2160
+ const minAcceptableRate = rateInfo ? calcMinRateFromPercent(value, rateInfo.rate) : undefined;
2161
+ config = {
2162
+ mode: 'percent',
2163
+ percent: value,
2164
+ base_currency: 'USD',
2165
+ ...(minAcceptableRate ? { min_acceptable_rate: minAcceptableRate } : {}),
2166
+ ...(rateInfo ? { rate_at_config_time: rateInfo.rate } : {}),
2167
+ updated_at_ms: Date.now(),
2168
+ };
2169
+ } else {
2170
+ return res.status(400).json({ error: 'slippage config is required' });
2171
+ }
2172
+
2173
+ await subscription.update({ slippage_config: config });
2174
+ logger.info('Subscription slippage updated', {
2175
+ subscriptionId: subscription.id,
2176
+ slippageConfig: config,
2177
+ });
2178
+
2179
+ // Check if authorization is sufficient with the new slippage config
2180
+ let delegationWarning: {
2181
+ sufficient: boolean;
2182
+ reason?: string;
2183
+ required_amount?: string;
2184
+ current_allowance?: string;
2185
+ } | null = null;
2186
+
2187
+ try {
2188
+ const paymentCurrency = (await PaymentCurrency.findByPk(subscription.currency_id, {
2189
+ include: [{ model: PaymentMethod, as: 'payment_method' }],
2190
+ })) as PaymentCurrency & { payment_method: PaymentMethod };
2191
+
2192
+ // Only check delegation for non-Stripe payment methods
2193
+ if (paymentCurrency && paymentCurrency.payment_method?.type !== 'stripe') {
2194
+ const paymentMethod = paymentCurrency.payment_method;
2195
+ const payer = getSubscriptionPaymentAddress(subscription, paymentMethod.type);
2196
+
2197
+ // Get subscription items and expand them
2198
+ const subscriptionItems = await SubscriptionItem.findAll({
2199
+ where: { subscription_id: subscription.id },
2200
+ });
2201
+ const lineItems = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
2202
+
2203
+ // Calculate the required amount with the new slippage
2204
+ const requiredAmount = await getFastCheckoutAmount({
2205
+ items: lineItems,
2206
+ mode: 'subscription',
2207
+ currencyId: paymentCurrency.id,
2208
+ trialing: subscription.status === 'trialing',
2209
+ });
2210
+
2211
+ // Check if delegation is sufficient
2212
+ const delegation = await isDelegationSufficientForPayment({
2213
+ paymentMethod,
2214
+ paymentCurrency,
2215
+ userDid: payer,
2216
+ amount: requiredAmount,
2217
+ });
2218
+
2219
+ if (!delegation.sufficient) {
2220
+ delegationWarning = {
2221
+ sufficient: false,
2222
+ reason: delegation.reason || 'INSUFFICIENT_AUTHORIZATION',
2223
+ required_amount: requiredAmount,
2224
+ };
2225
+ logger.info('Slippage update may require re-authorization', {
2226
+ subscriptionId: subscription.id,
2227
+ newSlippagePercent: config.percent,
2228
+ requiredAmount,
2229
+ delegationReason: delegation.reason,
2230
+ });
2231
+ }
2232
+ }
2233
+ } catch (delegationError: any) {
2234
+ // Don't fail the slippage update if delegation check fails
2235
+ logger.warn('Failed to check delegation after slippage update', {
2236
+ subscriptionId: subscription.id,
2237
+ error: delegationError.message,
2238
+ });
2239
+ }
2240
+
2241
+ return res.json({
2242
+ ...subscription.toJSON(),
2243
+ ...(delegationWarning ? { delegation_warning: delegationWarning } : {}),
2244
+ });
2245
+ } catch (err: any) {
2246
+ logger.error('Failed to update subscription slippage', {
2247
+ subscriptionId: req.params.id,
2248
+ error: err.message,
2249
+ });
2250
+ return res.status(500).json({ error: err.message });
2251
+ }
2252
+ });
2253
+
1759
2254
  // Prepare setupIntent for payment change
1760
2255
  router.post('/:id/change-payment', authPortal, async (req, res) => {
1761
2256
  try {
@@ -1964,16 +2459,32 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
1964
2459
  } else {
1965
2460
  // changing from crypto to crypto: just update the subscription
1966
2461
  const payer = getSubscriptionPaymentAddress(subscription, paymentMethod.type);
1967
- delegation = await isDelegationSufficientForPayment({
1968
- paymentMethod,
1969
- paymentCurrency,
1970
- userDid: payer,
1971
- amount: await getFastCheckoutAmount({
2462
+
2463
+ // Calculate required amount considering slippage_config for dynamic pricing
2464
+ const slippageConfig = subscription?.slippage_config;
2465
+ let requiredAmount: string;
2466
+ if (slippageConfig?.min_acceptable_rate) {
2467
+ const slippageOptions: SlippageOptions = {
2468
+ percent: slippageConfig.percent ?? 0.5,
2469
+ minAcceptableRate: slippageConfig.min_acceptable_rate,
2470
+ currencyDecimal: paymentCurrency.decimal,
2471
+ };
2472
+ const setup = getSubscriptionCreateSetup(lineItems, paymentCurrency.id, 0, 0, slippageOptions);
2473
+ requiredAmount = setup.amount.setup;
2474
+ } else {
2475
+ requiredAmount = await getFastCheckoutAmount({
1972
2476
  items: lineItems,
1973
2477
  mode: 'subscription',
1974
2478
  currencyId: paymentCurrency.id,
1975
2479
  trialing: false,
1976
- }),
2480
+ });
2481
+ }
2482
+
2483
+ delegation = await isDelegationSufficientForPayment({
2484
+ paymentMethod,
2485
+ paymentCurrency,
2486
+ userDid: payer,
2487
+ amount: requiredAmount,
1977
2488
  });
1978
2489
  const noStake = subscription.billing_thresholds?.no_stake;
1979
2490
  if (paymentMethod.type === 'arcblock' && delegation.sufficient && !noStake) {