payment-kit 1.24.4 → 1.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +3 -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 +167 -1
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/refund.ts +41 -9
- 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/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
package/src/locales/zh.tsx
CHANGED
|
@@ -7,6 +7,9 @@ export default flat({
|
|
|
7
7
|
estimated: '预估',
|
|
8
8
|
total: '总量',
|
|
9
9
|
active: '生效中',
|
|
10
|
+
inactive: '未生效',
|
|
11
|
+
enabled: '已启用',
|
|
12
|
+
disabled: '已禁用',
|
|
10
13
|
every: '每',
|
|
11
14
|
metadata: {
|
|
12
15
|
label: '元数据',
|
|
@@ -38,6 +41,9 @@ export default flat({
|
|
|
38
41
|
estimatedDuration: '预计可用 {duration}',
|
|
39
42
|
detail: '详情',
|
|
40
43
|
setting: '配置',
|
|
44
|
+
slippage: '滑点下限',
|
|
45
|
+
slippageMinRate: '最低可接受汇率 {rate} {currency}',
|
|
46
|
+
slippageTooltip: '自动扣款时可接受的最低汇率,当汇率低于此值时将暂停扣款',
|
|
41
47
|
welcome: '欢迎使用 Payment Kit',
|
|
42
48
|
welcomeDesc: '从基础功能开始,轻松接入支付功能。选择下方任意功能,立即开始使用。',
|
|
43
49
|
quickStart: '快速入门指南',
|
|
@@ -53,10 +59,14 @@ export default flat({
|
|
|
53
59
|
copyTip: '请手动复制',
|
|
54
60
|
save: '保存',
|
|
55
61
|
saving: '保存中...',
|
|
62
|
+
saved: '保存成功',
|
|
63
|
+
refresh: '刷新',
|
|
56
64
|
cancel: '取消',
|
|
57
65
|
back: '返回',
|
|
58
66
|
know: '知道了',
|
|
59
67
|
confirm: '确认',
|
|
68
|
+
increased: '上涨',
|
|
69
|
+
decreased: '下跌',
|
|
60
70
|
edit: '编辑',
|
|
61
71
|
view: '查看',
|
|
62
72
|
select: '选择',
|
|
@@ -74,7 +84,6 @@ export default flat({
|
|
|
74
84
|
events: '事件',
|
|
75
85
|
details: '详情',
|
|
76
86
|
discount: '折扣',
|
|
77
|
-
inactive: '无效',
|
|
78
87
|
deleted: '已删除',
|
|
79
88
|
days: {
|
|
80
89
|
sunday: '星期日',
|
|
@@ -772,6 +781,33 @@ export default flat({
|
|
|
772
781
|
description: '输入限制单次购买的最大数量, 0表示无限制',
|
|
773
782
|
},
|
|
774
783
|
inventory: '库存设置',
|
|
784
|
+
dynamicPricing: {
|
|
785
|
+
label: '启用动态定价',
|
|
786
|
+
description: '价格根据实时汇率波动',
|
|
787
|
+
config: {
|
|
788
|
+
title: '动态定价配置',
|
|
789
|
+
baseAmount: {
|
|
790
|
+
label: '基准价格',
|
|
791
|
+
description: '以法定货币计算的基准价格',
|
|
792
|
+
required: '基准价格是必需的',
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
validation: {
|
|
796
|
+
checking: '正在检查汇率可用性...',
|
|
797
|
+
// eslint-disable-next-line no-template-curly-in-string
|
|
798
|
+
rateLine: '当前汇率:1 {currency} ≈ ${rate}',
|
|
799
|
+
// eslint-disable-next-line no-template-curly-in-string
|
|
800
|
+
usdLine: '参考 USD:≈ ${amount}',
|
|
801
|
+
error: '获取汇率失败,货币不支持或者数据源异常',
|
|
802
|
+
// eslint-disable-next-line no-template-curly-in-string
|
|
803
|
+
estimatedLine: '预估数量:≈ {amount} {currency}',
|
|
804
|
+
useAmount: '填入',
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
referencePricing: {
|
|
808
|
+
label: '参考汇率定价',
|
|
809
|
+
description: '仅用于参考并快速填充币种数量,不会开启动态定价',
|
|
810
|
+
},
|
|
775
811
|
},
|
|
776
812
|
coupon: {
|
|
777
813
|
create: '创建优惠券',
|
|
@@ -1292,6 +1328,8 @@ export default flat({
|
|
|
1292
1328
|
finalizedAt: '已完成时间',
|
|
1293
1329
|
paidAt: '支付日期',
|
|
1294
1330
|
summary: '摘要',
|
|
1331
|
+
billingContextNote: '本账单金额基于结算时的实时价格计算。',
|
|
1332
|
+
dynamicPricingNote: '本账单使用动态定价并锁定了汇率。',
|
|
1295
1333
|
billTo: '开具给',
|
|
1296
1334
|
billing: '计费方式',
|
|
1297
1335
|
download: '下载PDF',
|
|
@@ -1766,6 +1804,69 @@ export default flat({
|
|
|
1766
1804
|
totalCreditUsed: '总使用额度',
|
|
1767
1805
|
transactionDate: '交易时间',
|
|
1768
1806
|
},
|
|
1807
|
+
exchangeRateProvider: {
|
|
1808
|
+
title: '汇率数据源',
|
|
1809
|
+
subtitle: '管理动态定价使用的汇率数据源',
|
|
1810
|
+
medianStrategyNote: '系统会自动从多个数据源计算汇率。只有当数据源长期异常或需要临时禁用时才需要在这里处理。',
|
|
1811
|
+
disableConfirmTitle: '确认停用该数据源?',
|
|
1812
|
+
disableConfirmMessage: '停用后可能影响动态计价支付流程,确定要停用吗?',
|
|
1813
|
+
table: {
|
|
1814
|
+
name: '名称',
|
|
1815
|
+
participation: '参与计算',
|
|
1816
|
+
health: '健康度',
|
|
1817
|
+
recentActivity: '最近活动',
|
|
1818
|
+
trustLevel: '可信度',
|
|
1819
|
+
trustLevelTip: '表示可信度与参与资格,不表示优先使用顺序',
|
|
1820
|
+
enabled: '启用',
|
|
1821
|
+
lastUpdate: '最近更新:{time}',
|
|
1822
|
+
failures24h: '24h 失败次数:{count}',
|
|
1823
|
+
},
|
|
1824
|
+
participation: {
|
|
1825
|
+
included: '已参与',
|
|
1826
|
+
excluded: '已排除',
|
|
1827
|
+
},
|
|
1828
|
+
health: {
|
|
1829
|
+
active: '健康',
|
|
1830
|
+
degraded: '不稳定',
|
|
1831
|
+
paused: '异常',
|
|
1832
|
+
inactive: '异常',
|
|
1833
|
+
},
|
|
1834
|
+
status: {
|
|
1835
|
+
active: '正常',
|
|
1836
|
+
degraded: '降级',
|
|
1837
|
+
paused: '已暂停',
|
|
1838
|
+
inactive: '停用',
|
|
1839
|
+
},
|
|
1840
|
+
create: {
|
|
1841
|
+
title: '新增数据源',
|
|
1842
|
+
primaryAction: '新增汇率数据源',
|
|
1843
|
+
},
|
|
1844
|
+
edit: {
|
|
1845
|
+
title: '编辑 {name}',
|
|
1846
|
+
name: '名称',
|
|
1847
|
+
nameHelp: '数据源的唯一标识',
|
|
1848
|
+
nameRequired: '名称必填',
|
|
1849
|
+
type: '数据源类型',
|
|
1850
|
+
typeHelp: '数据源类型。创建后不可修改。',
|
|
1851
|
+
baseUrl: '接口地址(可选)',
|
|
1852
|
+
baseUrlHelp: '自定义接口地址,用于代理或自建实例。默认:{defaultUrl}',
|
|
1853
|
+
apiKey: 'API Key',
|
|
1854
|
+
apiKeyHelp: '访问数据源的 API 密钥。',
|
|
1855
|
+
apiKeyLinkText: 'API Key 管理地址:',
|
|
1856
|
+
apiKeyLinkAction: 'CoinMarketCap 账户',
|
|
1857
|
+
testConnection: '测试连接',
|
|
1858
|
+
testing: '测试中...',
|
|
1859
|
+
testSuccess: '连接成功({symbol}/USD:{rate},{time}ms)',
|
|
1860
|
+
testFailed: '连接失败:{error}',
|
|
1861
|
+
enabled: '启用',
|
|
1862
|
+
priority: '优先级',
|
|
1863
|
+
priorityHelp: '数字越小优先级越高。系统按优先级顺序使用数据源。',
|
|
1864
|
+
status: '状态',
|
|
1865
|
+
statusHelp: '设置为"已暂停"可临时禁用此数据源。',
|
|
1866
|
+
pausedReason: '暂停原因',
|
|
1867
|
+
pausedReasonHelp: '说明为何暂停此数据源(可选)。',
|
|
1868
|
+
},
|
|
1869
|
+
},
|
|
1769
1870
|
},
|
|
1770
1871
|
empty: {
|
|
1771
1872
|
image: '无图片',
|
|
@@ -1868,6 +1969,16 @@ export default flat({
|
|
|
1868
1969
|
donation: '打赏记录',
|
|
1869
1970
|
creditsInfo: '总共包含 {amount}',
|
|
1870
1971
|
appliedDiscounts: '已应用优惠',
|
|
1972
|
+
priceChanged: '汇率已变动 {percent}%。支付金额将会更新,是否继续?',
|
|
1973
|
+
paymentCancelled: '支付已取消',
|
|
1974
|
+
paymentMethodChanged: '支付方式已更换',
|
|
1975
|
+
priceChangeTitle: '价格已变动',
|
|
1976
|
+
priceChangeDescription: '汇率{direction}了 {percent}%,支付金额将会更新。',
|
|
1977
|
+
currentPaymentMethod: '当前支付方式',
|
|
1978
|
+
otherPaymentMethods: '或使用其他方式支付',
|
|
1979
|
+
confirmAndPay: '确认并支付',
|
|
1980
|
+
switchAndPay: '切换并支付',
|
|
1981
|
+
current: '当前',
|
|
1871
1982
|
},
|
|
1872
1983
|
payout: {
|
|
1873
1984
|
empty: '没有收款记录',
|
|
@@ -1884,6 +1995,37 @@ export default flat({
|
|
|
1884
1995
|
alert: '您有欠费账单需要处理,请及时付款以避免服务中断',
|
|
1885
1996
|
title: '结清欠费账单',
|
|
1886
1997
|
},
|
|
1998
|
+
|
|
1999
|
+
quote: {
|
|
2000
|
+
title: '价格锁定记录',
|
|
2001
|
+
noQuotes: '此账单没有价格锁定记录',
|
|
2002
|
+
id: '报价 ID',
|
|
2003
|
+
pricingDetails: '定价详情',
|
|
2004
|
+
baseAmount: '基础金额',
|
|
2005
|
+
exchangeRate: '汇率',
|
|
2006
|
+
consensusMethod: '汇率算法',
|
|
2007
|
+
// eslint-disable-next-line no-template-curly-in-string
|
|
2008
|
+
referenceRate: '参考汇率:1 {symbol} ≈ ${rate}',
|
|
2009
|
+
quotedAmount: '锁定金额',
|
|
2010
|
+
providerInfo: '汇率来源',
|
|
2011
|
+
provider: '数据源',
|
|
2012
|
+
rateTimestamp: '汇率时间',
|
|
2013
|
+
slippage: '滑点',
|
|
2014
|
+
lifecycle: '生命周期',
|
|
2015
|
+
expiresAt: '过期时间',
|
|
2016
|
+
riskInfo: '风险信息',
|
|
2017
|
+
deviation: '价格偏差',
|
|
2018
|
+
anomalyDetected: '检测到异常',
|
|
2019
|
+
degraded: '降级',
|
|
2020
|
+
status: {
|
|
2021
|
+
active: '活跃',
|
|
2022
|
+
used: '已使用',
|
|
2023
|
+
paid: '已支付',
|
|
2024
|
+
expired: '已过期',
|
|
2025
|
+
cancelled: '已取消',
|
|
2026
|
+
failed: '失败',
|
|
2027
|
+
},
|
|
2028
|
+
},
|
|
1887
2029
|
},
|
|
1888
2030
|
integrations: {
|
|
1889
2031
|
description: '用于配置和管理 Payment Kit 与你应用之间的集成方式。',
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
TxGas,
|
|
8
8
|
TxLink,
|
|
9
9
|
api,
|
|
10
|
-
|
|
10
|
+
formatAmount,
|
|
11
11
|
formatError,
|
|
12
12
|
formatTime,
|
|
13
13
|
getInvoiceStatusColor,
|
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
TPaymentLink,
|
|
22
22
|
} from '@blocklet/payment-types';
|
|
23
23
|
import { ArrowBackOutlined } from '@mui/icons-material';
|
|
24
|
-
import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
|
|
24
|
+
import { Alert, Box, Button, CircularProgress, Divider, Stack, Tooltip, Typography } from '@mui/material';
|
|
25
25
|
import { styled } from '@mui/system';
|
|
26
26
|
import { useRequest, useSetState } from 'ahooks';
|
|
27
27
|
import { Link } from 'react-router-dom';
|
|
@@ -53,6 +53,7 @@ const fetchData = (
|
|
|
53
53
|
checkoutSession: TCheckoutSession;
|
|
54
54
|
paymentLink: TPaymentLink;
|
|
55
55
|
relatedCreditGrants?: TCreditGrantExpanded[];
|
|
56
|
+
quotes?: any[];
|
|
56
57
|
}
|
|
57
58
|
> => {
|
|
58
59
|
return api.get(`/api/invoices/${id}?sync=true`).then((res: any) => res.data);
|
|
@@ -150,6 +151,16 @@ export default function InvoiceDetail(props: { id: string }) {
|
|
|
150
151
|
<InvoiceActions data={data} onChange={runAsync} variant="normal" />
|
|
151
152
|
</Box>
|
|
152
153
|
</Stack>
|
|
154
|
+
{data.status === 'uncollectible' && data.metadata?.slippage?.below_threshold && (
|
|
155
|
+
<Alert severity="warning" sx={{ mt: 2 }}>
|
|
156
|
+
<Typography variant="body2">
|
|
157
|
+
{t('payment.customer.invoice.slippageExceededDetail', {
|
|
158
|
+
currentRate: data.metadata.slippage.rate_at_invoice || '—',
|
|
159
|
+
minRate: data.metadata.slippage.min_acceptable_rate || '—',
|
|
160
|
+
})}
|
|
161
|
+
</Typography>
|
|
162
|
+
</Alert>
|
|
163
|
+
)}
|
|
153
164
|
<Box
|
|
154
165
|
sx={{
|
|
155
166
|
mt: 4,
|
|
@@ -193,7 +204,7 @@ export default function InvoiceDetail(props: { id: string }) {
|
|
|
193
204
|
for
|
|
194
205
|
</Typography>
|
|
195
206
|
<Typography variant="h2" sx={{ fontWeight: 600 }}>
|
|
196
|
-
{
|
|
207
|
+
{formatAmount(data.total, data.paymentCurrency.decimal)} {data.paymentCurrency.symbol}
|
|
197
208
|
</Typography>
|
|
198
209
|
</Stack>
|
|
199
210
|
<Copyable text={props.id} style={{ marginLeft: 4 }} />
|
|
@@ -224,7 +235,24 @@ export default function InvoiceDetail(props: { id: string }) {
|
|
|
224
235
|
}}>
|
|
225
236
|
<InfoMetric
|
|
226
237
|
label={t('common.status')}
|
|
227
|
-
value={
|
|
238
|
+
value={
|
|
239
|
+
<Stack direction="row" spacing={1} alignItems="center">
|
|
240
|
+
<Status label={data.status} color={getInvoiceStatusColor(data.status)} />
|
|
241
|
+
{data.status === 'uncollectible' && data.metadata?.slippage?.below_threshold && (
|
|
242
|
+
<Tooltip
|
|
243
|
+
title={t('payment.customer.invoice.slippageExceededDetail', {
|
|
244
|
+
currentRate: data.metadata.slippage.rate_at_invoice || '—',
|
|
245
|
+
minRate: data.metadata.slippage.min_acceptable_rate || '—',
|
|
246
|
+
})}
|
|
247
|
+
arrow
|
|
248
|
+
placement="top">
|
|
249
|
+
<span>
|
|
250
|
+
<Status label={t('payment.customer.invoice.slippageExceeded')} color="warning" />
|
|
251
|
+
</span>
|
|
252
|
+
</Tooltip>
|
|
253
|
+
)}
|
|
254
|
+
</Stack>
|
|
255
|
+
}
|
|
228
256
|
divider
|
|
229
257
|
/>
|
|
230
258
|
{data.subscription && (
|
|
@@ -390,6 +418,15 @@ export default function InvoiceDetail(props: { id: string }) {
|
|
|
390
418
|
<Divider />
|
|
391
419
|
<Box className="section">
|
|
392
420
|
<SectionHeader title={t('admin.summary')} />
|
|
421
|
+
<Typography
|
|
422
|
+
variant="body2"
|
|
423
|
+
sx={{
|
|
424
|
+
color: 'text.secondary',
|
|
425
|
+
mb: 2,
|
|
426
|
+
fontSize: '0.875rem',
|
|
427
|
+
}}>
|
|
428
|
+
{t('admin.invoice.billingContextNote')}
|
|
429
|
+
</Typography>
|
|
393
430
|
<Box className="section-body">
|
|
394
431
|
<InvoiceTable invoice={data} emptyNodeText={t('empty.summary')} mode="admin" />
|
|
395
432
|
</Box>
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
|
+
import Dialog from '@arcblock/ux/lib/Dialog';
|
|
3
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
4
|
+
import { api, formatError } from '@blocklet/payment-react';
|
|
5
|
+
import {
|
|
6
|
+
Alert,
|
|
7
|
+
Button,
|
|
8
|
+
CircularProgress,
|
|
9
|
+
FormControl,
|
|
10
|
+
FormHelperText,
|
|
11
|
+
InputLabel,
|
|
12
|
+
Link,
|
|
13
|
+
MenuItem,
|
|
14
|
+
Select,
|
|
15
|
+
Stack,
|
|
16
|
+
TextField,
|
|
17
|
+
Typography,
|
|
18
|
+
} from '@mui/material';
|
|
19
|
+
import { useSetState } from 'ahooks';
|
|
20
|
+
import { useEffect } from 'react';
|
|
21
|
+
|
|
22
|
+
interface ExchangeRateProvider {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
type: 'token-data' | 'coingecko' | 'coinmarketcap';
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
priority: number;
|
|
28
|
+
status: 'active' | 'degraded' | 'paused' | 'inactive';
|
|
29
|
+
paused_reason: string | null;
|
|
30
|
+
config: Record<string, any> | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface EditProviderDialogProps {
|
|
34
|
+
provider: ExchangeRateProvider;
|
|
35
|
+
isCreate?: boolean;
|
|
36
|
+
open: boolean;
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
onSave: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const BASE_URL_PLACEHOLDERS = {
|
|
42
|
+
'token-data': 'https://token-data.arcblock.io',
|
|
43
|
+
coingecko: 'https://api.coingecko.com/api/v3',
|
|
44
|
+
coinmarketcap: 'https://pro-api.coinmarketcap.com/v1',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const API_KEY_PLACEHOLDERS = {
|
|
48
|
+
coinmarketcap: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
|
49
|
+
coingecko: 'CG-xxxxxxxxxxxxxxxxxxxxxxxx',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const TYPE_NAME_DEFAULTS: Record<ExchangeRateProvider['type'], string> = {
|
|
53
|
+
'token-data': 'Token Data',
|
|
54
|
+
coingecko: 'CoinGecko',
|
|
55
|
+
coinmarketcap: 'CoinMarketCap',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default function EditProviderDialog({
|
|
59
|
+
provider,
|
|
60
|
+
isCreate = false,
|
|
61
|
+
open,
|
|
62
|
+
onClose,
|
|
63
|
+
onSave,
|
|
64
|
+
}: EditProviderDialogProps) {
|
|
65
|
+
const { t } = useLocaleContext();
|
|
66
|
+
const [state, setState] = useSetState({
|
|
67
|
+
name: provider.name || '',
|
|
68
|
+
type: provider.type || 'token-data',
|
|
69
|
+
base_url: provider.config?.base_url || '',
|
|
70
|
+
api_key: provider.config?.api_key || '',
|
|
71
|
+
priority: provider.priority,
|
|
72
|
+
status: provider.status,
|
|
73
|
+
paused_reason: provider.paused_reason || '',
|
|
74
|
+
loading: false,
|
|
75
|
+
error: null as string | null,
|
|
76
|
+
testing: false,
|
|
77
|
+
testResult: null as {
|
|
78
|
+
success: boolean;
|
|
79
|
+
responseTime?: number;
|
|
80
|
+
error?: string;
|
|
81
|
+
rate?: string;
|
|
82
|
+
timestamp?: number;
|
|
83
|
+
symbol?: string;
|
|
84
|
+
} | null,
|
|
85
|
+
apiKeyTouched: false,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
setState({
|
|
90
|
+
name: provider.name || '',
|
|
91
|
+
type: provider.type || 'token-data',
|
|
92
|
+
base_url: provider.config?.base_url || '',
|
|
93
|
+
api_key: provider.config?.api_key || '',
|
|
94
|
+
priority: provider.priority,
|
|
95
|
+
status: provider.status,
|
|
96
|
+
paused_reason: provider.paused_reason || '',
|
|
97
|
+
error: null,
|
|
98
|
+
apiKeyTouched: false,
|
|
99
|
+
});
|
|
100
|
+
}, [provider, open]);
|
|
101
|
+
|
|
102
|
+
const handleSave = async () => {
|
|
103
|
+
if (isCreate && !state.name.trim()) {
|
|
104
|
+
setState({ error: t('admin.exchangeRateProvider.edit.nameRequired') });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
setState({ loading: true, error: null });
|
|
110
|
+
|
|
111
|
+
const updates: any = {
|
|
112
|
+
name: state.name.trim(),
|
|
113
|
+
type: state.type,
|
|
114
|
+
priority: state.priority,
|
|
115
|
+
status: state.status,
|
|
116
|
+
config: {
|
|
117
|
+
...(state.base_url && { base_url: state.base_url }),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
if (isCreate || state.apiKeyTouched) {
|
|
121
|
+
updates.config.api_key = state.api_key;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (state.status === 'paused' && state.paused_reason) {
|
|
125
|
+
updates.paused_reason = state.paused_reason;
|
|
126
|
+
} else if (state.status !== 'paused') {
|
|
127
|
+
updates.paused_reason = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (isCreate) {
|
|
131
|
+
await api.post('/api/exchange-rate-providers', updates);
|
|
132
|
+
} else {
|
|
133
|
+
await api.put(`/api/exchange-rate-providers/${provider.id}`, updates);
|
|
134
|
+
}
|
|
135
|
+
onSave();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
setState({ error: formatError(err) });
|
|
138
|
+
Toast.error(formatError(err));
|
|
139
|
+
} finally {
|
|
140
|
+
setState({ loading: false });
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const handleCancel = () => {
|
|
145
|
+
setState({
|
|
146
|
+
name: provider.name || '',
|
|
147
|
+
type: provider.type || 'token-data',
|
|
148
|
+
base_url: provider.config?.base_url || '',
|
|
149
|
+
api_key: provider.config?.api_key || '',
|
|
150
|
+
priority: provider.priority,
|
|
151
|
+
status: provider.status,
|
|
152
|
+
paused_reason: provider.paused_reason || '',
|
|
153
|
+
error: null,
|
|
154
|
+
testing: false,
|
|
155
|
+
testResult: null,
|
|
156
|
+
apiKeyTouched: false,
|
|
157
|
+
});
|
|
158
|
+
onClose();
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleTestConnection = async () => {
|
|
162
|
+
setState({ testing: true, testResult: null });
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const response = await api.post('/api/exchange-rate-providers/test-connection', {
|
|
166
|
+
type: state.type,
|
|
167
|
+
provider_id: isCreate ? undefined : provider.id,
|
|
168
|
+
config: {
|
|
169
|
+
...(state.base_url && { base_url: state.base_url }),
|
|
170
|
+
...(state.apiKeyTouched && { api_key: state.api_key }),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
setState({ testResult: response.data });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
setState({
|
|
177
|
+
testResult: {
|
|
178
|
+
success: false,
|
|
179
|
+
error: formatError(err),
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
} finally {
|
|
183
|
+
setState({ testing: false });
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<Dialog
|
|
189
|
+
open={open}
|
|
190
|
+
onClose={handleCancel}
|
|
191
|
+
maxWidth="sm"
|
|
192
|
+
fullWidth
|
|
193
|
+
title={
|
|
194
|
+
isCreate
|
|
195
|
+
? t('admin.exchangeRateProvider.create.title')
|
|
196
|
+
: t('admin.exchangeRateProvider.edit.title', { name: provider.name })
|
|
197
|
+
}
|
|
198
|
+
PaperProps={{
|
|
199
|
+
sx: {
|
|
200
|
+
borderRadius: 2,
|
|
201
|
+
},
|
|
202
|
+
}}
|
|
203
|
+
actions={
|
|
204
|
+
<Stack direction="row" spacing={2}>
|
|
205
|
+
<Button variant="outlined" onClick={handleCancel} disabled={state.loading}>
|
|
206
|
+
{t('common.cancel')}
|
|
207
|
+
</Button>
|
|
208
|
+
<Button variant="contained" onClick={handleSave} disabled={state.loading}>
|
|
209
|
+
{state.loading ? t('common.saving') : t('common.save')}
|
|
210
|
+
</Button>
|
|
211
|
+
</Stack>
|
|
212
|
+
}>
|
|
213
|
+
<Stack spacing={3}>
|
|
214
|
+
{state.error && (
|
|
215
|
+
<Alert severity="error" onClose={() => setState({ error: null })}>
|
|
216
|
+
{state.error}
|
|
217
|
+
</Alert>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
<TextField
|
|
221
|
+
label={t('admin.exchangeRateProvider.edit.name')}
|
|
222
|
+
value={state.name}
|
|
223
|
+
onChange={(e) => setState({ name: e.target.value })}
|
|
224
|
+
helperText={t('admin.exchangeRateProvider.edit.nameHelp')}
|
|
225
|
+
fullWidth
|
|
226
|
+
required
|
|
227
|
+
disabled={false}
|
|
228
|
+
/>
|
|
229
|
+
|
|
230
|
+
<FormControl fullWidth required>
|
|
231
|
+
<InputLabel>{t('admin.exchangeRateProvider.edit.type')}</InputLabel>
|
|
232
|
+
<Select
|
|
233
|
+
value={state.type}
|
|
234
|
+
label={t('admin.exchangeRateProvider.edit.type')}
|
|
235
|
+
onChange={(e) => {
|
|
236
|
+
const nextType = e.target.value as ExchangeRateProvider['type'];
|
|
237
|
+
setState((prev) => {
|
|
238
|
+
const shouldDefaultName = isCreate && (!prev.name || prev.name === TYPE_NAME_DEFAULTS[prev.type]);
|
|
239
|
+
return {
|
|
240
|
+
type: nextType,
|
|
241
|
+
name: shouldDefaultName ? TYPE_NAME_DEFAULTS[nextType] : prev.name,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
}}
|
|
245
|
+
disabled={!isCreate}>
|
|
246
|
+
<MenuItem value="token-data">Token Data (ArcBlock)</MenuItem>
|
|
247
|
+
<MenuItem value="coingecko">CoinGecko</MenuItem>
|
|
248
|
+
<MenuItem value="coinmarketcap">CoinMarketCap</MenuItem>
|
|
249
|
+
</Select>
|
|
250
|
+
<FormHelperText>{t('admin.exchangeRateProvider.edit.typeHelp')}</FormHelperText>
|
|
251
|
+
</FormControl>
|
|
252
|
+
|
|
253
|
+
<TextField
|
|
254
|
+
label={t('admin.exchangeRateProvider.edit.baseUrl')}
|
|
255
|
+
value={state.base_url}
|
|
256
|
+
onChange={(e) => setState({ base_url: e.target.value })}
|
|
257
|
+
helperText={t('admin.exchangeRateProvider.edit.baseUrlHelp', {
|
|
258
|
+
defaultUrl: BASE_URL_PLACEHOLDERS[state.type] || '',
|
|
259
|
+
})}
|
|
260
|
+
placeholder={BASE_URL_PLACEHOLDERS[state.type] || ''}
|
|
261
|
+
fullWidth
|
|
262
|
+
/>
|
|
263
|
+
|
|
264
|
+
{state.type === 'coinmarketcap' && (
|
|
265
|
+
<Stack spacing={1}>
|
|
266
|
+
<TextField
|
|
267
|
+
label={t('admin.exchangeRateProvider.edit.apiKey')}
|
|
268
|
+
value={state.api_key}
|
|
269
|
+
onChange={(e) => setState({ api_key: e.target.value, apiKeyTouched: true })}
|
|
270
|
+
helperText={t('admin.exchangeRateProvider.edit.apiKeyHelp')}
|
|
271
|
+
placeholder={API_KEY_PLACEHOLDERS[state.type] || ''}
|
|
272
|
+
type="password"
|
|
273
|
+
fullWidth
|
|
274
|
+
/>
|
|
275
|
+
<Typography variant="caption" color="text.secondary">
|
|
276
|
+
{t('admin.exchangeRateProvider.edit.apiKeyLinkText')}{' '}
|
|
277
|
+
<Link href="https://pro.coinmarketcap.com/account" target="_blank" rel="noreferrer">
|
|
278
|
+
{t('admin.exchangeRateProvider.edit.apiKeyLinkAction')}
|
|
279
|
+
</Link>
|
|
280
|
+
</Typography>
|
|
281
|
+
</Stack>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
<Stack direction="row" spacing={2} alignItems="flex-start">
|
|
285
|
+
<Button
|
|
286
|
+
variant="outlined"
|
|
287
|
+
onClick={handleTestConnection}
|
|
288
|
+
disabled={state.testing}
|
|
289
|
+
startIcon={state.testing ? <CircularProgress size={16} /> : undefined}>
|
|
290
|
+
{state.testing
|
|
291
|
+
? t('admin.exchangeRateProvider.edit.testing')
|
|
292
|
+
: t('admin.exchangeRateProvider.edit.testConnection')}
|
|
293
|
+
</Button>
|
|
294
|
+
</Stack>
|
|
295
|
+
|
|
296
|
+
{state.testResult && (
|
|
297
|
+
<Alert
|
|
298
|
+
severity={state.testResult.success ? 'success' : 'error'}
|
|
299
|
+
onClose={() => setState({ testResult: null })}>
|
|
300
|
+
{state.testResult.success
|
|
301
|
+
? t('admin.exchangeRateProvider.edit.testSuccess', {
|
|
302
|
+
time: state.testResult.responseTime,
|
|
303
|
+
rate: state.testResult.rate,
|
|
304
|
+
symbol: state.testResult.symbol,
|
|
305
|
+
timestamp: state.testResult.timestamp,
|
|
306
|
+
})
|
|
307
|
+
: t('admin.exchangeRateProvider.edit.testFailed', {
|
|
308
|
+
error: state.testResult.error,
|
|
309
|
+
})}
|
|
310
|
+
</Alert>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
<TextField
|
|
314
|
+
label={t('admin.exchangeRateProvider.edit.priority')}
|
|
315
|
+
type="number"
|
|
316
|
+
value={state.priority}
|
|
317
|
+
onChange={(e) => {
|
|
318
|
+
const value = parseInt(e.target.value, 10);
|
|
319
|
+
setState({ priority: Number.isNaN(value) ? 1 : Math.max(1, value) });
|
|
320
|
+
}}
|
|
321
|
+
helperText={t('admin.exchangeRateProvider.edit.priorityHelp')}
|
|
322
|
+
fullWidth
|
|
323
|
+
inputProps={{ min: 1 }}
|
|
324
|
+
/>
|
|
325
|
+
|
|
326
|
+
<FormControl fullWidth>
|
|
327
|
+
<InputLabel>{t('admin.exchangeRateProvider.edit.status')}</InputLabel>
|
|
328
|
+
<Select
|
|
329
|
+
value={state.status}
|
|
330
|
+
label={t('admin.exchangeRateProvider.edit.status')}
|
|
331
|
+
onChange={(e) => setState({ status: e.target.value as any })}>
|
|
332
|
+
<MenuItem value="active">{t('admin.exchangeRateProvider.status.active')}</MenuItem>
|
|
333
|
+
<MenuItem value="degraded">{t('admin.exchangeRateProvider.status.degraded')}</MenuItem>
|
|
334
|
+
<MenuItem value="paused">{t('admin.exchangeRateProvider.status.paused')}</MenuItem>
|
|
335
|
+
<MenuItem value="inactive">{t('admin.exchangeRateProvider.status.inactive')}</MenuItem>
|
|
336
|
+
</Select>
|
|
337
|
+
<FormHelperText>{t('admin.exchangeRateProvider.edit.statusHelp')}</FormHelperText>
|
|
338
|
+
</FormControl>
|
|
339
|
+
|
|
340
|
+
{state.status === 'paused' && (
|
|
341
|
+
<TextField
|
|
342
|
+
label={t('admin.exchangeRateProvider.edit.pausedReason')}
|
|
343
|
+
value={state.paused_reason}
|
|
344
|
+
onChange={(e) => setState({ paused_reason: e.target.value })}
|
|
345
|
+
multiline
|
|
346
|
+
rows={3}
|
|
347
|
+
fullWidth
|
|
348
|
+
helperText={t('admin.exchangeRateProvider.edit.pausedReasonHelp')}
|
|
349
|
+
/>
|
|
350
|
+
)}
|
|
351
|
+
</Stack>
|
|
352
|
+
</Dialog>
|
|
353
|
+
);
|
|
354
|
+
}
|