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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/* eslint-disable react/no-unstable-nested-components */
|
|
2
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
3
|
+
import Dialog from '@arcblock/ux/lib/Dialog';
|
|
4
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
5
|
+
import { api, ConfirmDialog, formatError, formatTime, Table, Status, Switch } from '@blocklet/payment-react';
|
|
6
|
+
import { DeleteOutlined, EditOutlined } from '@mui/icons-material';
|
|
7
|
+
import { Alert, Button, IconButton, Stack, Tooltip, Typography } from '@mui/material';
|
|
8
|
+
import { useRequest, useSetState } from 'ahooks';
|
|
9
|
+
import { useEffect } from 'react';
|
|
10
|
+
import { useSearchParams } from 'react-router-dom';
|
|
11
|
+
|
|
12
|
+
import EditProviderDialog from './edit-dialog';
|
|
13
|
+
|
|
14
|
+
interface ExchangeRateProvider {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
type: 'token-data' | 'coingecko' | 'coinmarketcap';
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
priority: number;
|
|
20
|
+
status: 'active' | 'degraded' | 'paused' | 'inactive';
|
|
21
|
+
paused_reason: string | null;
|
|
22
|
+
config: Record<string, any> | null;
|
|
23
|
+
last_success_at: string | null;
|
|
24
|
+
last_failure_at: string | null;
|
|
25
|
+
failure_count: number;
|
|
26
|
+
created_at: string;
|
|
27
|
+
updated_at: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_BASE_URLS = {
|
|
31
|
+
'token-data': 'https://token-data.arcblock.io',
|
|
32
|
+
coingecko: 'https://api.coingecko.com/api/v3',
|
|
33
|
+
coinmarketcap: 'https://pro-api.coinmarketcap.com/v1',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
const fetchProviders = (): Promise<{ data: ExchangeRateProvider[] }> => {
|
|
37
|
+
return api.get('/api/exchange-rate-providers').then((res) => res.data);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const getStatusColor = (status: string) => {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case 'active':
|
|
43
|
+
return 'success';
|
|
44
|
+
case 'degraded':
|
|
45
|
+
return 'warning';
|
|
46
|
+
case 'paused':
|
|
47
|
+
return 'default';
|
|
48
|
+
case 'inactive':
|
|
49
|
+
return 'error';
|
|
50
|
+
default:
|
|
51
|
+
return 'default';
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default function ExchangeRateProviders() {
|
|
56
|
+
const { t } = useLocaleContext();
|
|
57
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
58
|
+
const [state, setState] = useSetState({
|
|
59
|
+
editingProvider: null as ExchangeRateProvider | null,
|
|
60
|
+
isCreating: false,
|
|
61
|
+
deletingProvider: null as ExchangeRateProvider | null,
|
|
62
|
+
updatingProviderId: null as string | null,
|
|
63
|
+
confirmDisableProvider: null as ExchangeRateProvider | null,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const { loading, error, data, refresh } = useRequest(fetchProviders);
|
|
67
|
+
|
|
68
|
+
const handleEdit = (provider: ExchangeRateProvider) => {
|
|
69
|
+
setState({ editingProvider: provider, isCreating: false });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleCloseEdit = () => {
|
|
73
|
+
setState({ editingProvider: null, isCreating: false });
|
|
74
|
+
if (searchParams.get('create') === '1') {
|
|
75
|
+
const nextParams = new URLSearchParams(searchParams);
|
|
76
|
+
nextParams.delete('create');
|
|
77
|
+
setSearchParams(nextParams);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handleSave = async () => {
|
|
82
|
+
await refresh();
|
|
83
|
+
handleCloseEdit();
|
|
84
|
+
Toast.success(t('common.saved'));
|
|
85
|
+
if (searchParams.get('create') === '1') {
|
|
86
|
+
const nextParams = new URLSearchParams(searchParams);
|
|
87
|
+
nextParams.delete('create');
|
|
88
|
+
setSearchParams(nextParams);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleCreate = () => {
|
|
93
|
+
setState({
|
|
94
|
+
editingProvider: {
|
|
95
|
+
id: '',
|
|
96
|
+
name: 'Token Data',
|
|
97
|
+
type: 'token-data',
|
|
98
|
+
enabled: true,
|
|
99
|
+
priority: (data?.data?.length || 0) + 1,
|
|
100
|
+
status: 'active',
|
|
101
|
+
paused_reason: '',
|
|
102
|
+
config: { base_url: '' },
|
|
103
|
+
last_failure_at: null,
|
|
104
|
+
last_success_at: null,
|
|
105
|
+
failure_count: 0,
|
|
106
|
+
created_at: '',
|
|
107
|
+
updated_at: '',
|
|
108
|
+
},
|
|
109
|
+
isCreating: true,
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const shouldCreate = searchParams.get('create') === '1';
|
|
115
|
+
if (shouldCreate && !state.isCreating && !state.editingProvider) {
|
|
116
|
+
handleCreate();
|
|
117
|
+
}
|
|
118
|
+
}, [searchParams, state.isCreating, state.editingProvider, data?.data]);
|
|
119
|
+
|
|
120
|
+
const handleDelete = async () => {
|
|
121
|
+
if (!state.deletingProvider) return;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await api.delete(`/api/exchange-rate-providers/${state.deletingProvider.id}`);
|
|
125
|
+
Toast.success(t('common.deleted'));
|
|
126
|
+
await refresh();
|
|
127
|
+
setState({ deletingProvider: null });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
Toast.error(formatError(err));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleToggleEnabled = async (provider: ExchangeRateProvider, enabled: boolean) => {
|
|
134
|
+
setState({ updatingProviderId: provider.id });
|
|
135
|
+
try {
|
|
136
|
+
await api.put(`/api/exchange-rate-providers/${provider.id}`, {
|
|
137
|
+
enabled,
|
|
138
|
+
});
|
|
139
|
+
Toast.success(t('common.saved'));
|
|
140
|
+
await refresh();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
Toast.error(formatError(err));
|
|
143
|
+
} finally {
|
|
144
|
+
setState({ updatingProviderId: null });
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (error) {
|
|
149
|
+
return <Alert severity="error">{formatError(error)}</Alert>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const columns = [
|
|
153
|
+
{
|
|
154
|
+
label: t('admin.exchangeRateProvider.table.name'),
|
|
155
|
+
name: 'name',
|
|
156
|
+
options: {
|
|
157
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
158
|
+
const provider = data?.data?.[index];
|
|
159
|
+
if (!provider) return null;
|
|
160
|
+
const baseUrl = provider.config?.base_url || DEFAULT_BASE_URLS[provider.type];
|
|
161
|
+
return (
|
|
162
|
+
<Stack direction="column" spacing={0.5}>
|
|
163
|
+
<Typography variant="body2" fontWeight={500}>
|
|
164
|
+
{provider.name}
|
|
165
|
+
</Typography>
|
|
166
|
+
{baseUrl && (
|
|
167
|
+
<Typography variant="caption" color="text.secondary">
|
|
168
|
+
{baseUrl}
|
|
169
|
+
</Typography>
|
|
170
|
+
)}
|
|
171
|
+
</Stack>
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
label: t('admin.exchangeRateProvider.table.participation'),
|
|
178
|
+
name: 'participation',
|
|
179
|
+
options: {
|
|
180
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
181
|
+
const provider = data?.data?.[index];
|
|
182
|
+
if (!provider) return null;
|
|
183
|
+
const included = provider.enabled && provider.status !== 'paused' && provider.status !== 'inactive';
|
|
184
|
+
return (
|
|
185
|
+
<Status
|
|
186
|
+
label={
|
|
187
|
+
included
|
|
188
|
+
? t('admin.exchangeRateProvider.participation.included')
|
|
189
|
+
: t('admin.exchangeRateProvider.participation.excluded')
|
|
190
|
+
}
|
|
191
|
+
color={included ? 'success' : 'default'}
|
|
192
|
+
/>
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
label: t('admin.exchangeRateProvider.table.health'),
|
|
199
|
+
name: 'health',
|
|
200
|
+
options: {
|
|
201
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
202
|
+
const provider = data?.data?.[index];
|
|
203
|
+
if (!provider) return null;
|
|
204
|
+
const healthLabel = t(`admin.exchangeRateProvider.health.${provider.status}`);
|
|
205
|
+
if (provider.paused_reason) {
|
|
206
|
+
return (
|
|
207
|
+
<Tooltip title={provider.paused_reason} arrow>
|
|
208
|
+
<span>
|
|
209
|
+
<Status label={healthLabel} color={getStatusColor(provider.status)} />
|
|
210
|
+
</span>
|
|
211
|
+
</Tooltip>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return <Status label={healthLabel} color={getStatusColor(provider.status)} />;
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
label: t('admin.exchangeRateProvider.table.recentActivity'),
|
|
220
|
+
name: 'recent_activity',
|
|
221
|
+
options: {
|
|
222
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
223
|
+
const provider = data?.data?.[index];
|
|
224
|
+
if (!provider) return null;
|
|
225
|
+
const lastUpdateAt = provider.last_success_at || provider.last_failure_at;
|
|
226
|
+
const lastUpdateDisplay = lastUpdateAt ? formatTime(lastUpdateAt) : '-';
|
|
227
|
+
const failureWindowMs = 24 * 60 * 60 * 1000;
|
|
228
|
+
const failureCount24h =
|
|
229
|
+
provider.last_failure_at && Date.now() - new Date(provider.last_failure_at).getTime() <= failureWindowMs
|
|
230
|
+
? provider.failure_count
|
|
231
|
+
: 0;
|
|
232
|
+
return (
|
|
233
|
+
<Stack direction="column" spacing={0.25}>
|
|
234
|
+
<Typography variant="caption" color="text.secondary">
|
|
235
|
+
{t('admin.exchangeRateProvider.table.lastUpdate', { time: lastUpdateDisplay })}
|
|
236
|
+
</Typography>
|
|
237
|
+
<Typography variant="caption" color="text.secondary">
|
|
238
|
+
{t('admin.exchangeRateProvider.table.failures24h', {
|
|
239
|
+
count: failureCount24h,
|
|
240
|
+
})}
|
|
241
|
+
</Typography>
|
|
242
|
+
</Stack>
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
label: t('admin.exchangeRateProvider.table.enabled'),
|
|
249
|
+
name: 'enabled',
|
|
250
|
+
options: {
|
|
251
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
252
|
+
const provider = data?.data?.[index];
|
|
253
|
+
if (!provider) return null;
|
|
254
|
+
return (
|
|
255
|
+
<Switch
|
|
256
|
+
checked={provider.enabled}
|
|
257
|
+
onChange={(e) => {
|
|
258
|
+
const nextEnabled = e.target.checked;
|
|
259
|
+
if (!nextEnabled) {
|
|
260
|
+
setState({ confirmDisableProvider: provider });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
handleToggleEnabled(provider, nextEnabled);
|
|
264
|
+
}}
|
|
265
|
+
disabled={state.updatingProviderId === provider.id}
|
|
266
|
+
size="small"
|
|
267
|
+
/>
|
|
268
|
+
);
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
label: t('common.actions'),
|
|
274
|
+
name: 'id',
|
|
275
|
+
options: {
|
|
276
|
+
customBodyRenderLite: (_: string, index: number) => {
|
|
277
|
+
const provider = data?.data?.[index];
|
|
278
|
+
if (!provider) return null;
|
|
279
|
+
return (
|
|
280
|
+
<Stack direction="row" spacing={0.5}>
|
|
281
|
+
<IconButton size="small" onClick={() => handleEdit(provider)}>
|
|
282
|
+
<EditOutlined fontSize="small" />
|
|
283
|
+
</IconButton>
|
|
284
|
+
<IconButton size="small" onClick={() => setState({ deletingProvider: provider })} color="error">
|
|
285
|
+
<DeleteOutlined fontSize="small" />
|
|
286
|
+
</IconButton>
|
|
287
|
+
</Stack>
|
|
288
|
+
);
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<>
|
|
296
|
+
<Typography variant="subtitle1" sx={{ color: 'text.secondary', whiteSpace: 'break-spaces', mt: 3, mb: 2 }}>
|
|
297
|
+
{t('admin.exchangeRateProvider.medianStrategyNote')}
|
|
298
|
+
</Typography>
|
|
299
|
+
<Table
|
|
300
|
+
data={data?.data || []}
|
|
301
|
+
columns={columns}
|
|
302
|
+
loading={loading}
|
|
303
|
+
toolbar={false}
|
|
304
|
+
footer={false}
|
|
305
|
+
options={{
|
|
306
|
+
count: data?.data?.length || 0,
|
|
307
|
+
page: 0,
|
|
308
|
+
rowsPerPage: 100,
|
|
309
|
+
}}
|
|
310
|
+
emptyNodeText={t('common.noData')}
|
|
311
|
+
/>
|
|
312
|
+
{state.confirmDisableProvider && (
|
|
313
|
+
<ConfirmDialog
|
|
314
|
+
title={t('admin.exchangeRateProvider.disableConfirmTitle')}
|
|
315
|
+
message={t('admin.exchangeRateProvider.disableConfirmMessage')}
|
|
316
|
+
onConfirm={() => {
|
|
317
|
+
const provider = state.confirmDisableProvider as ExchangeRateProvider;
|
|
318
|
+
setState({ confirmDisableProvider: null });
|
|
319
|
+
handleToggleEnabled(provider, false);
|
|
320
|
+
}}
|
|
321
|
+
onCancel={() => setState({ confirmDisableProvider: null })}
|
|
322
|
+
/>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{state.editingProvider && (
|
|
326
|
+
<EditProviderDialog
|
|
327
|
+
provider={state.editingProvider}
|
|
328
|
+
isCreate={state.isCreating}
|
|
329
|
+
open={!!state.editingProvider}
|
|
330
|
+
onClose={handleCloseEdit}
|
|
331
|
+
onSave={handleSave}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{state.deletingProvider && (
|
|
336
|
+
<Dialog
|
|
337
|
+
open={!!state.deletingProvider}
|
|
338
|
+
onClose={() => setState({ deletingProvider: null })}
|
|
339
|
+
maxWidth="sm"
|
|
340
|
+
title={t('admin.exchangeRateProvider.delete.title')}
|
|
341
|
+
PaperProps={{
|
|
342
|
+
sx: {
|
|
343
|
+
borderRadius: 2,
|
|
344
|
+
},
|
|
345
|
+
}}
|
|
346
|
+
actions={
|
|
347
|
+
<Stack direction="row" spacing={2}>
|
|
348
|
+
<Button variant="outlined" onClick={() => setState({ deletingProvider: null })}>
|
|
349
|
+
{t('common.cancel')}
|
|
350
|
+
</Button>
|
|
351
|
+
<Button variant="contained" color="error" onClick={handleDelete}>
|
|
352
|
+
{t('common.delete')}
|
|
353
|
+
</Button>
|
|
354
|
+
</Stack>
|
|
355
|
+
}>
|
|
356
|
+
<Typography>
|
|
357
|
+
{t('admin.exchangeRateProvider.delete.confirm', { name: state.deletingProvider.name })}
|
|
358
|
+
</Typography>
|
|
359
|
+
</Dialog>
|
|
360
|
+
)}
|
|
361
|
+
</>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
2
|
import Tabs from '@arcblock/ux/lib/Tabs';
|
|
3
|
-
import { Box, Stack } from '@mui/material';
|
|
3
|
+
import { Box, Button, Stack } from '@mui/material';
|
|
4
|
+
import { AddOutlined } from '@mui/icons-material';
|
|
4
5
|
import React, { isValidElement, useState } from 'react';
|
|
5
6
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
6
7
|
|
|
@@ -25,6 +26,7 @@ const pages = {
|
|
|
25
26
|
'pricing-tables': React.lazy(() => import('./pricing-tables')),
|
|
26
27
|
passports: React.lazy(() => import('./passports')),
|
|
27
28
|
vendors: React.lazy(() => import('./vendors')),
|
|
29
|
+
'exchange-rate-providers': React.lazy(() => import('./exchange-rate-providers')),
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
export default function Products() {
|
|
@@ -34,6 +36,8 @@ export default function Products() {
|
|
|
34
36
|
const [createProduct, setCreateProduct] = useState(false);
|
|
35
37
|
const [createCoupon, setCreateCoupon] = useState(false);
|
|
36
38
|
const { startTransition } = useTransitionContext();
|
|
39
|
+
const handleCreateProvider = () =>
|
|
40
|
+
startTransition(() => navigate('/admin/products/exchange-rate-providers?create=1'));
|
|
37
41
|
|
|
38
42
|
// Handle old-style routing where ID is passed as page parameter
|
|
39
43
|
if (page.startsWith('prod_')) {
|
|
@@ -78,6 +82,7 @@ export default function Products() {
|
|
|
78
82
|
{ label: t('admin.pricingTables'), value: 'pricing-tables' },
|
|
79
83
|
{ label: t('admin.passports'), value: 'passports' },
|
|
80
84
|
{ label: t('admin.vendors'), value: 'vendors' },
|
|
85
|
+
{ label: t('admin.exchangeRateProvider.title'), value: 'exchange-rate-providers' },
|
|
81
86
|
];
|
|
82
87
|
|
|
83
88
|
let extra = null;
|
|
@@ -91,6 +96,12 @@ export default function Products() {
|
|
|
91
96
|
extra = <CouponCreate open={createCoupon} onClose={() => setCreateCoupon(false)} />;
|
|
92
97
|
} else if (page === 'vendors') {
|
|
93
98
|
extra = <VendorCreate open={createProduct} onClose={() => setCreateProduct(false)} />;
|
|
99
|
+
} else if (page === 'exchange-rate-providers') {
|
|
100
|
+
extra = (
|
|
101
|
+
<Button variant="contained" startIcon={<AddOutlined />} onClick={handleCreateProvider} size="small">
|
|
102
|
+
{t('admin.exchangeRateProvider.create.primaryAction')}
|
|
103
|
+
</Button>
|
|
104
|
+
);
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
return (
|
|
@@ -22,7 +22,7 @@ import { ArrowBackOutlined } from '@mui/icons-material';
|
|
|
22
22
|
import { Alert, Box, Button, CircularProgress, Divider, Stack, Tooltip, Typography, useTheme } from '@mui/material';
|
|
23
23
|
import { styled } from '@mui/system';
|
|
24
24
|
import { useRequest, useSetState } from 'ahooks';
|
|
25
|
-
import { useEffect
|
|
25
|
+
import { useEffect } from 'react';
|
|
26
26
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
27
27
|
|
|
28
28
|
import { joinURL } from 'ufo';
|
|
@@ -71,15 +71,15 @@ export default function CustomerInvoiceDetail() {
|
|
|
71
71
|
const action = searchParams.get('action');
|
|
72
72
|
const { session } = useSessionContext();
|
|
73
73
|
const isDonation = data?.checkoutSession?.submit_type === 'donate';
|
|
74
|
-
const payHandlerRef = useRef<(() => void) | null>(null);
|
|
75
74
|
|
|
76
75
|
const handleExternalPayment = () => {
|
|
77
76
|
setState({ paying: true });
|
|
77
|
+
|
|
78
78
|
connect.open({
|
|
79
79
|
action: 'collect',
|
|
80
80
|
saveConnect: false,
|
|
81
81
|
locale: locale as 'en' | 'zh',
|
|
82
|
-
extraParams: { invoiceId: params.id, action },
|
|
82
|
+
extraParams: { invoiceId: params.id, action, allowCreatePI: data?.status === 'uncollectible' },
|
|
83
83
|
messages: {
|
|
84
84
|
scan: '',
|
|
85
85
|
title: t(`payment.customer.invoice.${action || 'pay'}`),
|
|
@@ -202,7 +202,6 @@ export default function CustomerInvoiceDetail() {
|
|
|
202
202
|
setState({ paying: false });
|
|
203
203
|
}}>
|
|
204
204
|
{(onPay: () => void, paying: boolean) => {
|
|
205
|
-
payHandlerRef.current = onPay;
|
|
206
205
|
return (
|
|
207
206
|
<Button
|
|
208
207
|
variant="outlined"
|
|
@@ -232,6 +231,16 @@ export default function CustomerInvoiceDetail() {
|
|
|
232
231
|
))}
|
|
233
232
|
</Stack>
|
|
234
233
|
</Stack>
|
|
234
|
+
{data.status === 'uncollectible' && data.metadata?.slippage?.below_threshold && (
|
|
235
|
+
<Alert severity="warning" sx={{ mt: 2 }}>
|
|
236
|
+
<Typography variant="body2">
|
|
237
|
+
{t('payment.customer.invoice.slippageExceededDetail', {
|
|
238
|
+
currentRate: data.metadata.slippage.rate_at_invoice || '—',
|
|
239
|
+
minRate: data.metadata.slippage.min_acceptable_rate || '—',
|
|
240
|
+
})}
|
|
241
|
+
</Typography>
|
|
242
|
+
</Alert>
|
|
243
|
+
)}
|
|
235
244
|
<Box
|
|
236
245
|
sx={{
|
|
237
246
|
mt: isMobile ? '0 !important' : 4,
|
|
@@ -310,14 +319,29 @@ export default function CustomerInvoiceDetail() {
|
|
|
310
319
|
<InfoMetric
|
|
311
320
|
label={t('common.status')}
|
|
312
321
|
value={
|
|
313
|
-
<
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
<
|
|
319
|
-
|
|
320
|
-
|
|
322
|
+
<Stack direction="row" spacing={1} alignItems="center">
|
|
323
|
+
<Tooltip
|
|
324
|
+
title={data.status === 'void' ? t('payment.customer.invoice.noPaymentRequired') : ''}
|
|
325
|
+
arrow
|
|
326
|
+
placement="top">
|
|
327
|
+
<span>
|
|
328
|
+
<Status label={data.status} color={getInvoiceStatusColor(data.status)} />
|
|
329
|
+
</span>
|
|
330
|
+
</Tooltip>
|
|
331
|
+
{data.status === 'uncollectible' && data.metadata?.slippage?.below_threshold && (
|
|
332
|
+
<Tooltip
|
|
333
|
+
title={t('payment.customer.invoice.slippageExceededDetail', {
|
|
334
|
+
currentRate: data.metadata.slippage.rate_at_invoice || '—',
|
|
335
|
+
minRate: data.metadata.slippage.min_acceptable_rate || '—',
|
|
336
|
+
})}
|
|
337
|
+
arrow
|
|
338
|
+
placement="top">
|
|
339
|
+
<span>
|
|
340
|
+
<Status label={t('payment.customer.invoice.slippageExceeded')} color="warning" />
|
|
341
|
+
</span>
|
|
342
|
+
</Tooltip>
|
|
343
|
+
)}
|
|
344
|
+
</Stack>
|
|
321
345
|
}
|
|
322
346
|
divider
|
|
323
347
|
/>
|
|
@@ -20,12 +20,13 @@ import {
|
|
|
20
20
|
showStaking,
|
|
21
21
|
usePaymentContext,
|
|
22
22
|
} from '@blocklet/payment-react';
|
|
23
|
+
import type { SlippageConfigValue } from '@blocklet/payment-react';
|
|
23
24
|
import type { TCustomer, TPaymentCurrency, TPaymentMethod, TSubscriptionExpanded } from '@blocklet/payment-types';
|
|
24
25
|
import { ArrowBackOutlined } from '@mui/icons-material';
|
|
25
26
|
import { Alert, Box, CircularProgress, Stack, Typography } from '@mui/material';
|
|
26
27
|
import { useRequest, useSetState } from 'ahooks';
|
|
27
28
|
import pWaitFor from 'p-wait-for';
|
|
28
|
-
import { useEffect, useState } from 'react';
|
|
29
|
+
import { useEffect, useState, useMemo } from 'react';
|
|
29
30
|
import { Controller, FormProvider, useForm, useFormContext, useWatch } from 'react-hook-form';
|
|
30
31
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
|
31
32
|
import { joinURL } from 'ufo';
|
|
@@ -33,7 +34,7 @@ import { joinURL } from 'ufo';
|
|
|
33
34
|
import SectionHeader from '../../../components/section/header';
|
|
34
35
|
import SubscriptionDescription from '../../../components/subscription/description';
|
|
35
36
|
import { goBackOrFallback } from '../../../libs/util';
|
|
36
|
-
import { useUnpaidInvoicesCheckForSubscription } from '../../../hooks/subscription';
|
|
37
|
+
import { useSubscriptionExchangeRate, useUnpaidInvoicesCheckForSubscription } from '../../../hooks/subscription';
|
|
37
38
|
import { useArcsphere } from '../../../hooks/browser';
|
|
38
39
|
|
|
39
40
|
const fetchData = async (id: string): Promise<{ subscription: TSubscriptionExpanded; customer: TCustomer }> => {
|
|
@@ -48,6 +49,28 @@ const fetchData = async (id: string): Promise<{ subscription: TSubscriptionExpan
|
|
|
48
49
|
};
|
|
49
50
|
};
|
|
50
51
|
|
|
52
|
+
const defaultSlippageConfig: SlippageConfigValue = { mode: 'percent', percent: 0.5 };
|
|
53
|
+
|
|
54
|
+
const normalizeSlippageConfig = (rawConfig: any): SlippageConfigValue => {
|
|
55
|
+
if (!rawConfig || typeof rawConfig !== 'object') {
|
|
56
|
+
return defaultSlippageConfig;
|
|
57
|
+
}
|
|
58
|
+
const mode = rawConfig.mode === 'rate' ? 'rate' : 'percent';
|
|
59
|
+
const percentValue = Number(rawConfig.percent);
|
|
60
|
+
const percent = Number.isFinite(percentValue) && percentValue >= 0 ? percentValue : defaultSlippageConfig.percent;
|
|
61
|
+
const minRate = typeof rawConfig.min_acceptable_rate === 'string' ? rawConfig.min_acceptable_rate : undefined;
|
|
62
|
+
const baseCurrency = typeof rawConfig.base_currency === 'string' ? rawConfig.base_currency : undefined;
|
|
63
|
+
const updatedAtMs = Number.isFinite(Number(rawConfig.updated_at_ms)) ? Number(rawConfig.updated_at_ms) : undefined;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
mode,
|
|
67
|
+
percent,
|
|
68
|
+
...(mode === 'rate' && minRate ? { min_acceptable_rate: minRate } : {}),
|
|
69
|
+
...(baseCurrency ? { base_currency: baseCurrency } : {}),
|
|
70
|
+
...(updatedAtMs ? { updated_at_ms: updatedAtMs } : {}),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
51
74
|
type Props = {
|
|
52
75
|
subscription: TSubscriptionExpanded;
|
|
53
76
|
customer: TCustomer;
|
|
@@ -83,6 +106,9 @@ function CustomerSubscriptionChangePayment({ subscription, customer, onComplete
|
|
|
83
106
|
const { checkUnpaidInvoices } = useUnpaidInvoicesCheckForSubscription(subscription.id);
|
|
84
107
|
const { control, setValue, handleSubmit } = useFormContext();
|
|
85
108
|
const inArcsphere = useArcsphere();
|
|
109
|
+
const [slippageConfig, setSlippageConfig] = useState<SlippageConfigValue>(() =>
|
|
110
|
+
normalizeSlippageConfig((subscription as any)?.slippage_config)
|
|
111
|
+
);
|
|
86
112
|
|
|
87
113
|
const [state, setState] = useSetState<{
|
|
88
114
|
submitting: boolean;
|
|
@@ -101,7 +127,6 @@ function CustomerSubscriptionChangePayment({ subscription, customer, onComplete
|
|
|
101
127
|
stripeContext: undefined,
|
|
102
128
|
customer,
|
|
103
129
|
});
|
|
104
|
-
|
|
105
130
|
const payee = getStatementDescriptor(subscription.items);
|
|
106
131
|
const supported = getPriceCurrencyOptions(subscription.items[0]?.price!).map((x: any) => x.currency_id);
|
|
107
132
|
const currencies = flattenPaymentMethods(settings.paymentMethods).filter((x: any) => supported.includes(x.id));
|
|
@@ -119,6 +144,37 @@ function CustomerSubscriptionChangePayment({ subscription, customer, onComplete
|
|
|
119
144
|
|
|
120
145
|
const method = settings.paymentMethods.find((x: any) => x.id === selectedMethodId) as TPaymentMethod;
|
|
121
146
|
const changed = selectedCurrencyId !== subscription.currency_id;
|
|
147
|
+
const hasDynamicPricing = useMemo(
|
|
148
|
+
() =>
|
|
149
|
+
(subscription.items || []).some((item: any) => {
|
|
150
|
+
const price = item.upsell_price || item.price;
|
|
151
|
+
return price && (price as any)?.pricing_type === 'dynamic';
|
|
152
|
+
}),
|
|
153
|
+
[subscription.items]
|
|
154
|
+
);
|
|
155
|
+
const isStripePayment = method?.type === 'stripe';
|
|
156
|
+
const needsExchangeRate = hasDynamicPricing && !isStripePayment;
|
|
157
|
+
const { liveRateInfo, liveRateUnavailable } = useSubscriptionExchangeRate({
|
|
158
|
+
subscriptionId: subscription.id,
|
|
159
|
+
currencyId: selectedCurrencyId,
|
|
160
|
+
enabled: needsExchangeRate,
|
|
161
|
+
});
|
|
162
|
+
const handleSlippageChange = async (nextConfig: SlippageConfigValue) => {
|
|
163
|
+
try {
|
|
164
|
+
const payloadConfig = {
|
|
165
|
+
...nextConfig,
|
|
166
|
+
...(nextConfig.base_currency ? {} : { base_currency: liveRateInfo?.base_currency || 'USD' }),
|
|
167
|
+
};
|
|
168
|
+
await api.put(`/api/subscriptions/${subscription.id}/slippage`, {
|
|
169
|
+
slippage_config: payloadConfig,
|
|
170
|
+
});
|
|
171
|
+
setSlippageConfig(payloadConfig);
|
|
172
|
+
Toast.success(t('common.saved'));
|
|
173
|
+
} catch (err: any) {
|
|
174
|
+
console.error('Failed to update slippage', err);
|
|
175
|
+
Toast.error(formatError(err));
|
|
176
|
+
}
|
|
177
|
+
};
|
|
122
178
|
|
|
123
179
|
const handleCompleted = async () => {
|
|
124
180
|
await onComplete();
|
|
@@ -260,6 +316,12 @@ function CustomerSubscriptionChangePayment({ subscription, customer, onComplete
|
|
|
260
316
|
trialInDays={0}
|
|
261
317
|
billingThreshold={0}
|
|
262
318
|
showStaking={showStaking(method, currency, !!subscription.billing_thresholds?.no_stake)}
|
|
319
|
+
liveRate={liveRateInfo}
|
|
320
|
+
rateUnavailable={needsExchangeRate && liveRateUnavailable}
|
|
321
|
+
slippageConfig={slippageConfig}
|
|
322
|
+
onSlippageChange={needsExchangeRate ? handleSlippageChange : undefined}
|
|
323
|
+
isSubscription
|
|
324
|
+
isStripePayment={isStripePayment}
|
|
263
325
|
/>
|
|
264
326
|
</Stack>
|
|
265
327
|
<Stack direction="column" spacing={2}>
|