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
@@ -5,7 +5,7 @@ import Joi from 'joi';
5
5
  import pick from 'lodash/pick';
6
6
  import { Op } from 'sequelize';
7
7
 
8
- import { BN } from '@ocap/util';
8
+ import { BN, fromUnitToToken } from '@ocap/util';
9
9
  import { syncStripeInvoice } from '../integrations/stripe/handlers/invoice';
10
10
  import { syncStripePayment } from '../integrations/stripe/handlers/payment-intent';
11
11
  import { ensureStripeCustomer, ensureStripeSetupIntentForInvoicePayment } from '../integrations/stripe/resource';
@@ -33,11 +33,19 @@ import {
33
33
  PromotionCode,
34
34
  CreditGrant,
35
35
  TaxRate,
36
+ PriceQuote,
36
37
  } from '../store/models';
37
38
  import { mergePaginate, defaultTimeOrderBy, getCachedOrFetch, DataSource } from '../libs/pagination';
38
39
  import logger from '../libs/logger';
39
40
  import { returnOverdraftProtectionQueue, returnStakeQueue } from '../queues/subscription';
40
41
  import { checkRemainingStake } from '../libs/subscription';
42
+ import { getExchangeRateService } from '../libs/exchange-rate';
43
+
44
+ // Simple format amount helper for backend
45
+ function formatAmountForDisplay(amount: string, decimal: number, symbol: string): string {
46
+ const tokenValue = fromUnitToToken(amount, decimal);
47
+ return `${parseFloat(tokenValue).toFixed(Math.min(decimal, 6))} ${symbol}`;
48
+ }
41
49
 
42
50
  const router = Router();
43
51
  const authAdmin = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
@@ -156,6 +164,7 @@ const schema = createListParamSchema<{
156
164
  include_return_staking?: boolean;
157
165
  include_overdraft_protection?: boolean;
158
166
  include_recovered_from?: boolean;
167
+ include_quote?: boolean;
159
168
  tax_rate_id?: string;
160
169
  }>({
161
170
  status: Joi.string().empty(''),
@@ -168,9 +177,64 @@ const schema = createListParamSchema<{
168
177
  include_return_staking: Joi.boolean().empty(false),
169
178
  include_overdraft_protection: Joi.boolean().default(true),
170
179
  include_recovered_from: Joi.boolean().empty(false),
180
+ include_quote: Joi.boolean().empty(false),
171
181
  tax_rate_id: Joi.string().empty(''),
172
182
  });
173
183
 
184
+ const buildQuoteMetadata = (quote: PriceQuote) => {
185
+ const slippagePercent = quote.slippage_percent ?? (quote.metadata as any)?.slippage?.percent ?? null;
186
+ const minAcceptableRate = quote.min_acceptable_rate ?? (quote.metadata as any)?.slippage?.min_acceptable_rate ?? null;
187
+ const maxPayableToken = quote.max_payable_token ?? (quote.metadata as any)?.slippage?.max_payable_token ?? null;
188
+ const derivedAtMs = quote.slippage_derived_at_ms ?? (quote.metadata as any)?.slippage?.derived_at_ms ?? null;
189
+ const degraded = (quote.metadata as any)?.risk?.degraded ?? null;
190
+ const degradedReason = (quote.metadata as any)?.risk?.degraded_reason ?? null;
191
+
192
+ return {
193
+ id: quote.id,
194
+ base_currency: quote.base_currency,
195
+ base_amount: quote.base_amount,
196
+ quoted_amount: quote.quoted_amount,
197
+ target_currency_id: quote.target_currency_id,
198
+ rate_currency_symbol: quote.rate_currency_symbol,
199
+ exchange_rate: quote.exchange_rate,
200
+ rate_provider_name: quote.rate_provider_name,
201
+ rate_provider_id: quote.rate_provider_id,
202
+ rate_timestamp_ms: quote.rate_timestamp_ms,
203
+ consensus_method: (quote.metadata as any)?.rate?.consensus_method || null,
204
+ providers: (quote.metadata as any)?.rate?.providers || [],
205
+ degraded,
206
+ degraded_reason: degradedReason,
207
+ slippage_percent: slippagePercent,
208
+ min_acceptable_rate: minAcceptableRate,
209
+ max_payable_token: maxPayableToken,
210
+ derived_at_ms: derivedAtMs,
211
+ status: quote.status,
212
+ };
213
+ };
214
+
215
+ const attachQuoteMetadataToLines = (lines: any[] | undefined, quotesById: Map<string, PriceQuote>) => {
216
+ if (!lines?.length) {
217
+ return;
218
+ }
219
+ lines.forEach((line) => {
220
+ const quoteId = line?.metadata?.quote_id;
221
+ if (!quoteId) {
222
+ return;
223
+ }
224
+ const quote = quotesById.get(quoteId);
225
+ if (!quote) {
226
+ return;
227
+ }
228
+ line.metadata = {
229
+ ...(line.metadata || {}),
230
+ quote: {
231
+ ...(line.metadata?.quote || {}),
232
+ ...buildQuoteMetadata(quote),
233
+ },
234
+ };
235
+ });
236
+ };
237
+
174
238
  router.get('/', authMine, async (req, res) => {
175
239
  try {
176
240
  // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -183,6 +247,7 @@ router.get('/', authMine, async (req, res) => {
183
247
  include_staking,
184
248
  include_return_staking,
185
249
  include_overdraft_protection = true,
250
+ include_quote = false,
186
251
  ...query
187
252
  } = await schema.validateAsync(req.query, {
188
253
  stripUnknown: false,
@@ -191,6 +256,8 @@ router.get('/', authMine, async (req, res) => {
191
256
 
192
257
  const taxRateId = query.tax_rate_id;
193
258
  delete query.tax_rate_id;
259
+ delete (query as any).include_quote;
260
+ const includeQuote = include_quote === true;
194
261
 
195
262
  const where = getWhereFromKvQuery(query.q);
196
263
 
@@ -275,7 +342,7 @@ router.get('/', authMine, async (req, res) => {
275
342
  model: InvoiceItem,
276
343
  as: 'lines',
277
344
  where: { tax_rate_id: taxRateId },
278
- attributes: [],
345
+ attributes: includeQuote ? ['id', 'metadata', 'amount', 'quantity', 'price_id'] : [],
279
346
  required: true,
280
347
  },
281
348
  { model: PaymentCurrency, as: 'paymentCurrency' },
@@ -283,6 +350,28 @@ router.get('/', authMine, async (req, res) => {
283
350
  { model: Subscription, as: 'subscription', attributes: ['id', 'description'] },
284
351
  { model: Customer, as: 'customer' },
285
352
  ],
353
+ }).then(async (result) => {
354
+ if (!includeQuote) {
355
+ return result;
356
+ }
357
+ const quoteIds = new Set<string>();
358
+ result.forEach((invoice) => {
359
+ (invoice as any).lines?.forEach((line: any) => {
360
+ const quoteId = line?.metadata?.quote_id;
361
+ if (typeof quoteId === 'string' && quoteId.length > 0) {
362
+ quoteIds.add(quoteId);
363
+ }
364
+ });
365
+ });
366
+ if (quoteIds.size === 0) {
367
+ return result;
368
+ }
369
+ const quotes = await PriceQuote.findAll({ where: { id: Array.from(quoteIds) } });
370
+ const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
371
+ result.forEach((invoice) => {
372
+ attachQuoteMetadataToLines((invoice as any).lines, quotesById);
373
+ });
374
+ return result;
286
375
  }),
287
376
  ]);
288
377
 
@@ -308,6 +397,15 @@ router.get('/', authMine, async (req, res) => {
308
397
  limit,
309
398
  offset,
310
399
  include: [
400
+ ...(includeQuote
401
+ ? [
402
+ {
403
+ model: InvoiceItem,
404
+ as: 'lines',
405
+ attributes: ['id', 'metadata', 'amount', 'quantity', 'price_id'],
406
+ },
407
+ ]
408
+ : []),
311
409
  { model: PaymentCurrency, as: 'paymentCurrency' },
312
410
  { model: PaymentMethod, as: 'paymentMethod' },
313
411
  { model: Subscription, as: 'subscription', attributes: ['id', 'description'] },
@@ -326,6 +424,24 @@ router.get('/', authMine, async (req, res) => {
326
424
  { model: Customer, as: 'customer' },
327
425
  ],
328
426
  });
427
+ if (includeQuote && result.length > 0) {
428
+ const quoteIds = new Set<string>();
429
+ result.forEach((invoice) => {
430
+ (invoice as any).lines?.forEach((line: any) => {
431
+ const quoteId = line?.metadata?.quote_id;
432
+ if (typeof quoteId === 'string' && quoteId.length > 0) {
433
+ quoteIds.add(quoteId);
434
+ }
435
+ });
436
+ });
437
+ if (quoteIds.size > 0) {
438
+ const quotes = await PriceQuote.findAll({ where: { id: Array.from(quoteIds) } });
439
+ const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
440
+ result.forEach((invoice) => {
441
+ attachQuoteMetadataToLines((invoice as any).lines, quotesById);
442
+ });
443
+ }
444
+ }
329
445
  return result;
330
446
  },
331
447
  meta: {
@@ -786,6 +902,42 @@ router.get('/:id', authPortal, async (req, res) => {
786
902
  });
787
903
  }
788
904
 
905
+ // Fetch related price quotes
906
+ let quotes: any[] = [];
907
+ try {
908
+ quotes = await PriceQuote.findAll({
909
+ where: {
910
+ invoice_id: doc.id,
911
+ },
912
+ order: [['created_at', 'ASC']],
913
+ });
914
+ const quoteIds = new Set<string>();
915
+ (json as any).lines?.forEach((line: any) => {
916
+ const quoteId = line?.metadata?.quote_id;
917
+ if (typeof quoteId === 'string' && quoteId.length > 0) {
918
+ quoteIds.add(quoteId);
919
+ }
920
+ });
921
+ if (quoteIds.size > 0) {
922
+ const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
923
+ const missingIds = Array.from(quoteIds).filter((id) => !quotesById.has(id));
924
+ if (missingIds.length > 0) {
925
+ const quotesByLine = await PriceQuote.findAll({ where: { id: missingIds } });
926
+ quotesByLine.forEach((quote) => quotesById.set(quote.id, quote));
927
+ }
928
+ quotes = Array.from(quotesById.values());
929
+ attachQuoteMetadataToLines((json as any).lines, quotesById);
930
+ } else if (quotes.length > 0) {
931
+ const quotesById = new Map(quotes.map((quote) => [quote.id, quote]));
932
+ attachQuoteMetadataToLines((json as any).lines, quotesById);
933
+ }
934
+ } catch (error) {
935
+ logger.error('Failed to fetch related price quotes', {
936
+ error,
937
+ invoiceId: doc.id,
938
+ });
939
+ }
940
+
789
941
  if (doc.metadata?.invoice_id || doc.metadata?.prev_invoice_id) {
790
942
  const relatedInvoice = await Invoice.findByPk(doc.metadata.invoice_id || doc.metadata.prev_invoice_id, {
791
943
  attributes: ['id', 'number', 'status', 'billing_reason'],
@@ -797,6 +949,7 @@ router.get('/:id', authPortal, async (req, res) => {
797
949
  relatedCreditGrants,
798
950
  paymentLink,
799
951
  checkoutSession,
952
+ quotes,
800
953
  });
801
954
  }
802
955
  return res.json({
@@ -805,6 +958,7 @@ router.get('/:id', authPortal, async (req, res) => {
805
958
  relatedCreditGrants,
806
959
  paymentLink,
807
960
  checkoutSession,
961
+ quotes,
808
962
  });
809
963
  }
810
964
  return res.status(404).json(null);
@@ -814,6 +968,129 @@ router.get('/:id', authPortal, async (req, res) => {
814
968
  }
815
969
  });
816
970
 
971
+ /**
972
+ * Get available payment options for an invoice with estimated prices
973
+ * Used when user wants to switch payment method after price change
974
+ */
975
+ router.get('/:id/payment-options', authPortal, async (req, res) => {
976
+ try {
977
+ const invoice = await Invoice.findByPk(req.params.id, {
978
+ include: [
979
+ { model: InvoiceItem, as: 'lines' },
980
+ { model: PaymentCurrency, as: 'paymentCurrency' },
981
+ ],
982
+ });
983
+
984
+ if (!invoice) {
985
+ return res.status(404).json({ error: 'Invoice not found' });
986
+ }
987
+
988
+ // Get all available currencies for this invoice's products
989
+ const invoiceItems = (invoice as any).lines || [];
990
+ const priceIds = invoiceItems.map((item: any) => item.price_id).filter(Boolean);
991
+
992
+ // Get all unique product IDs from prices
993
+ const prices = await Price.findAll({
994
+ where: { id: priceIds },
995
+ include: [{ model: PaymentCurrency, as: 'currencies' }],
996
+ });
997
+
998
+ // Collect all unique currencies
999
+ const currencyMap = new Map<string, any>();
1000
+ const currentCurrencyId = invoice.currency_id;
1001
+
1002
+ // Add current currency first
1003
+ const { paymentCurrency } = invoice as any;
1004
+ if (paymentCurrency) {
1005
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
1006
+ currencyMap.set(paymentCurrency.id, {
1007
+ ...paymentCurrency.toJSON(),
1008
+ method: paymentMethod?.toJSON(),
1009
+ });
1010
+ }
1011
+
1012
+ // Add other available currencies from prices
1013
+ for (const price of prices) {
1014
+ const priceCurrencies = (price as any).currencies || [];
1015
+ for (const currency of priceCurrencies) {
1016
+ if (!currencyMap.has(currency.id)) {
1017
+ // eslint-disable-next-line no-await-in-loop
1018
+ const paymentMethod = await PaymentMethod.findByPk(currency.payment_method_id);
1019
+ currencyMap.set(currency.id, {
1020
+ ...currency.toJSON(),
1021
+ method: paymentMethod?.toJSON(),
1022
+ });
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ // Calculate estimated amounts for each currency
1028
+ const exchangeRateService = getExchangeRateService();
1029
+ const options = [];
1030
+
1031
+ for (const [currencyId, currency] of currencyMap) {
1032
+ let estimatedAmount = '';
1033
+ const isCurrentMethod = currencyId === currentCurrencyId;
1034
+
1035
+ // For current method, use the invoice amount
1036
+ if (isCurrentMethod) {
1037
+ estimatedAmount = formatAmountForDisplay(invoice.amount_due, currency.decimal, currency.symbol);
1038
+ } else {
1039
+ // For other methods, calculate based on USD base amount and current exchange rate
1040
+ try {
1041
+ // Get USD total from line items
1042
+ let usdTotal = 0;
1043
+ for (const item of invoiceItems) {
1044
+ const itemPrice = prices.find((p) => p.id === item.price_id);
1045
+ if (itemPrice?.base_amount) {
1046
+ usdTotal += parseFloat(itemPrice.base_amount) * item.quantity;
1047
+ }
1048
+ }
1049
+
1050
+ if (usdTotal > 0) {
1051
+ // Get exchange rate for this currency
1052
+ const rateSymbol =
1053
+ currency.method?.type === 'arcblock' ? 'ABT' : `${currency.symbol}@${currency.method?.type}`;
1054
+ // eslint-disable-next-line no-await-in-loop
1055
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
1056
+ const rate = parseFloat(rateResult.rate);
1057
+
1058
+ if (rate > 0) {
1059
+ const tokenAmount = usdTotal / rate;
1060
+ const tokenAmountInUnits = Math.floor(tokenAmount * 10 ** currency.decimal);
1061
+ estimatedAmount = `≈ ${formatAmountForDisplay(tokenAmountInUnits.toString(), currency.decimal, currency.symbol)}`;
1062
+ }
1063
+ }
1064
+ } catch (error) {
1065
+ logger.warn('Failed to calculate estimated amount for currency', {
1066
+ currencyId,
1067
+ error: (error as Error).message,
1068
+ });
1069
+ estimatedAmount = '—';
1070
+ }
1071
+ }
1072
+
1073
+ options.push({
1074
+ currency,
1075
+ estimatedAmount,
1076
+ isCurrentMethod,
1077
+ });
1078
+ }
1079
+
1080
+ // Sort: current method first, then by symbol
1081
+ options.sort((a, b) => {
1082
+ if (a.isCurrentMethod) return -1;
1083
+ if (b.isCurrentMethod) return 1;
1084
+ return a.currency.symbol.localeCompare(b.currency.symbol);
1085
+ });
1086
+
1087
+ return res.json({ options });
1088
+ } catch (err: any) {
1089
+ logger.error('Failed to get payment options for invoice', { error: err, invoiceId: req.params.id });
1090
+ return res.status(500).json({ error: `Failed to get payment options: ${err.message}` });
1091
+ }
1092
+ });
1093
+
817
1094
  router.post('/pay-stripe', authPortal, async (req, res) => {
818
1095
  try {
819
1096
  const { invoice_ids, subscription_id, customer_id, currency_id } = req.body;
@@ -1021,4 +1298,5 @@ router.post('/:id/void', authAdmin, async (req, res) => {
1021
1298
  return res.status(400).json({ error: 'Failed to void invoice' });
1022
1299
  }
1023
1300
  });
1301
+
1024
1302
  export default router;
@@ -372,6 +372,7 @@ router.get('/overdue-summary', auth, async (req, res) => {
372
372
  JOIN meters m ON me.event_name = m.event_name
373
373
  WHERE me.livemode = :livemode
374
374
  AND me.status IN ('requires_capture', 'requires_action')
375
+ AND m.status = 'active'
375
376
  GROUP BY m.currency_id
376
377
  HAVING total_pending > 0
377
378
  ORDER BY total_pending DESC`,
@@ -465,6 +466,7 @@ router.get('/overdue-customers', auth, async (req, res) => {
465
466
  JOIN meters m ON me.event_name = m.event_name
466
467
  WHERE me.livemode = :livemode
467
468
  AND me.status IN ('requires_capture', 'requires_action')
469
+ AND m.status = 'active'
468
470
  ${currencyFilter}
469
471
  ${customerFilter}
470
472
  GROUP BY json_extract(me.payload, '$.customer_id'), m.currency_id
@@ -485,6 +487,7 @@ router.get('/overdue-customers', auth, async (req, res) => {
485
487
  JOIN meters m ON me.event_name = m.event_name
486
488
  WHERE me.livemode = :livemode
487
489
  AND me.status IN ('requires_capture', 'requires_action')
490
+ AND m.status = 'active'
488
491
  ${currencyFilter}
489
492
  ${customerFilter}
490
493
  GROUP BY json_extract(me.payload, '$.customer_id'), m.currency_id
@@ -138,6 +138,19 @@ export async function createPaymentLink(payload: any) {
138
138
  throw new Error('Multiple items with custom unit amount are not supported in payment link');
139
139
  }
140
140
 
141
+ // Check for mixed pricing types (fixed + dynamic)
142
+ const hasDynamicPricing = items.some((item) => {
143
+ return item.price?.pricing_type === 'dynamic';
144
+ });
145
+
146
+ const hasFixedPricing = items.some((item) => {
147
+ return !item.price?.pricing_type || item.price?.pricing_type === 'fixed';
148
+ });
149
+
150
+ if (hasDynamicPricing && hasFixedPricing) {
151
+ throw new Error('Cannot mix fixed pricing and dynamic pricing in the same payment link');
152
+ }
153
+
141
154
  for (let i = 0; i < items.length; i++) {
142
155
  const result = isLineItemAligned(items, i);
143
156
  if (result.currency === false) {
@@ -5,6 +5,8 @@ import pick from 'lodash/pick';
5
5
  import type { WhereOptions } from 'sequelize';
6
6
 
7
7
  import { createListParamSchema, getOrder, getWhereFromQuery, MetadataSchema } from '../libs/api';
8
+ import { getExchangeRateService } from '../libs/exchange-rate';
9
+ import { getExchangeRateSymbol, hasTokenAddress } from '../libs/exchange-rate/token-address-mapping';
8
10
  import logger from '../libs/logger';
9
11
  import { authenticate } from '../libs/security';
10
12
  import { canUpsell } from '../libs/session';
@@ -12,7 +14,7 @@ import { PaymentCurrency } from '../store/models/payment-currency';
12
14
  import { Price } from '../store/models/price';
13
15
  import { Product } from '../store/models/product';
14
16
  import { checkCurrencySupportRecurring } from '../libs/product';
15
- import { Meter } from '../store/models';
17
+ import { ChainType, Meter, PaymentMethod } from '../store/models';
16
18
 
17
19
  const router = Router();
18
20
 
@@ -66,6 +68,41 @@ const CreditConfigSchema = Joi.object({
66
68
  'any.invalid': 'valid_duration_* is mutually exclusive with schedule.expire_with_next_grant',
67
69
  });
68
70
 
71
+ const exchangeRateService = getExchangeRateService();
72
+
73
+ async function validateDynamicPricingCurrencies(currencyIds: string[]): Promise<string | null> {
74
+ if (!currencyIds?.length) {
75
+ return null;
76
+ }
77
+
78
+ const uniqueIds = Array.from(new Set(currencyIds.filter(Boolean)));
79
+ const currencies = (await PaymentCurrency.findAll({
80
+ where: { id: uniqueIds },
81
+ include: [{ model: PaymentMethod, as: 'payment_method' }],
82
+ })) as (PaymentCurrency & { payment_method: PaymentMethod })[];
83
+ const unsupported: string[] = [];
84
+
85
+ await Promise.all(
86
+ currencies.map(async (currency) => {
87
+ const rateSymbol = getExchangeRateSymbol(currency.symbol, currency.payment_method?.type as ChainType);
88
+ if (currency.payment_method.type === 'stripe') {
89
+ return;
90
+ }
91
+ if (!hasTokenAddress(rateSymbol)) {
92
+ unsupported.push(`${currency.symbol}: missing token mapping`);
93
+ return;
94
+ }
95
+ try {
96
+ await exchangeRateService.getRate(rateSymbol);
97
+ } catch (error: any) {
98
+ unsupported.push(`${currency.symbol}: ${error.message}`);
99
+ }
100
+ })
101
+ );
102
+
103
+ return unsupported.length ? `Exchange rate not available for ${unsupported.join(', ')}` : null;
104
+ }
105
+
69
106
  export async function getExpandedPrice(id: string) {
70
107
  const price = await Price.findByPkOrLookupKey(id, {
71
108
  include: [
@@ -285,6 +322,18 @@ router.post('/', auth, async (req, res) => {
285
322
  return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
286
323
  }
287
324
  }
325
+
326
+ if (req.body.pricing_type === 'dynamic') {
327
+ const currencyIds = req.body.currency_options?.map((x: any) => x.currency_id).filter(Boolean) || [];
328
+ if (req.body.currency_id) {
329
+ currencyIds.push(req.body.currency_id);
330
+ }
331
+ const validationError = await validateDynamicPricingCurrencies(currencyIds);
332
+ if (validationError) {
333
+ return res.status(400).json({ error: validationError });
334
+ }
335
+ }
336
+
288
337
  const result = await createPrice({
289
338
  ...req.body,
290
339
  livemode: !!req.livemode,
@@ -388,7 +437,28 @@ router.put('/:id', auth, async (req, res) => {
388
437
  req.body,
389
438
  locked
390
439
  ? ['nickname', 'description', 'metadata', 'upsell', 'lookup_key', ...quantityKeys]
391
- : ['type', 'model', 'active', 'livemode', 'nickname', 'recurring', 'description', 'tiers', 'unit_amount', 'transform_quantity', 'metadata', 'currency_id', 'lookup_key', 'currency_options', 'upsell', ...quantityKeys] // prettier-ignore
440
+ : [
441
+ 'type',
442
+ 'model',
443
+ 'active',
444
+ 'livemode',
445
+ 'nickname',
446
+ 'recurring',
447
+ 'description',
448
+ 'tiers',
449
+ 'unit_amount',
450
+ 'transform_quantity',
451
+ 'metadata',
452
+ 'currency_id',
453
+ 'lookup_key',
454
+ 'currency_options',
455
+ 'upsell',
456
+ 'pricing_type',
457
+ 'base_currency',
458
+ 'base_amount',
459
+ 'dynamic_pricing_config',
460
+ ...quantityKeys,
461
+ ] // prettier-ignore
392
462
  )
393
463
  );
394
464
 
@@ -454,6 +524,18 @@ router.put('/:id', auth, async (req, res) => {
454
524
  .json({ error: `currency ${notSupportCurrencies.map((x) => x.name).join(', ')} does not support recurring` });
455
525
  }
456
526
 
527
+ const pricingType = updates.pricing_type || doc.pricing_type;
528
+ if (pricingType === 'dynamic') {
529
+ const currencyOptions = updates.currency_options || doc.currency_options || [];
530
+ const currencyIds = currencyOptions.map((x: any) => x.currency_id).filter(Boolean);
531
+ const validationError = await validateDynamicPricingCurrencies(
532
+ currencyIds.length ? currencyIds : [updates.currency_id || doc.currency_id]
533
+ );
534
+ if (validationError) {
535
+ return res.status(400).json({ error: validationError });
536
+ }
537
+ }
538
+
457
539
  try {
458
540
  await doc.update(Price.formatBeforeSave(updates));
459
541
  logger.info(`Price updated: ${req.params.id}`, { priceId: req.params.id, updates, requestedBy: req.user?.did });