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.
Files changed (116) hide show
  1. package/api/src/index.ts +3 -0
  2. package/api/src/libs/credit-utils.ts +21 -0
  3. package/api/src/libs/discount/discount.ts +13 -0
  4. package/api/src/libs/env.ts +5 -0
  5. package/api/src/libs/error.ts +14 -0
  6. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  7. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  8. package/api/src/libs/exchange-rate/index.ts +5 -0
  9. package/api/src/libs/exchange-rate/service.ts +583 -0
  10. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  11. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  12. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  13. package/api/src/libs/exchange-rate/types.ts +114 -0
  14. package/api/src/libs/exchange-rate/validator.ts +319 -0
  15. package/api/src/libs/invoice-quote.ts +158 -0
  16. package/api/src/libs/invoice.ts +143 -7
  17. package/api/src/libs/math-utils.ts +46 -0
  18. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  19. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  20. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  21. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  22. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  23. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  24. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  25. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  26. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  27. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  28. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  29. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  30. package/api/src/libs/payment.ts +3 -1
  31. package/api/src/libs/price.ts +4 -1
  32. package/api/src/libs/queue/index.ts +8 -0
  33. package/api/src/libs/quote-service.ts +1132 -0
  34. package/api/src/libs/quote-validation.ts +388 -0
  35. package/api/src/libs/session.ts +686 -39
  36. package/api/src/libs/slippage.ts +135 -0
  37. package/api/src/libs/subscription.ts +185 -15
  38. package/api/src/libs/util.ts +64 -3
  39. package/api/src/locales/en.ts +50 -0
  40. package/api/src/locales/zh.ts +48 -0
  41. package/api/src/queues/auto-recharge.ts +295 -21
  42. package/api/src/queues/exchange-rate-health.ts +242 -0
  43. package/api/src/queues/invoice.ts +48 -1
  44. package/api/src/queues/notification.ts +167 -1
  45. package/api/src/queues/payment.ts +177 -7
  46. package/api/src/queues/refund.ts +41 -9
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/payment-links.ts +13 -0
  66. package/api/src/routes/prices.ts +84 -2
  67. package/api/src/routes/subscriptions.ts +526 -15
  68. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  69. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  70. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  71. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  72. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  73. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  74. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  75. package/api/src/store/models/auto-recharge-config.ts +12 -0
  76. package/api/src/store/models/checkout-session.ts +7 -0
  77. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  78. package/api/src/store/models/index.ts +6 -0
  79. package/api/src/store/models/payment-intent.ts +6 -0
  80. package/api/src/store/models/price-quote.ts +284 -0
  81. package/api/src/store/models/price.ts +53 -5
  82. package/api/src/store/models/subscription.ts +11 -0
  83. package/api/src/store/models/types.ts +61 -1
  84. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  85. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  86. package/api/tests/libs/quote-service.spec.ts +199 -0
  87. package/api/tests/libs/session.spec.ts +464 -0
  88. package/api/tests/libs/slippage.spec.ts +109 -0
  89. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  90. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  91. package/api/tests/models/price-dynamic.spec.ts +100 -0
  92. package/api/tests/models/price-quote.spec.ts +112 -0
  93. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  94. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  95. package/blocklet.yml +1 -1
  96. package/package.json +7 -6
  97. package/src/components/customer/credit-overview.tsx +14 -0
  98. package/src/components/discount/discount-info.tsx +8 -2
  99. package/src/components/invoice/list.tsx +146 -16
  100. package/src/components/invoice/table.tsx +276 -71
  101. package/src/components/invoice-pdf/template.tsx +3 -7
  102. package/src/components/metadata/form.tsx +6 -8
  103. package/src/components/price/form.tsx +519 -149
  104. package/src/components/promotion/active-redemptions.tsx +5 -3
  105. package/src/components/quote/info.tsx +234 -0
  106. package/src/hooks/subscription.ts +132 -2
  107. package/src/locales/en.tsx +145 -0
  108. package/src/locales/zh.tsx +143 -1
  109. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  110. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  111. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  112. package/src/pages/admin/products/index.tsx +12 -1
  113. package/src/pages/customer/invoice/detail.tsx +36 -12
  114. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  115. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  116. 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, useRef } from 'react';
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
- <Tooltip
314
- title={data.status === 'void' ? t('payment.customer.invoice.noPaymentRequired') : ''}
315
- arrow
316
- placement="top">
317
- <span>
318
- <Status label={data.status} color={getInvoiceStatusColor(data.status)} />
319
- </span>
320
- </Tooltip>
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}>