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,135 @@
|
|
|
1
|
+
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
2
|
+
|
|
3
|
+
import { trimDecimals } from './math-utils';
|
|
4
|
+
|
|
5
|
+
export const SLIPPAGE_BPS_BASE = new BN(10000);
|
|
6
|
+
export const DEFAULT_SLIPPAGE_PERCENT = 0.5;
|
|
7
|
+
|
|
8
|
+
export type SlippageConfig = {
|
|
9
|
+
mode: 'percent' | 'rate';
|
|
10
|
+
percent: number;
|
|
11
|
+
min_acceptable_rate?: string;
|
|
12
|
+
base_currency?: string;
|
|
13
|
+
updated_at_ms?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type SlippageSource = {
|
|
17
|
+
slippage_percent?: number | string | null;
|
|
18
|
+
metadata?: any;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function normalizeSlippagePercent(value: unknown, fallback = DEFAULT_SLIPPAGE_PERCENT): number {
|
|
22
|
+
const normalized = typeof value === 'string' ? Number(value) : Number(value);
|
|
23
|
+
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeSlippageConfigFromMetadata(
|
|
30
|
+
metadata: any,
|
|
31
|
+
fallbackPercent: number = DEFAULT_SLIPPAGE_PERCENT
|
|
32
|
+
): SlippageConfig | null {
|
|
33
|
+
const fromMeta = metadata?.slippage;
|
|
34
|
+
if (!fromMeta || typeof fromMeta !== 'object') {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const mode = fromMeta.mode === 'rate' ? 'rate' : 'percent';
|
|
38
|
+
const percent = normalizeSlippagePercent(fromMeta.percent, fallbackPercent);
|
|
39
|
+
return {
|
|
40
|
+
mode,
|
|
41
|
+
percent,
|
|
42
|
+
min_acceptable_rate: fromMeta.min_acceptable_rate,
|
|
43
|
+
base_currency: fromMeta.base_currency,
|
|
44
|
+
updated_at_ms: fromMeta.updated_at_ms,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveSlippagePercent(
|
|
49
|
+
quote: SlippageSource,
|
|
50
|
+
checkoutSession?: SlippageSource,
|
|
51
|
+
fallbackPercent: number = DEFAULT_SLIPPAGE_PERCENT
|
|
52
|
+
): number {
|
|
53
|
+
const fromQuote = quote.slippage_percent ?? quote.metadata?.slippage?.percent;
|
|
54
|
+
const fromSessionMeta = normalizeSlippageConfigFromMetadata(checkoutSession?.metadata, fallbackPercent)?.percent;
|
|
55
|
+
const fromSession = checkoutSession?.slippage_percent;
|
|
56
|
+
const value = fromQuote ?? fromSessionMeta ?? fromSession ?? fallbackPercent;
|
|
57
|
+
return normalizeSlippagePercent(value, fallbackPercent);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildSlippageSnapshot(params: {
|
|
61
|
+
quote: SlippageSource & { quoted_amount?: string | null; exchange_rate?: string | null };
|
|
62
|
+
checkoutSession?: SlippageSource;
|
|
63
|
+
nowMs?: number;
|
|
64
|
+
}): { percent: number; max_payable_token: string; min_acceptable_rate: string; derived_at_ms: number } | null {
|
|
65
|
+
const { quote, checkoutSession, nowMs = Date.now() } = params;
|
|
66
|
+
if (!quote.quoted_amount || !quote.exchange_rate) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check if user is using rate mode with a specific min_acceptable_rate
|
|
71
|
+
// In this case, preserve user's precise rate value instead of recalculating from percent
|
|
72
|
+
const sessionSlippage = checkoutSession?.metadata?.slippage;
|
|
73
|
+
const userSetMinRate = sessionSlippage?.mode === 'rate' && sessionSlippage?.min_acceptable_rate;
|
|
74
|
+
|
|
75
|
+
const slippagePercent = resolveSlippagePercent(quote, checkoutSession);
|
|
76
|
+
const slippageBps = Math.round(slippagePercent * 100);
|
|
77
|
+
const multiplier = SLIPPAGE_BPS_BASE.add(new BN(slippageBps));
|
|
78
|
+
const quotedAmountUnit = new BN(quote.quoted_amount);
|
|
79
|
+
const maxPayableToken = quotedAmountUnit
|
|
80
|
+
.mul(multiplier)
|
|
81
|
+
.add(SLIPPAGE_BPS_BASE.sub(new BN(1)))
|
|
82
|
+
.div(SLIPPAGE_BPS_BASE);
|
|
83
|
+
|
|
84
|
+
let minAcceptableRate: string;
|
|
85
|
+
if (userSetMinRate) {
|
|
86
|
+
// Rate mode: preserve user's precise min_acceptable_rate value
|
|
87
|
+
minAcceptableRate = sessionSlippage.min_acceptable_rate;
|
|
88
|
+
} else {
|
|
89
|
+
// Percent mode: calculate min_acceptable_rate from percent
|
|
90
|
+
const USD_DECIMALS = 8;
|
|
91
|
+
const rateBN = fromTokenToUnit(trimDecimals(quote.exchange_rate, USD_DECIMALS), USD_DECIMALS);
|
|
92
|
+
const minRateBN = rateBN.mul(SLIPPAGE_BPS_BASE).div(multiplier);
|
|
93
|
+
minAcceptableRate = fromUnitToToken(minRateBN.toString(), USD_DECIMALS);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
percent: slippagePercent,
|
|
98
|
+
max_payable_token: maxPayableToken.toString(),
|
|
99
|
+
min_acceptable_rate: minAcceptableRate,
|
|
100
|
+
derived_at_ms: nowMs,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isRateBelowMinAcceptableRate(
|
|
105
|
+
currentRate?: string | number | null,
|
|
106
|
+
minAcceptableRate?: string | number | null
|
|
107
|
+
): boolean {
|
|
108
|
+
const current = Number(currentRate);
|
|
109
|
+
const min = Number(minAcceptableRate);
|
|
110
|
+
if (!Number.isFinite(current) || !Number.isFinite(min)) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return current < min;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply slippage to an amount to get the maximum payable amount
|
|
118
|
+
* This is used for delegation authorization to ensure sufficient buffer
|
|
119
|
+
*
|
|
120
|
+
* @param amount - Base amount in smallest unit (e.g., wei)
|
|
121
|
+
* @param slippagePercent - Slippage percentage (e.g., 15 for 15%)
|
|
122
|
+
* @returns Maximum amount with slippage applied (rounded up)
|
|
123
|
+
*/
|
|
124
|
+
export function applySlippageToAmount(amount: BN, slippagePercent: number): BN {
|
|
125
|
+
if (!slippagePercent || slippagePercent <= 0) {
|
|
126
|
+
return amount;
|
|
127
|
+
}
|
|
128
|
+
const slippageBps = Math.round(slippagePercent * 100);
|
|
129
|
+
const multiplier = SLIPPAGE_BPS_BASE.add(new BN(slippageBps));
|
|
130
|
+
// Round up: (amount * multiplier + BASE - 1) / BASE
|
|
131
|
+
return amount
|
|
132
|
+
.mul(multiplier)
|
|
133
|
+
.add(SLIPPAGE_BPS_BASE.sub(new BN(1)))
|
|
134
|
+
.div(SLIPPAGE_BPS_BASE);
|
|
135
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable no-await-in-loop */
|
|
2
2
|
import component from '@blocklet/sdk/lib/component';
|
|
3
|
-
import { BN, fromUnitToToken } from '@ocap/util';
|
|
3
|
+
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
4
4
|
import isEmpty from 'lodash/isEmpty';
|
|
5
5
|
import trim from 'lodash/trim';
|
|
6
6
|
import pick from 'lodash/pick';
|
|
@@ -9,6 +9,7 @@ import { withQuery } from 'ufo';
|
|
|
9
9
|
|
|
10
10
|
import { Op } from 'sequelize';
|
|
11
11
|
import {
|
|
12
|
+
ChainType,
|
|
12
13
|
Customer,
|
|
13
14
|
Invoice,
|
|
14
15
|
InvoiceItem,
|
|
@@ -30,13 +31,17 @@ import { createEvent } from './audit';
|
|
|
30
31
|
import dayjs from './dayjs';
|
|
31
32
|
import env from './env';
|
|
32
33
|
import logger from './logger';
|
|
34
|
+
import { getExchangeRateService } from './exchange-rate';
|
|
35
|
+
import { getExchangeRateSymbol } from './exchange-rate/token-address-mapping';
|
|
36
|
+
import { trimDecimals, limitTokenPrecision } from './math-utils';
|
|
33
37
|
import { getPriceCurrencyOptions, getPriceUintAmountByCurrency } from './price';
|
|
34
|
-
import { getRecurringPeriod, getSubscriptionCreateSetup } from './session';
|
|
38
|
+
import { getRecurringPeriod, getSubscriptionCreateSetup, SlippageOptions } from './session';
|
|
35
39
|
import { getConnectQueryParam, getCustomerStakeAddress } from './util';
|
|
36
40
|
import { wallet } from './auth';
|
|
37
41
|
import { getGasPayerExtra } from './payment';
|
|
38
42
|
import { getLock } from './lock';
|
|
39
43
|
import { emitAsync } from './event';
|
|
44
|
+
import { getSubscriptionItemPrice } from './credit-utils';
|
|
40
45
|
|
|
41
46
|
export function getCustomerSubscriptionPageUrl({
|
|
42
47
|
subscriptionId,
|
|
@@ -263,9 +268,16 @@ export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyI
|
|
|
263
268
|
let amount = new BN(0);
|
|
264
269
|
|
|
265
270
|
items.forEach((x) => {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
271
|
+
const price = getSubscriptionItemPrice(x) as any;
|
|
272
|
+
const dynamicAmount =
|
|
273
|
+
price?.pricing_type === 'dynamic' ? (x as any).custom_amount || (x as any).quoted_amount : null;
|
|
274
|
+
|
|
275
|
+
if (dynamicAmount) {
|
|
276
|
+
amount = amount.add(new BN(dynamicAmount));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
amount = amount.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
|
|
269
281
|
});
|
|
270
282
|
|
|
271
283
|
return {
|
|
@@ -273,9 +285,8 @@ export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyI
|
|
|
273
285
|
};
|
|
274
286
|
}
|
|
275
287
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
288
|
+
// Re-exported from credit-utils.ts to maintain backward compatibility
|
|
289
|
+
export { getSubscriptionItemPrice } from './credit-utils';
|
|
279
290
|
|
|
280
291
|
export async function createProration(
|
|
281
292
|
subscription: Subscription,
|
|
@@ -330,17 +341,29 @@ export async function createProration(
|
|
|
330
341
|
const prorations = await Promise.all(
|
|
331
342
|
prorationItems.map((x: TLineItemExpanded & { [key: string]: any }) => {
|
|
332
343
|
const price = getSubscriptionItemPrice(x);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
.
|
|
344
|
+
// For dynamic pricing, use the actual paid amount from invoice item
|
|
345
|
+
// For fixed pricing, use the standard unit_amount calculation
|
|
346
|
+
let baseAmount: string;
|
|
347
|
+
if (price.pricing_type === 'dynamic') {
|
|
348
|
+
// Use invoice item's actual amount (from Quote)
|
|
349
|
+
baseAmount = x.amount || '0';
|
|
350
|
+
logger.info('Using dynamic pricing amount for proration', {
|
|
351
|
+
subscriptionId: subscription.id,
|
|
352
|
+
priceId: price.id,
|
|
353
|
+
actualAmount: baseAmount,
|
|
354
|
+
});
|
|
355
|
+
} else {
|
|
356
|
+
const unitAmount = getPriceUintAmountByCurrency(price, lastInvoice.currency_id);
|
|
357
|
+
baseAmount = new BN(unitAmount).mul(new BN(x.quantity)).toString();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const amount = new BN(baseAmount).mul(new BN(prorationRate)).div(new BN(precision)).toString();
|
|
339
361
|
logger.info('subscription proration item', {
|
|
340
362
|
subscription: subscription.id,
|
|
341
363
|
invoice: x.invoice_id,
|
|
342
364
|
invoiceItem: x.id,
|
|
343
365
|
amount,
|
|
366
|
+
isDynamic: price.pricing_type === 'dynamic',
|
|
344
367
|
});
|
|
345
368
|
unused = unused.add(new BN(amount));
|
|
346
369
|
|
|
@@ -362,6 +385,10 @@ export async function createProration(
|
|
|
362
385
|
invoice_line_items: [x.id],
|
|
363
386
|
},
|
|
364
387
|
},
|
|
388
|
+
// Additional info for frontend to recalculate with current exchange rate
|
|
389
|
+
pricing_type: price.pricing_type,
|
|
390
|
+
base_amount_usd: price.pricing_type === 'dynamic' ? price.base_amount : null,
|
|
391
|
+
proration_rate: prorationRate / precision, // e.g., 0.85 means 85% unused time
|
|
365
392
|
};
|
|
366
393
|
})
|
|
367
394
|
);
|
|
@@ -437,7 +464,17 @@ export async function createProration(
|
|
|
437
464
|
export async function getSubscriptionRefundSetup(subscription: Subscription, anchor: number, currencyId?: string) {
|
|
438
465
|
const items = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
439
466
|
const expanded = await Price.expand(items.map((x) => x.toJSON()));
|
|
440
|
-
const
|
|
467
|
+
const targetCurrencyId = currencyId || subscription.currency_id;
|
|
468
|
+
|
|
469
|
+
// Build slippage options with min_acceptable_rate for precise authorization calculation
|
|
470
|
+
const slippageConfig = subscription.slippage_config;
|
|
471
|
+
const currency = await PaymentCurrency.findByPk(targetCurrencyId);
|
|
472
|
+
const slippageOptions: SlippageOptions = {
|
|
473
|
+
percent: slippageConfig?.percent ?? 0.5,
|
|
474
|
+
minAcceptableRate: slippageConfig?.min_acceptable_rate,
|
|
475
|
+
currencyDecimal: currency?.decimal,
|
|
476
|
+
};
|
|
477
|
+
const setup = getSubscriptionCreateSetup(expanded, targetCurrencyId, 0, 0, slippageOptions);
|
|
441
478
|
return createProration(subscription, setup, anchor);
|
|
442
479
|
}
|
|
443
480
|
|
|
@@ -488,8 +525,13 @@ export async function getUpcomingInvoiceAmount(subscriptionId: string) {
|
|
|
488
525
|
|
|
489
526
|
let amount = new BN(0);
|
|
490
527
|
let minExpectedAmount = new BN(0);
|
|
528
|
+
let hasDynamicPricing = false;
|
|
529
|
+
|
|
491
530
|
for (const item of expanded) {
|
|
492
531
|
const price = getSubscriptionItemPrice(item);
|
|
532
|
+
if (price.pricing_type === 'dynamic') {
|
|
533
|
+
hasDynamicPricing = true;
|
|
534
|
+
}
|
|
493
535
|
if (price.type === 'recurring') {
|
|
494
536
|
const unit = getPriceUintAmountByCurrency(price, subscription.currency_id);
|
|
495
537
|
if (price.recurring?.usage_type === 'licensed') {
|
|
@@ -513,12 +555,43 @@ export async function getUpcomingInvoiceAmount(subscriptionId: string) {
|
|
|
513
555
|
}
|
|
514
556
|
}
|
|
515
557
|
|
|
558
|
+
// Get exchange rate info for dynamic pricing subscriptions
|
|
559
|
+
let quoteInfo: {
|
|
560
|
+
exchange_rate: string;
|
|
561
|
+
rate_timestamp_ms: number;
|
|
562
|
+
providers: Array<{ provider_name: string; rate?: string }>;
|
|
563
|
+
} | null = null;
|
|
564
|
+
|
|
565
|
+
if (hasDynamicPricing && currency) {
|
|
566
|
+
try {
|
|
567
|
+
const paymentMethod = await PaymentMethod.findByPk(currency.payment_method_id);
|
|
568
|
+
const exchangeRateService = getExchangeRateService();
|
|
569
|
+
const rateSymbol = getExchangeRateSymbol(currency.symbol, paymentMethod?.type as ChainType);
|
|
570
|
+
if (rateSymbol) {
|
|
571
|
+
const rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
572
|
+
if (rateResult?.rate) {
|
|
573
|
+
quoteInfo = {
|
|
574
|
+
exchange_rate: rateResult.rate,
|
|
575
|
+
rate_timestamp_ms: rateResult.timestamp_ms || Date.now(),
|
|
576
|
+
providers: rateResult.providers || [],
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} catch (err) {
|
|
581
|
+
logger.warn('Failed to fetch exchange rate for upcoming invoice', {
|
|
582
|
+
subscriptionId,
|
|
583
|
+
error: err,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
516
588
|
return {
|
|
517
589
|
amount: amount.toString(),
|
|
518
590
|
minExpectedAmount: minExpectedAmount.toString(),
|
|
519
591
|
start: subscription.current_period_start,
|
|
520
592
|
end: subscription.current_period_end,
|
|
521
593
|
currency,
|
|
594
|
+
quoteInfo,
|
|
522
595
|
};
|
|
523
596
|
}
|
|
524
597
|
|
|
@@ -1153,6 +1226,103 @@ export async function getPaymentAmountForCycleSubscription(
|
|
|
1153
1226
|
return 0;
|
|
1154
1227
|
}
|
|
1155
1228
|
|
|
1229
|
+
export async function getEstimatedPaymentAmountForCycleSubscription(
|
|
1230
|
+
subscription: Subscription,
|
|
1231
|
+
paymentCurrency: PaymentCurrency
|
|
1232
|
+
): Promise<{ amount: number; estimatedByRate: boolean }> {
|
|
1233
|
+
const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
1234
|
+
if (subscriptionItems.length === 0) {
|
|
1235
|
+
logger.info('subscription items not found in getEstimatedPaymentAmountForCycleSubscription', {
|
|
1236
|
+
subscription: subscription.id,
|
|
1237
|
+
});
|
|
1238
|
+
return { amount: 0, estimatedByRate: false };
|
|
1239
|
+
}
|
|
1240
|
+
let expandedItems = await Price.expand(
|
|
1241
|
+
subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
|
|
1242
|
+
{ product: true }
|
|
1243
|
+
);
|
|
1244
|
+
if (expandedItems.length === 0) {
|
|
1245
|
+
logger.info('expanded items not found in getEstimatedPaymentAmountForCycleSubscription', {
|
|
1246
|
+
subscription: subscription.id,
|
|
1247
|
+
});
|
|
1248
|
+
return { amount: 0, estimatedByRate: false };
|
|
1249
|
+
}
|
|
1250
|
+
const previousPeriodEnd =
|
|
1251
|
+
subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
|
|
1252
|
+
const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
|
|
1253
|
+
expandedItems = await Promise.all(
|
|
1254
|
+
expandedItems.map(async (x: any) => {
|
|
1255
|
+
if (x.price.recurring?.usage_type === 'metered') {
|
|
1256
|
+
const rawQuantity = await UsageRecord.getSummary({
|
|
1257
|
+
id: x.id,
|
|
1258
|
+
start: setup.period.start - setup.cycle / 1000,
|
|
1259
|
+
end: setup.period.end - setup.cycle / 1000,
|
|
1260
|
+
method: x.price.recurring?.aggregate_usage,
|
|
1261
|
+
dryRun: true,
|
|
1262
|
+
});
|
|
1263
|
+
x.quantity = x.price.transformQuantity(rawQuantity);
|
|
1264
|
+
x.metadata = x.metadata || {};
|
|
1265
|
+
x.metadata.quantity = rawQuantity;
|
|
1266
|
+
}
|
|
1267
|
+
return x;
|
|
1268
|
+
})
|
|
1269
|
+
);
|
|
1270
|
+
if (expandedItems.length === 0) {
|
|
1271
|
+
return { amount: 0, estimatedByRate: false };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
let estimatedByRate = false;
|
|
1275
|
+
const hasDynamicItems = expandedItems.some((item: any) => item.price?.pricing_type === 'dynamic');
|
|
1276
|
+
if (hasDynamicItems) {
|
|
1277
|
+
try {
|
|
1278
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
1279
|
+
if (!paymentMethod) {
|
|
1280
|
+
throw new Error(`PaymentMethod not found in ${subscription.id}`);
|
|
1281
|
+
}
|
|
1282
|
+
const exchangeRateService = getExchangeRateService();
|
|
1283
|
+
const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, paymentMethod.type as any);
|
|
1284
|
+
const rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
1285
|
+
const USD_DECIMALS = 8;
|
|
1286
|
+
const rateBN = fromTokenToUnit(trimDecimals(rateResult.rate, USD_DECIMALS), USD_DECIMALS);
|
|
1287
|
+
|
|
1288
|
+
expandedItems = expandedItems.map((item: any) => {
|
|
1289
|
+
const { price } = item;
|
|
1290
|
+
if (price?.pricing_type !== 'dynamic') {
|
|
1291
|
+
return item;
|
|
1292
|
+
}
|
|
1293
|
+
if (!price.base_amount) {
|
|
1294
|
+
logger.warn('Dynamic price missing base_amount for estimate', {
|
|
1295
|
+
subscriptionId: subscription.id,
|
|
1296
|
+
priceId: price.id,
|
|
1297
|
+
});
|
|
1298
|
+
return item;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
|
|
1302
|
+
const quantityBN = new BN(item.quantity || 0);
|
|
1303
|
+
const totalBaseAmountBN = baseAmountBN.mul(quantityBN);
|
|
1304
|
+
const numerator = totalBaseAmountBN.mul(new BN(10).pow(new BN(paymentCurrency.decimal)));
|
|
1305
|
+
const quotedAmountRaw = numerator.add(rateBN).sub(new BN(1)).div(rateBN);
|
|
1306
|
+
const quotedAmount = limitTokenPrecision(quotedAmountRaw, paymentCurrency.decimal, 10);
|
|
1307
|
+
estimatedByRate = true;
|
|
1308
|
+
return {
|
|
1309
|
+
...item,
|
|
1310
|
+
custom_amount: quotedAmount.toString(),
|
|
1311
|
+
quoted_amount: quotedAmount.toString(),
|
|
1312
|
+
};
|
|
1313
|
+
});
|
|
1314
|
+
} catch (error: any) {
|
|
1315
|
+
logger.warn('Failed to estimate dynamic pricing amount for subscription', {
|
|
1316
|
+
subscriptionId: subscription.id,
|
|
1317
|
+
error: error?.message || error,
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const amount = getSubscriptionCycleAmount(expandedItems, paymentCurrency.id);
|
|
1323
|
+
return { amount: +fromUnitToToken(amount?.total || '0', paymentCurrency.decimal), estimatedByRate };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1156
1326
|
// check if subscription overdraft protection is enabled
|
|
1157
1327
|
export async function isSubscriptionOverdraftProtectionEnabled(subscription: Subscription, paymentCurrencyId?: string) {
|
|
1158
1328
|
try {
|
package/api/src/libs/util.ts
CHANGED
|
@@ -521,6 +521,26 @@ export function getCustomerIndexUrl({ locale, userDid }: { locale: string; userD
|
|
|
521
521
|
return getUrl(withQuery('customer', { locale, ...getConnectQueryParam({ userDid }) }));
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
export function getCustomerAutoRechargeSettingsUrl({
|
|
525
|
+
locale,
|
|
526
|
+
userDid,
|
|
527
|
+
currencyId,
|
|
528
|
+
}: {
|
|
529
|
+
locale: string;
|
|
530
|
+
userDid: string;
|
|
531
|
+
currencyId?: string;
|
|
532
|
+
}) {
|
|
533
|
+
const query: Record<string, string> = {
|
|
534
|
+
locale,
|
|
535
|
+
action: 'auto-recharge',
|
|
536
|
+
...getConnectQueryParam({ userDid }),
|
|
537
|
+
};
|
|
538
|
+
if (currencyId) {
|
|
539
|
+
query.currencyId = currencyId;
|
|
540
|
+
}
|
|
541
|
+
return getUrl(withQuery('customer', query));
|
|
542
|
+
}
|
|
543
|
+
|
|
524
544
|
// Check if user is in blocklist
|
|
525
545
|
export async function isUserInBlocklist(did: string, paymentMethod: PaymentMethod): Promise<boolean> {
|
|
526
546
|
try {
|
|
@@ -584,20 +604,61 @@ export function formatCurrencyInfo(
|
|
|
584
604
|
paymentMethod?: PaymentMethod | null,
|
|
585
605
|
isToken?: boolean
|
|
586
606
|
) {
|
|
587
|
-
let amountStr = '';
|
|
588
607
|
const defaultPaymentCurrency = {
|
|
589
608
|
symbol: '',
|
|
590
609
|
decimal: 18,
|
|
591
610
|
};
|
|
592
611
|
|
|
612
|
+
const decimal = paymentCurrency.decimal ?? defaultPaymentCurrency.decimal;
|
|
613
|
+
const symbol = paymentCurrency.symbol ?? defaultPaymentCurrency.symbol;
|
|
614
|
+
|
|
615
|
+
let formattedAmount: string;
|
|
593
616
|
if (isToken) {
|
|
594
|
-
|
|
617
|
+
// Already in token format, apply precision formatting
|
|
618
|
+
const numericValue = Number(amount || '0');
|
|
619
|
+
if (!Number.isFinite(numericValue)) {
|
|
620
|
+
formattedAmount = String(amount || '0');
|
|
621
|
+
} else {
|
|
622
|
+
const abs = Math.abs(numericValue);
|
|
623
|
+
// If amount > 0.01, show 2 decimal places; otherwise show full precision
|
|
624
|
+
const targetPrecision = abs > 0 && abs < 0.01 ? decimal : 2;
|
|
625
|
+
formattedAmount = formatNumber(numericValue, targetPrecision, true, false) || '0';
|
|
626
|
+
}
|
|
595
627
|
} else {
|
|
596
|
-
|
|
628
|
+
// Convert from unit to token format, then apply precision formatting
|
|
629
|
+
const tokenAmount = fromUnitToToken(amount || '0', decimal);
|
|
630
|
+
const numericValue = Number(tokenAmount);
|
|
631
|
+
if (!Number.isFinite(numericValue)) {
|
|
632
|
+
formattedAmount = tokenAmount;
|
|
633
|
+
} else {
|
|
634
|
+
const abs = Math.abs(numericValue);
|
|
635
|
+
// If amount > 0.01, show 2 decimal places; otherwise show full precision
|
|
636
|
+
const targetPrecision = abs > 0 && abs < 0.01 ? decimal : 2;
|
|
637
|
+
formattedAmount = formatNumber(numericValue, targetPrecision, true, false) || '0';
|
|
638
|
+
}
|
|
597
639
|
}
|
|
640
|
+
|
|
641
|
+
const amountStr = `${formattedAmount} ${symbol}`;
|
|
598
642
|
return paymentMethod && paymentMethod.type !== 'arcblock' ? `${amountStr} (${paymentMethod.name})` : amountStr;
|
|
599
643
|
}
|
|
600
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Format amount from unit to token with consistent precision
|
|
647
|
+
* - If amount > 0.01, show 2 decimal places
|
|
648
|
+
* - If 0 < amount <= 0.01, show full precision
|
|
649
|
+
* - Returns formatted string without symbol
|
|
650
|
+
*/
|
|
651
|
+
export function formatTokenAmount(amount: string | number, decimal: number): string {
|
|
652
|
+
const tokenAmount = fromUnitToToken(amount || '0', decimal);
|
|
653
|
+
const numericValue = Number(tokenAmount);
|
|
654
|
+
if (!Number.isFinite(numericValue)) {
|
|
655
|
+
return tokenAmount;
|
|
656
|
+
}
|
|
657
|
+
const abs = Math.abs(numericValue);
|
|
658
|
+
const targetPrecision = abs > 0 && abs < 0.01 ? decimal : 2;
|
|
659
|
+
return formatNumber(numericValue, targetPrecision, true, false) || '0';
|
|
660
|
+
}
|
|
661
|
+
|
|
601
662
|
export function getExplorerTxUrl({
|
|
602
663
|
explorerHost,
|
|
603
664
|
txHash,
|
package/api/src/locales/en.ts
CHANGED
|
@@ -159,6 +159,8 @@ export default flat({
|
|
|
159
159
|
'The estimated payment amount is {price}, but your current balance is insufficient ({balance}). Please ensure your account has enough balance to avoid payment failure.',
|
|
160
160
|
renewAmount: 'Payment amount',
|
|
161
161
|
estimatedAmountNote: 'Estimate {amount}, billed based on final usage',
|
|
162
|
+
estimatedAmountNoteRate: 'Estimate {amount}, based on the exchange rate at billing time',
|
|
163
|
+
estimatedAmountNoteRateAndUsage: 'Estimate {amount}, based on final usage and the exchange rate at billing time',
|
|
162
164
|
},
|
|
163
165
|
|
|
164
166
|
subscriptionRenewed: {
|
|
@@ -204,7 +206,21 @@ export default flat({
|
|
|
204
206
|
noEnoughToken: 'Your account token balance is {balance}, which is insufficient for {price}. Please add funds',
|
|
205
207
|
noSupported: 'Automatic payment with tokens is not supported. Please check your package',
|
|
206
208
|
txSendFailed: 'Failed to send automatic payment transaction',
|
|
209
|
+
// Skipped reasons (dynamic pricing)
|
|
210
|
+
slippageExceeded:
|
|
211
|
+
'Current {paymentCurrency} rate is {currentRate}, below your limit of {minAcceptableRate}. System will auto-retry when rate recovers.',
|
|
212
|
+
exchangeRateNotSupported:
|
|
213
|
+
'{paymentCurrency} rate is not available for your payment method. Please check your payment method configuration.',
|
|
214
|
+
exchangeRateFetchFailed:
|
|
215
|
+
'Unable to retrieve {paymentCurrency} rate at this time. System will auto-retry shortly.',
|
|
207
216
|
},
|
|
217
|
+
// For skipped scenarios (no invoice)
|
|
218
|
+
titleSkipped: 'Auto Top-Up Skipped',
|
|
219
|
+
titleSkippedSlippageExceeded: 'Auto Top-Up Skipped: Rate Below Your Limit',
|
|
220
|
+
titleSkippedExchangeRateNotSupported: 'Auto Top-Up Skipped: Currency Not Supported',
|
|
221
|
+
titleSkippedExchangeRateFetchFailed: 'Auto Top-Up Skipped: Rate Unavailable',
|
|
222
|
+
bodySkipped: 'Your {creditCurrencyName} auto top-up scheduled for {at} was skipped. Details:',
|
|
223
|
+
adjustSettings: 'Adjust Settings',
|
|
208
224
|
},
|
|
209
225
|
|
|
210
226
|
autoRechargeDailyLimitExceeded: {
|
|
@@ -332,5 +348,39 @@ export default flat({
|
|
|
332
348
|
status: 'Status',
|
|
333
349
|
lessThanOnePercent: 'less than 1%',
|
|
334
350
|
},
|
|
351
|
+
|
|
352
|
+
subscriptionSlippageWarning: {
|
|
353
|
+
title: 'Rate Alert: {productName} Renewal',
|
|
354
|
+
body: 'Your {productName} subscription will renew in {timeUntilRenewal}. The current exchange rate ({currentRate}) is below your minimum acceptable rate ({minAcceptableRate}). If the rate does not recover before renewal, automatic payment may fail.',
|
|
355
|
+
currentRate: 'Current Rate',
|
|
356
|
+
minAcceptableRate: 'Minimum Acceptable Rate',
|
|
357
|
+
renewalTime: 'Renewal Time',
|
|
358
|
+
adjustSlippage: 'Adjust Slippage Settings',
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
subscriptionSlippageExceeded: {
|
|
362
|
+
title: 'Payment Paused: {productName}',
|
|
363
|
+
body: 'Your {productName} subscription renewal has been paused because the current exchange rate ({currentRate}) is below your minimum acceptable rate ({minAcceptableRate}). The system will not auto-retry. Please pay manually to restore your subscription.',
|
|
364
|
+
currentRate: 'Current Rate',
|
|
365
|
+
minAcceptableRate: 'Minimum Acceptable Rate',
|
|
366
|
+
payNow: 'Pay Now',
|
|
367
|
+
adjustSlippage: 'Adjust Slippage Settings',
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
exchangeRateAlert: {
|
|
371
|
+
spread_exceeded: {
|
|
372
|
+
title: 'Exchange Rate Alert: High Spread Detected ({symbol})',
|
|
373
|
+
body: 'The exchange rate spread for {symbol} has reached {spreadPercent}%, exceeding the threshold of {threshold}%. This may indicate rate source instability.',
|
|
374
|
+
},
|
|
375
|
+
providers_unavailable: {
|
|
376
|
+
title: 'Exchange Rate Alert: All Providers Unavailable ({symbol})',
|
|
377
|
+
body: 'All exchange rate providers for {symbol} are currently unavailable. Exchange rate queries will fail until providers recover.',
|
|
378
|
+
},
|
|
379
|
+
timestamp: 'Time',
|
|
380
|
+
symbol: 'Symbol',
|
|
381
|
+
spread: 'Spread',
|
|
382
|
+
providers: 'Provider Rates',
|
|
383
|
+
viewProviders: 'View Provider Settings',
|
|
384
|
+
},
|
|
335
385
|
},
|
|
336
386
|
});
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -157,6 +157,8 @@ export default flat({
|
|
|
157
157
|
'预计扣款金额为 {price},但当前余额不足(余额为 {balance}),请确保您的账户余额充足,避免扣费失败。',
|
|
158
158
|
renewAmount: '扣费金额',
|
|
159
159
|
estimatedAmountNote: '预估 {amount},按最终用量计费',
|
|
160
|
+
estimatedAmountNoteRate: '预估 {amount},按扣费时汇率计算',
|
|
161
|
+
estimatedAmountNoteRateAndUsage: '预估 {amount},按最终用量与扣费时汇率计算',
|
|
160
162
|
},
|
|
161
163
|
|
|
162
164
|
subscriptionRenewed: {
|
|
@@ -195,7 +197,19 @@ export default flat({
|
|
|
195
197
|
noEnoughToken: '您的账户代币余额为 {balance},不足 {price},请充值代币。',
|
|
196
198
|
noSupported: '不支持使用代币扣费,请检查您的套餐。',
|
|
197
199
|
txSendFailed: '扣费交易发送失败。',
|
|
200
|
+
// 跳过原因(动态定价)
|
|
201
|
+
slippageExceeded:
|
|
202
|
+
'当前 {paymentCurrency} 汇率为 {currentRate},低于您设置的下限 {minAcceptableRate}。汇率恢复后系统将自动重试。',
|
|
203
|
+
exchangeRateNotSupported: '您的支付方式暂不支持 {paymentCurrency} 汇率查询,请检查支付方式配置。',
|
|
204
|
+
exchangeRateFetchFailed: '暂时无法获取 {paymentCurrency} 汇率,系统将稍后自动重试。',
|
|
198
205
|
},
|
|
206
|
+
// 跳过场景(无 invoice)
|
|
207
|
+
titleSkipped: '自动充值跳过',
|
|
208
|
+
titleSkippedSlippageExceeded: '自动充值跳过:汇率低于您设置的下限',
|
|
209
|
+
titleSkippedExchangeRateNotSupported: '自动充值跳过:货币暂不支持',
|
|
210
|
+
titleSkippedExchangeRateFetchFailed: '自动充值跳过:暂无法获取汇率',
|
|
211
|
+
bodySkipped: '您的 {creditCurrencyName} 自动充值(原定于 {at})已跳过。详情:',
|
|
212
|
+
adjustSettings: '调整设置',
|
|
199
213
|
},
|
|
200
214
|
|
|
201
215
|
autoRechargeDailyLimitExceeded: {
|
|
@@ -320,5 +334,39 @@ export default flat({
|
|
|
320
334
|
status: '状态',
|
|
321
335
|
lessThanOnePercent: '低于 1%',
|
|
322
336
|
},
|
|
337
|
+
|
|
338
|
+
subscriptionSlippageWarning: {
|
|
339
|
+
title: '汇率提醒:{productName} 续费',
|
|
340
|
+
body: '您的 {productName} 订阅将在 {timeUntilRenewal} 后续费。当前汇率({currentRate})低于您设置的最低可接受汇率({minAcceptableRate})。如果续费前汇率未恢复,自动付款可能会失败。',
|
|
341
|
+
currentRate: '当前汇率',
|
|
342
|
+
minAcceptableRate: '最低可接受汇率',
|
|
343
|
+
renewalTime: '续费时间',
|
|
344
|
+
adjustSlippage: '调整滑点设置',
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
subscriptionSlippageExceeded: {
|
|
348
|
+
title: '支付已暂停:{productName}',
|
|
349
|
+
body: '您的 {productName} 订阅续费已暂停,因为当前汇率({currentRate})低于您设置的最低可接受汇率({minAcceptableRate})。系统不会自动重试,请手动付款以恢复订阅。',
|
|
350
|
+
currentRate: '当前汇率',
|
|
351
|
+
minAcceptableRate: '最低可接受汇率',
|
|
352
|
+
payNow: '立即付款',
|
|
353
|
+
adjustSlippage: '调整滑点设置',
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
exchangeRateAlert: {
|
|
357
|
+
spread_exceeded: {
|
|
358
|
+
title: '汇率警报:检测到高价差 ({symbol})',
|
|
359
|
+
body: '{symbol} 的汇率价差已达到 {spreadPercent}%,超过阈值 {threshold}%。这可能表明汇率数据源不稳定。',
|
|
360
|
+
},
|
|
361
|
+
providers_unavailable: {
|
|
362
|
+
title: '汇率警报:所有提供商不可用 ({symbol})',
|
|
363
|
+
body: '{symbol} 的所有汇率提供商当前不可用。在提供商恢复之前,汇率查询将失败。',
|
|
364
|
+
},
|
|
365
|
+
timestamp: '时间',
|
|
366
|
+
symbol: '货币对',
|
|
367
|
+
spread: '价差',
|
|
368
|
+
providers: '提供商汇率',
|
|
369
|
+
viewProviders: '查看提供商设置',
|
|
370
|
+
},
|
|
323
371
|
},
|
|
324
372
|
});
|