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.
- package/api/src/crons/overdue-detection.ts +10 -1
- package/api/src/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +1 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +190 -3
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/meter-events.ts +3 -0
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- 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
|
+
}
|
package/api/src/libs/invoice.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 = `${
|
|
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 = `${
|
|
86
|
+
const shouldPayAmount = `${formatTokenAmount(shouldPayTotal, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
|
|
88
87
|
return {
|
|
89
88
|
userDid,
|
|
90
89
|
locale,
|