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
@@ -0,0 +1,158 @@
1
+ import { Price, PaymentCurrency } from '../store/models';
2
+ import { getQuoteService, type RateResult } from './quote-service';
3
+ import { getExchangeRateService } from './exchange-rate';
4
+ import logger from './logger';
5
+
6
+ export interface InvoiceItemInput {
7
+ price_id: string;
8
+ quantity: number;
9
+ amount: string;
10
+ subscription_item_id?: string;
11
+ description: string;
12
+ period?: { start: number; end: number };
13
+ metadata?: Record<string, any>;
14
+ discountable?: boolean;
15
+ discount_amounts?: any[];
16
+ }
17
+
18
+ export interface InvoiceItemWithQuote extends InvoiceItemInput {
19
+ quote_id?: string;
20
+ }
21
+
22
+ /**
23
+ * Generate quotes for dynamic pricing invoice items
24
+ * This function checks each invoice item's price and generates a quote if it uses dynamic pricing
25
+ */
26
+ export async function generateQuotesForInvoiceItems(params: {
27
+ invoiceId: string;
28
+ items: InvoiceItemInput[];
29
+ currencyId: string;
30
+ }): Promise<InvoiceItemWithQuote[]> {
31
+ const { invoiceId, items, currencyId } = params;
32
+
33
+ if (!items || items.length === 0) {
34
+ return [];
35
+ }
36
+
37
+ // Load currency with payment method
38
+ const currency = await PaymentCurrency.findByPk(currencyId, {
39
+ include: [{ association: 'payment_method' }],
40
+ });
41
+ if (!currency) {
42
+ throw new Error(`Currency ${currencyId} not found`);
43
+ }
44
+
45
+ // Get exchange rate once for all dynamic prices (for efficiency)
46
+ // For ArcBlock payment method, always use ABT for exchange rate
47
+ const exchangeRateService = getExchangeRateService();
48
+ const rateSymbol = (currency as any).payment_method?.type === 'arcblock' ? 'ABT' : currency.symbol;
49
+ let rateResult: RateResult | null = null;
50
+
51
+ const quoteService = getQuoteService();
52
+ const enrichedItems: InvoiceItemWithQuote[] = [];
53
+
54
+ // Note: Sequential processing is intentional here
55
+ // 1. Prices may not exist (early continue)
56
+ // 2. Exchange rate is fetched once and reused (rateResult)
57
+ // 3. Quote failures must be handled immediately (P0 critical)
58
+ // eslint-disable-next-line no-restricted-syntax
59
+ for (const item of items) {
60
+ // eslint-disable-next-line no-await-in-loop
61
+ const price = await Price.findByPk(item.price_id);
62
+ if (!price) {
63
+ logger.warn('Price not found for invoice item', { priceId: item.price_id, invoiceId });
64
+ enrichedItems.push(item);
65
+ // eslint-disable-next-line no-continue
66
+ continue;
67
+ }
68
+
69
+ // Skip items with zero or negative quantity (free trial, metered billing outside billing period)
70
+ // These items don't require quote generation
71
+ if (item.quantity <= 0) {
72
+ enrichedItems.push(item);
73
+ // eslint-disable-next-line no-continue
74
+ continue;
75
+ }
76
+
77
+ // Check if price uses dynamic pricing
78
+ if (price.pricing_type !== 'dynamic') {
79
+ // Fixed pricing - use item amount as-is
80
+ enrichedItems.push(item);
81
+ // eslint-disable-next-line no-continue
82
+ continue;
83
+ }
84
+
85
+ // Dynamic pricing - generate quote
86
+ try {
87
+ // Get exchange rate if not already fetched
88
+ if (!rateResult) {
89
+ // eslint-disable-next-line no-await-in-loop
90
+ rateResult = await exchangeRateService.getRate(rateSymbol);
91
+ }
92
+
93
+ // Create quote for this invoice item
94
+ // eslint-disable-next-line no-await-in-loop
95
+ const { quote } = await quoteService.createQuoteWithRate({
96
+ price_id: item.price_id,
97
+ invoice_id: invoiceId,
98
+ target_currency_id: currencyId,
99
+ quantity: item.quantity,
100
+ rateResult,
101
+ });
102
+
103
+ logger.info('Generated quote for invoice item', {
104
+ invoiceId,
105
+ priceId: item.price_id,
106
+ quoteId: quote.id,
107
+ quotedAmount: quote.quoted_amount,
108
+ });
109
+
110
+ // Update item with quote info
111
+ // Only store quote_id in metadata - full quote info is attached dynamically when querying
112
+ // See: invoices.ts attachQuoteMetadataToLines()
113
+ enrichedItems.push({
114
+ ...item,
115
+ amount: quote.quoted_amount,
116
+ quote_id: quote.id,
117
+ metadata: {
118
+ ...item.metadata,
119
+ quote_id: quote.id,
120
+ },
121
+ });
122
+ } catch (error: any) {
123
+ logger.error('Failed to generate quote for invoice item', {
124
+ invoiceId,
125
+ priceId: item.price_id,
126
+ error: error.message,
127
+ });
128
+
129
+ // P0 CRITICAL: Do not allow fallback for dynamic pricing
130
+ // Quote is the only source of truth for dynamic pricing amounts
131
+ // If quote generation fails, the invoice creation must fail
132
+ throw new Error(`Failed to generate quote for dynamic price ${item.price_id}: ${error.message}`);
133
+ }
134
+ }
135
+
136
+ return enrichedItems;
137
+ }
138
+
139
+ /**
140
+ * Check if any invoice items use dynamic pricing
141
+ */
142
+ export async function hasDynamicPricingItems(items: InvoiceItemInput[]): Promise<boolean> {
143
+ if (!items || items.length === 0) {
144
+ return false;
145
+ }
146
+
147
+ const priceIds = items.map((item) => item.price_id).filter(Boolean);
148
+ if (priceIds.length === 0) {
149
+ return false;
150
+ }
151
+
152
+ const prices = await Price.findAll({
153
+ where: { id: priceIds },
154
+ attributes: ['id', 'pricing_type'],
155
+ });
156
+
157
+ return prices.some((price) => price.pricing_type === 'dynamic');
158
+ }
@@ -14,6 +14,7 @@ import {
14
14
  PaymentMethod,
15
15
  PaymentSettings,
16
16
  Price,
17
+ PriceQuote,
17
18
  Product,
18
19
  Refund,
19
20
  SetupIntent,
@@ -42,6 +43,10 @@ import { ensureOverdraftProtectionPrice } from './overdraft-protection';
42
43
  import { CHARGE_SUPPORTED_CHAIN_TYPES } from './constants';
43
44
  import { emitAsync } from './event';
44
45
  import { getPriceUintAmountByCurrency } from './price';
46
+ import { generateQuotesForInvoiceItems } from './invoice-quote';
47
+ import { getExchangeRateService, getExchangeRateSymbol } from './exchange-rate';
48
+ import { getQuoteService } from './quote-service';
49
+ import type { ChainType } from '../store/models';
45
50
 
46
51
  export function getCustomerInvoicePageUrl({
47
52
  invoiceId,
@@ -121,6 +126,27 @@ export async function getInvoiceShouldPayTotal(invoice: Invoice) {
121
126
  const subscriptionItems = await SubscriptionItem.findAll({
122
127
  where: { subscription_id: subscription.id },
123
128
  });
129
+ const invoiceItems = await InvoiceItem.findAll({
130
+ where: { invoice_id: invoice.id },
131
+ });
132
+ const invoiceQuoteIds = invoiceItems
133
+ .map((item) => item.metadata?.quote_id)
134
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
135
+ const invoiceQuotes = invoiceQuoteIds.length ? await PriceQuote.findAll({ where: { id: invoiceQuoteIds } }) : [];
136
+ const invoiceQuotesById = new Map(invoiceQuotes.map((quote) => [quote.id, quote]));
137
+ const invoiceQuoteBySubscriptionItemId = new Map<string, { quotedAmount?: string; amount?: string }>();
138
+ invoiceItems.forEach((item) => {
139
+ if (!item.subscription_item_id) {
140
+ return;
141
+ }
142
+ const quoteId = typeof item.metadata?.quote_id === 'string' ? item.metadata?.quote_id : undefined;
143
+ const quote = quoteId ? invoiceQuotesById.get(quoteId) : undefined;
144
+ const quotedAmount = quote?.quoted_amount || (item.metadata as any)?.quote?.quoted_amount;
145
+ invoiceQuoteBySubscriptionItemId.set(item.subscription_item_id, {
146
+ quotedAmount,
147
+ amount: item.amount,
148
+ });
149
+ });
124
150
 
125
151
  let expandedItems = await Price.expand(
126
152
  subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
@@ -163,6 +189,28 @@ export async function getInvoiceShouldPayTotal(invoice: Invoice) {
163
189
  })
164
190
  );
165
191
 
192
+ if (invoiceQuoteBySubscriptionItemId.size > 0) {
193
+ expandedItems = expandedItems.map((item: any) => {
194
+ const { price } = item;
195
+ if (price?.pricing_type !== 'dynamic') {
196
+ return item;
197
+ }
198
+ const quoteInfo = invoiceQuoteBySubscriptionItemId.get(item.id);
199
+ if (!quoteInfo) {
200
+ return item;
201
+ }
202
+ const quotedAmount = quoteInfo.quotedAmount || quoteInfo.amount;
203
+ if (!quotedAmount) {
204
+ return item;
205
+ }
206
+ return {
207
+ ...item,
208
+ custom_amount: quotedAmount,
209
+ quoted_amount: quotedAmount,
210
+ };
211
+ });
212
+ }
213
+
166
214
  const baseAmount = getSubscriptionCycleAmount(expandedItems, subscription.currency_id);
167
215
  let shouldPayTotal = baseAmount?.total || invoice.total;
168
216
 
@@ -431,6 +479,8 @@ type BaseInvoiceProps = {
431
479
  ending_token_balance?: Record<string, string>;
432
480
  subtotal_excluding_tax?: string;
433
481
  collection_method?: 'charge_automatically' | 'send_invoice';
482
+ id?: string;
483
+ quote_id?: string;
434
484
  };
435
485
 
436
486
  async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
@@ -519,9 +569,19 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
519
569
  if (!itemsData) {
520
570
  return { invoice, items: [] };
521
571
  }
572
+
573
+ // Generate quotes for dynamic pricing items (subscription invoices)
574
+ const enrichedItemsData = subscription
575
+ ? await generateQuotesForInvoiceItems({
576
+ invoiceId: invoice.id,
577
+ items: itemsData,
578
+ currencyId,
579
+ })
580
+ : itemsData;
581
+
522
582
  // create invoice items
523
583
  const items = await Promise.all(
524
- itemsData.map(async (item) => {
584
+ enrichedItemsData.map(async (item) => {
525
585
  // Match tax rate for this specific item
526
586
  let taxRateId: string | undefined;
527
587
  if (customer.address?.country && item.price_id) {
@@ -565,7 +625,11 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
565
625
  [],
566
626
  proration: false,
567
627
  proration_details: {},
568
- metadata: item.metadata || {},
628
+ metadata: {
629
+ ...(item.metadata || {}),
630
+ // Include quote_id in metadata if it exists (for dynamic pricing)
631
+ ...((item as any).quote_id && { quote_id: (item as any).quote_id }),
632
+ },
569
633
  });
570
634
  })
571
635
  );
@@ -618,6 +682,7 @@ export async function ensureInvoiceAndItems({
618
682
 
619
683
  function getLineSetup(x: TLineItemExpanded) {
620
684
  const price = getSubscriptionItemPrice(x);
685
+
621
686
  if (price.type === 'recurring' && trialing) {
622
687
  return {
623
688
  price,
@@ -631,11 +696,13 @@ export async function ensureInvoiceAndItems({
631
696
  };
632
697
  }
633
698
 
699
+ const calculatedAmount =
700
+ x.custom_amount ||
701
+ new BN(getPriceUintAmountByCurrency(price, props.currency_id)).mul(new BN(x.quantity)).toString();
702
+
634
703
  return {
635
704
  price,
636
- amount:
637
- x.custom_amount ||
638
- new BN(getPriceUintAmountByCurrency(price, props.currency_id)).mul(new BN(x.quantity)).toString(),
705
+ amount: calculatedAmount,
639
706
  description: price.product.name,
640
707
  period: undefined,
641
708
  };
@@ -665,6 +732,8 @@ export async function ensureInvoiceAndItems({
665
732
  // Discount fields from pre-calculated line items
666
733
  discountable: x.discountable || false,
667
734
  discount_amounts: x.discount_amounts || [],
735
+ // Pass quote_id for dynamic pricing audit trail
736
+ quote_id: (x as any).quote_id,
668
737
  };
669
738
  });
670
739
 
@@ -1361,7 +1430,74 @@ export const migrateSubscriptionPaymentMethodInvoice = async (
1361
1430
  const metadata: Record<string, any> = {
1362
1431
  prev_invoice_id: preInvoice.id,
1363
1432
  };
1364
- const amount = getSubscriptionCycleAmount(subscriptionItemsExpanded, newCurrencyId);
1433
+
1434
+ // For dynamic pricing items, we need to create new quotes with the new currency's exchange rate
1435
+ const dynamicItems = subscriptionItemsExpanded.filter(
1436
+ (item) => ((item as any)?.upsell_price || (item as any)?.price)?.pricing_type === 'dynamic'
1437
+ );
1438
+
1439
+ let enrichedLineItems = subscriptionItemsExpanded;
1440
+
1441
+ if (dynamicItems.length > 0 && newPaymentMethod.type !== 'stripe') {
1442
+ // Get fresh exchange rate for the new currency
1443
+ const exchangeRateService = getExchangeRateService();
1444
+ const quoteService = getQuoteService();
1445
+ const rateSymbol = getExchangeRateSymbol(newPaymentCurrency.symbol, newPaymentMethod.type as ChainType);
1446
+ const rateResult = await exchangeRateService.getRate(rateSymbol);
1447
+
1448
+ logger.info('Creating new quotes for dynamic pricing items during payment method change', {
1449
+ subscriptionId: subscription.id,
1450
+ newCurrencyId,
1451
+ rateSymbol,
1452
+ exchangeRate: rateResult.rate,
1453
+ dynamicItemCount: dynamicItems.length,
1454
+ });
1455
+
1456
+ // Create quotes for each dynamic pricing item
1457
+ const quoteResults = await Promise.all(
1458
+ dynamicItems.map(async (item) => {
1459
+ const targetPrice: any = (item as any).upsell_price || (item as any).price;
1460
+ const quoteResponse = await quoteService.createQuoteWithRate({
1461
+ price_id: targetPrice.id,
1462
+ target_currency_id: newCurrencyId,
1463
+ quantity: item.quantity,
1464
+ rateResult,
1465
+ });
1466
+ return { item, quoteResponse };
1467
+ })
1468
+ );
1469
+
1470
+ // Build a map for quick lookup
1471
+ const quoteMap = new Map<string, (typeof quoteResults)[number]>();
1472
+ quoteResults.forEach((result) => {
1473
+ const targetPrice: any = (result.item as any).upsell_price || (result.item as any).price;
1474
+ quoteMap.set(targetPrice.id, result);
1475
+ });
1476
+
1477
+ // Enrich line items with quote information
1478
+ enrichedLineItems = subscriptionItemsExpanded.map((item) => {
1479
+ const targetPrice: any = (item as any).upsell_price || (item as any).price;
1480
+ const hit = quoteMap.get(targetPrice?.id);
1481
+ if (!hit) {
1482
+ return item;
1483
+ }
1484
+ const { quoteResponse } = hit;
1485
+ return {
1486
+ ...item,
1487
+ quote_id: quoteResponse.quote.id,
1488
+ quoted_amount: quoteResponse.computed_unit_amount,
1489
+ exchange_rate: quoteResponse.quote.exchange_rate,
1490
+ rate_provider_name: quoteResponse.quote.rate_provider_name,
1491
+ rate_provider_id: quoteResponse.quote.rate_provider_id,
1492
+ custom_amount: quoteResponse.computed_unit_amount,
1493
+ } as any;
1494
+ });
1495
+
1496
+ // Store quote IDs in metadata for audit trail
1497
+ metadata.quote_ids = quoteResults.map((r) => r.quoteResponse.quote.id);
1498
+ }
1499
+
1500
+ const amount = getSubscriptionCycleAmount(enrichedLineItems, newCurrencyId);
1365
1501
 
1366
1502
  const { invoice } = await ensureInvoiceAndItems({
1367
1503
  customer,
@@ -1369,7 +1505,7 @@ export const migrateSubscriptionPaymentMethodInvoice = async (
1369
1505
  subscription,
1370
1506
  trialing: subscription.status === 'trialing',
1371
1507
  metered: false,
1372
- lineItems: subscriptionItemsExpanded,
1508
+ lineItems: enrichedLineItems,
1373
1509
  applyCredit: false,
1374
1510
  props: {
1375
1511
  status: 'open',
@@ -1,6 +1,52 @@
1
+ import { BN } from '@ocap/util';
2
+
1
3
  export function trimDecimals(value: string | number, maxDecimals: number): string {
2
4
  const num = typeof value === 'number' ? value : parseFloat(value || '0');
3
5
  const multiplier = 10 ** maxDecimals;
4
6
  const rounded = Math.round(num * multiplier) / multiplier;
5
7
  return rounded.toString();
6
8
  }
9
+
10
+ /**
11
+ * Limit precision of token amount in smallest unit to avoid meaningless precision
12
+ *
13
+ * For dynamic pricing, we limit precision to at most 10 decimal places in token amount.
14
+ * This is done by rounding to the nearest 10^(decimal - MAX_SIGNIFICANT_DECIMALS) unit.
15
+ *
16
+ * @param amountInSmallestUnit - Amount in token's smallest unit (e.g., wei for 18 decimals)
17
+ * @param tokenDecimals - Token decimal places (e.g., 18 for most ERC20 tokens)
18
+ * @param maxSignificantDecimals - Maximum significant decimal places to keep (default: 10)
19
+ * @returns Rounded amount with limited precision
20
+ *
21
+ * @example
22
+ * // Token with 18 decimals, amount = 1234567890123456789 wei (1.234567890123456789 tokens)
23
+ * // With maxSignificantDecimals = 10, we round to nearest 10^8 wei
24
+ * // Result: 1234567890200000000 wei (1.2345678902 tokens)
25
+ * limitTokenPrecision('1234567890123456789', 18, 10) // Returns BN of 1234567890200000000
26
+ */
27
+ export function limitTokenPrecision(
28
+ amountInSmallestUnit: any, // BN type
29
+ tokenDecimals: number,
30
+ maxSignificantDecimals: number = 10
31
+ ): any {
32
+ const amount = new BN(amountInSmallestUnit.toString());
33
+
34
+ // If token has fewer decimals than our max, no need to limit
35
+ if (tokenDecimals <= maxSignificantDecimals) {
36
+ return amount;
37
+ }
38
+
39
+ // Calculate the precision unit to round to
40
+ // For example, if tokenDecimals = 18 and maxSignificantDecimals = 6
41
+ // precisionUnit = 10^(18-6) = 10^12
42
+ const precisionExponent = tokenDecimals - maxSignificantDecimals;
43
+ const precisionUnit = new BN(10).pow(new BN(precisionExponent));
44
+
45
+ // Round to nearest precisionUnit using ceiling division
46
+ // This ensures we don't undercharge the customer
47
+ // Formula: ceil(amount / precisionUnit) * precisionUnit
48
+ const quotient = amount.add(precisionUnit).sub(new BN(1)).div(precisionUnit);
49
+ const roundedAmount = quotient.mul(precisionUnit);
50
+
51
+ return roundedAmount;
52
+ }
@@ -1,6 +1,5 @@
1
- import { fromUnitToToken } from '@ocap/util';
2
1
  import prettyMsI18n from 'pretty-ms-i18n';
3
- import { getOwnerDid } from '../../util';
2
+ import { formatTokenAmount, getOwnerDid } from '../../util';
4
3
  import { translate } from '../../../locales';
5
4
  import { Invoice, PaymentCurrency, Subscription } from '../../../store/models';
6
5
  import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
@@ -78,13 +77,13 @@ export class BillingDiscrepancyEmailTemplate implements BaseEmailTemplate<Billin
78
77
  locale,
79
78
  });
80
79
 
81
- const billingAmount = `${fromUnitToToken(invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
80
+ const billingAmount = `${formatTokenAmount(invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
82
81
 
83
82
  const shouldPayTotal = await getInvoiceShouldPayTotal(invoice);
84
83
  if (shouldPayTotal === invoice.total) {
85
84
  throw new Error('should pay total is equal to invoice total, no need to send billing discrepancy notification');
86
85
  }
87
- const shouldPayAmount = `${fromUnitToToken(shouldPayTotal, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
86
+ const shouldPayAmount = `${formatTokenAmount(shouldPayTotal, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
88
87
  return {
89
88
  userDid,
90
89
  locale,