payment-kit 1.17.2 → 1.17.4

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.
@@ -24,6 +24,7 @@ export default flat({
24
24
  rechargeTime: '充值时间',
25
25
  submit: '提交',
26
26
  custom: '自定义',
27
+ estimatedDuration: '预计可用 {duration}',
27
28
  },
28
29
  admin: {
29
30
  balances: '余额',
@@ -445,6 +446,9 @@ export default flat({
445
446
  update: '更新订阅',
446
447
  resume: '恢复付款',
447
448
  resumeTip: '您确定要继续收款吗?此订阅的未来账单将继续付款。',
449
+ paymentAddress: '扣费地址',
450
+ currentBalance: '扣款账户余额',
451
+ insufficientBalance: '余额不足,立即充值',
448
452
  cancel: {
449
453
  schedule: '计划取消',
450
454
  title: '取消订阅',
@@ -634,34 +638,37 @@ export default flat({
634
638
  error: '授权失败',
635
639
  },
636
640
  overdraftProtection: {
637
- title: '透支保护',
638
- setting: '透支保护设置',
639
- tip: '为避免因扣费失败中断服务,您可以通过质押开启透支保护。按时付款不会收取额外费用,请及时付清账单,若可用质押不足或者超期未付,我们将从质押中扣除并收取服务费',
641
+ title: '订阅守护',
642
+ setting: '配置订阅守护',
643
+ tip: '为避免因扣费失败中断服务,您可以通过质押开启订阅守护。按时付款不会收取额外费用,请及时付清账单,若可用质押不足或者超期未付,我们将从质押中扣除并收取服务费',
640
644
  enabled: '已启用',
641
645
  disabled: '未启用',
642
646
  returnRemaining: '退还剩余质押',
643
- returnRemainingTip: '退还剩余质押后,透支保护将自动关闭,请确认操作。',
647
+ returnRemainingTip: '退还剩余质押后,订阅守护服务将自动关闭,请确认操作。',
644
648
  applyRemainingSuccess: '质押退还申请成功',
645
649
  remaining: '您当前剩余可用质押:{amount} {symbol}, 每周期预计需质押:{estimateAmount} {symbol}。',
646
- noRemaining: '当前无质押,为确保透支保护功能的正常使用,请至少质押 {estimateAmount} {symbol}。',
650
+ noRemaining: '当前无质押,为确保订阅守护服务的正常使用,请至少质押 {estimateAmount} {symbol}。',
647
651
  remainingNotEnough:
648
652
  '当前存在未支付的账单,总计 {due} {symbol},如果不支付,您当前剩余质押将无法覆盖下期账单,剩余可用质押:{unused} {symbol},请质押至少 {min} {symbol}。',
649
653
  due: '请先支付欠款',
650
- insufficient: '额度不足,下期账单将无法使用透支保护',
651
- insufficientTip: '透支保护额度不足,请尽快质押保证透支保护功能的正常使用。',
654
+ insufficient: '额度不足,下期账单将无法使用订阅守护服务, 请添加额度',
655
+ insufficientTip: '订阅守护服务额度不足,请尽快质押保证订阅守护服务的正常使用。',
652
656
  intervals: '个周期',
653
657
  estimatedDuration: '预计可用 {duration} {unit}',
654
658
  rule: '规则:N * ( P + Fee )',
655
- ruleTip: 'N 为周期数, P 为订阅账单费用, Fee 为透支保护服务费用,单次费用为 {gas} {symbol}',
659
+ ruleTip: 'N 为周期数, P 为订阅账单费用, Fee 为订阅守护服务费用,单次费用为 {gas} {symbol}',
656
660
  min: '质押金额不得小于 {min} {symbol}',
657
- settingSuccess: '透支保护设置成功',
658
- settingError: '透支保护设置失败',
661
+ settingSuccess: '订阅守护配置成功',
662
+ settingError: '订阅守护配置失败',
659
663
  keepStake: '不退还质押',
660
664
  returnStake: '退还剩余质押',
661
665
  stake: '质押',
662
666
  address: '质押账户',
663
667
  total: '总质押:{total} {symbol},',
664
668
  disableConfirm: '您当前有未支付的账单,请先付清账单。',
669
+ open: '开启订阅守护',
670
+ payerAddress: '付款账户',
671
+ stakingAddress: '质押地址',
665
672
  },
666
673
  unpaidInvoicesWarning: '您当前有未支付的账单,请先付清账单。',
667
674
  unpaidInvoicesWarningTip: '您当前有未支付的账单,请及时付清。',
@@ -156,7 +156,7 @@ export default function SubscriptionDetail(props: { id: string }) {
156
156
  md: 3,
157
157
  },
158
158
  }}>
159
- <SubscriptionMetrics subscription={data} />
159
+ <SubscriptionMetrics subscription={data} showBalance={false} />
160
160
  </Stack>
161
161
  </Box>
162
162
  <Divider />
@@ -38,6 +38,7 @@ import SubscriptionMetrics from '../../components/subscription/metrics';
38
38
  import { goBackOrFallback } from '../../libs/util';
39
39
  import CustomerLink from '../../components/customer/link';
40
40
  import { useSessionContext } from '../../contexts/session';
41
+ import { formatSmartDuration, TimeUnit } from '../../libs/dayjs';
41
42
 
42
43
  const Root = styled(Stack)(({ theme }) => ({
43
44
  marginBottom: theme.spacing(3),
@@ -73,6 +74,7 @@ export default function RechargePage() {
73
74
  const [customAmount, setCustomAmount] = useState(false);
74
75
  const [presetAmounts, setPresetAmounts] = useState<Array<{ amount: string; cycles: number }>>([]);
75
76
  const { session } = useSessionContext();
77
+ const [cycleAmount, setCycleAmount] = useState('0');
76
78
 
77
79
  const {
78
80
  paymentCurrency,
@@ -116,6 +118,11 @@ export default function RechargePage() {
116
118
  { amount: getCycleAmount(5), cycles: 5 },
117
119
  { amount: getCycleAmount(10), cycles: 10 },
118
120
  ]);
121
+
122
+ setCycleAmount(fromUnitToToken(upcomingRes.data.amount || '0', upcomingRes.data?.currency?.decimal) || '0');
123
+ if (!amount && !customAmount) {
124
+ handleSelect(getCycleAmount(10));
125
+ }
119
126
  } catch (err) {
120
127
  setError(formatError(err) || t('common.fetchError'));
121
128
  console.error(err);
@@ -124,19 +131,36 @@ export default function RechargePage() {
124
131
  }
125
132
  };
126
133
 
134
+ const rechargeRef = useRef<HTMLDivElement>(null);
135
+
127
136
  useEffect(() => {
128
137
  fetchData();
129
138
  }, [subscriptionId]);
130
139
 
140
+ useEffect(() => {
141
+ if (rechargeRef.current && subscription) {
142
+ setTimeout(() => {
143
+ // @ts-ignore
144
+ rechargeRef.current?.scrollIntoView({
145
+ behavior: 'smooth',
146
+ });
147
+ }, 200);
148
+ }
149
+ }, [subscription]);
150
+
131
151
  const handleRecharge = () => {
132
152
  if (!subscription) return;
133
153
 
154
+ if (Number.isNaN(Number(amount))) {
155
+ return;
156
+ }
157
+
134
158
  connect.open({
135
159
  containerEl: undefined as unknown as Element,
136
160
  saveConnect: false,
137
161
  action: 'recharge',
138
162
  prefix: joinURL(getPrefix(), '/api/did'),
139
- extraParams: { subscriptionId, amount },
163
+ extraParams: { subscriptionId, amount: Number(amount) },
140
164
  onSuccess: () => {
141
165
  connect.close();
142
166
  Toast.success(t('customer.recharge.success'));
@@ -154,7 +178,9 @@ export default function RechargePage() {
154
178
  const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
155
179
  const { value } = e.target;
156
180
  if (!subscription) return;
157
-
181
+ if (!/^\d*\.?\d*$/.test(value)) return;
182
+ // 不允许以小数点开头
183
+ if (value.startsWith('.')) return;
158
184
  const precision = subscription.paymentCurrency.maximum_precision || 6;
159
185
  const errorMessage = formatAmountPrecisionLimit(value, locale, precision);
160
186
  setAmountError(errorMessage || '');
@@ -198,13 +224,16 @@ export default function RechargePage() {
198
224
  const totalIntervals = cycles * intervalCount;
199
225
  const availableUnitKeys = ['hour', 'day', 'week', 'month', 'year'];
200
226
 
201
- const unitKey = availableUnitKeys.includes(interval)
202
- ? `common.${interval}${totalIntervals > 1 ? 's' : ''}`
203
- : 'customer.recharge.intervals';
204
-
205
- return t('customer.recharge.estimatedDuration', {
206
- duration: totalIntervals,
207
- unit: t(unitKey).toLowerCase(),
227
+ if (!availableUnitKeys.includes(interval)) {
228
+ return t('customer.recharge.estimatedDuration', {
229
+ duration: totalIntervals,
230
+ unit: t('customer.recharge.intervals').toLowerCase(),
231
+ });
232
+ }
233
+ return t('common.estimatedDuration', {
234
+ duration: formatSmartDuration(totalIntervals, interval as TimeUnit, {
235
+ t,
236
+ }),
208
237
  });
209
238
  };
210
239
 
@@ -248,7 +277,7 @@ export default function RechargePage() {
248
277
  alignItems: { xs: 'flex-start', sm: 'flex-start', md: 'center' },
249
278
  gap: { xs: 1, sm: 1, md: 3 },
250
279
  }}>
251
- <SubscriptionMetrics subscription={subscription} />
280
+ <SubscriptionMetrics subscription={subscription} showBalance={false} />
252
281
  </Stack>
253
282
  </Box>
254
283
 
@@ -292,7 +321,7 @@ export default function RechargePage() {
292
321
  </Box>
293
322
  <Divider />
294
323
 
295
- <Box sx={{ maxWidth: 600 }}>
324
+ <Box sx={{ maxWidth: 600 }} ref={rechargeRef}>
296
325
  <Typography variant="h2" gutterBottom>
297
326
  {t('customer.recharge.title')}
298
327
  </Typography>
@@ -389,22 +418,29 @@ export default function RechargePage() {
389
418
  </Paper>
390
419
 
391
420
  {customAmount && (
392
- <TextField
393
- fullWidth
394
- label={t('customer.recharge.amount')}
395
- variant="outlined"
396
- type="number"
397
- value={amount}
398
- error={!!amountError}
399
- helperText={amountError}
400
- onChange={handleAmountChange}
401
- InputProps={{
402
- endAdornment: <Typography>{subscription.paymentCurrency.symbol}</Typography>,
403
- autoComplete: 'off',
404
- }}
405
- sx={{ mt: 2, mb: 2 }}
406
- inputRef={customInputRef}
407
- />
421
+ <Box>
422
+ <TextField
423
+ fullWidth
424
+ label={t('customer.recharge.amount')}
425
+ variant="outlined"
426
+ type="text"
427
+ value={amount}
428
+ error={!!amountError}
429
+ helperText={amountError}
430
+ onChange={handleAmountChange}
431
+ InputProps={{
432
+ endAdornment: <Typography>{subscription.paymentCurrency.symbol}</Typography>,
433
+ autoComplete: 'off',
434
+ }}
435
+ sx={{ mt: 1 }}
436
+ inputRef={customInputRef}
437
+ />
438
+ {amount && Number(amount) > 0 && Number(cycleAmount) > 0 && !amountError && (
439
+ <Typography variant="body2" sx={{ color: 'text.lighter', mt: '8px !important' }} fontSize={12}>
440
+ {formatEstimatedDuration(Math.floor(Number(amount) / Number(cycleAmount)))}
441
+ </Typography>
442
+ )}
443
+ </Box>
408
444
  )}
409
445
 
410
446
  <Button
@@ -1,24 +1,34 @@
1
1
  /* eslint-disable react/no-unstable-nested-components */
2
2
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
- import { CustomerInvoiceList, TxLink, api, formatTime, hasDelegateTxHash, useMobile } from '@blocklet/payment-react';
4
- import type { TSubscriptionExpanded } from '@blocklet/payment-types';
5
- import { ArrowBackOutlined } from '@mui/icons-material';
6
- import { Alert, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
3
+ import {
4
+ CustomerInvoiceList,
5
+ TxLink,
6
+ api,
7
+ formatBNStr,
8
+ formatTime,
9
+ hasDelegateTxHash,
10
+ useMobile,
11
+ } from '@blocklet/payment-react';
12
+ import type { TPaymentCurrency, TSubscriptionExpanded } from '@blocklet/payment-types';
13
+ import { ArrowBackOutlined, CheckCircle } from '@mui/icons-material';
14
+ import { Alert, Avatar, Box, Button, CircularProgress, Divider, Stack, Typography } from '@mui/material';
7
15
  import { useRequest } from 'ahooks';
8
16
  import { Link, useNavigate, useParams } from 'react-router-dom';
9
17
  import { styled } from '@mui/system';
10
- import { BN } from '@ocap/util';
18
+ import { BN, fromUnitToToken } from '@ocap/util';
19
+ import { useCallback, useRef } from 'react';
11
20
  import Currency from '../../../components/currency';
12
21
  import CustomerLink from '../../../components/customer/link';
13
22
  import InfoRow from '../../../components/info-row';
14
23
  import SubscriptionDescription from '../../../components/subscription/description';
15
24
  import SubscriptionItemList from '../../../components/subscription/items';
16
25
  import SubscriptionMetrics from '../../../components/subscription/metrics';
17
- import SubscriptionActions from '../../../components/subscription/portal/actions';
26
+ import SubscriptionActions, { ActionMethods } from '../../../components/subscription/portal/actions';
18
27
  import { canChangePaymentMethod } from '../../../libs/util';
19
28
  import { useSessionContext } from '../../../contexts/session';
20
29
  import InfoMetric from '../../../components/info-metric';
21
30
  import { useUnpaidInvoicesCheckForSubscription } from '../../../hooks/subscription';
31
+ import { formatSmartDuration, TimeUnit } from '../../../libs/dayjs';
22
32
 
23
33
  const fetchData = (id: string | undefined): Promise<TSubscriptionExpanded> => {
24
34
  return api.get(`/api/subscriptions/${id}`).then((res) => res.data);
@@ -29,6 +39,14 @@ const fetchOverdraftProtection = (
29
39
  ): Promise<{ enabled: boolean; remaining: string; unused: string; upcoming: { amount: string }; gas: string }> => {
30
40
  return api.get(`/api/subscriptions/${id}/overdraft-protection`).then((res) => res.data);
31
41
  };
42
+
43
+ const fetchCycleAmount = (
44
+ subscriptionId: string,
45
+ params: { overdraftProtection: boolean }
46
+ ): Promise<{ amount: string; gas: string; currency: TPaymentCurrency }> => {
47
+ return api.get(`/api/subscriptions/${subscriptionId}/cycle-amount`, { params }).then((res) => res.data);
48
+ };
49
+
32
50
  const InfoDirection = 'column';
33
51
  const InfoAlignItems = 'flex-start';
34
52
 
@@ -48,8 +66,29 @@ export default function CustomerSubscriptionDetail() {
48
66
  ready: ['active', 'trialing', 'past_due'].includes(data?.status || ''),
49
67
  });
50
68
 
69
+ const {
70
+ data: cycleAmount = {
71
+ amount: '0',
72
+ gas: '0',
73
+ },
74
+ } = useRequest(
75
+ () =>
76
+ fetchCycleAmount(id, {
77
+ overdraftProtection: true,
78
+ }),
79
+ {
80
+ refreshDeps: [id],
81
+ }
82
+ );
83
+
84
+ const actionRef = useRef<ActionMethods>();
85
+
51
86
  const enableOverdraftProtection = !!overdraftProtection?.enabled;
52
87
 
88
+ const actionSetUp = useCallback((methods: ActionMethods) => {
89
+ actionRef.current = methods;
90
+ }, []);
91
+
53
92
  if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
54
93
  return <Alert severity="error">You do not have permission to access other customer data</Alert>;
55
94
  }
@@ -75,13 +114,116 @@ export default function CustomerSubscriptionDetail() {
75
114
  new BN(overdraftProtection.unused).lt(
76
115
  new BN(overdraftProtection.upcoming?.amount).add(new BN(overdraftProtection.gas))
77
116
  );
117
+ const estimateAmount = +fromUnitToToken(cycleAmount.amount, data.paymentCurrency?.decimal);
118
+ const remainingStake = +fromUnitToToken(overdraftProtection?.unused, data.paymentCurrency?.decimal);
119
+ const formatEstimatedDuration = (cycles: number) => {
120
+ if (!data?.pending_invoice_item_interval) return '';
121
+ const { interval, interval_count: intervalCount } = data.pending_invoice_item_interval;
122
+ const totalIntervals = cycles * intervalCount;
123
+ const availableUnitKeys = ['hour', 'day', 'week', 'month', 'year'];
124
+
125
+ if (!availableUnitKeys.includes(interval)) {
126
+ return t('customer.overdraftProtection.estimatedDuration', {
127
+ duration: totalIntervals,
128
+ unit: t('customer.overdraftProtection.intervals').toLowerCase(),
129
+ });
130
+ }
131
+ return t('common.estimatedDuration', {
132
+ duration: formatSmartDuration(totalIntervals, interval as TimeUnit, {
133
+ t,
134
+ }),
135
+ });
136
+ };
78
137
  if (!enabled) {
79
- return t('customer.overdraftProtection.disabled');
138
+ return (
139
+ <Stack direction="row" spacing={1} alignItems="center">
140
+ <Typography sx={{ color: 'text.lighter' }}>{t('customer.overdraftProtection.disabled')}</Typography>
141
+ <Button
142
+ size="small"
143
+ sx={{
144
+ fontSize: '13px',
145
+ color: 'text.link',
146
+ '&:hover': { backgroundColor: 'primary.lighter' },
147
+ }}
148
+ onClick={() =>
149
+ actionRef.current?.openOverdraftProtection({
150
+ enabled: true,
151
+ })
152
+ }>
153
+ {t('customer.overdraftProtection.open')}
154
+ </Button>
155
+ </Stack>
156
+ );
80
157
  }
158
+
81
159
  if (enabled && insufficient) {
82
- return <Typography color="error">{t('customer.overdraftProtection.insufficient')}</Typography>;
160
+ return (
161
+ <Button
162
+ size="small"
163
+ color="error"
164
+ sx={{
165
+ fontSize: '12px',
166
+ textAlign: 'left',
167
+ }}
168
+ onClick={() =>
169
+ actionRef.current?.openOverdraftProtection({
170
+ enabled: true,
171
+ })
172
+ }>
173
+ {t('customer.overdraftProtection.insufficient')}
174
+ </Button>
175
+ );
83
176
  }
84
- return t('customer.overdraftProtection.enabled');
177
+
178
+ return (
179
+ <Stack direction="row" spacing={1} alignItems="center">
180
+ <Stack direction="row" spacing={0.5} alignItems="center">
181
+ <CheckCircle
182
+ sx={{
183
+ fontSize: '16px',
184
+ color: 'success.main',
185
+ verticalAlign: 'middle',
186
+ }}
187
+ />
188
+ <Typography
189
+ sx={{
190
+ color: 'success.main',
191
+ fontWeight: 500,
192
+ }}>
193
+ {t('customer.overdraftProtection.enabled')}
194
+ </Typography>
195
+ </Stack>
196
+ <Divider
197
+ orientation="vertical"
198
+ flexItem
199
+ sx={{
200
+ mx: 1,
201
+ borderColor: 'divider',
202
+ }}
203
+ />
204
+ <Typography
205
+ sx={{
206
+ color: 'text.primary',
207
+ display: 'flex',
208
+ alignItems: 'center',
209
+ gap: 0.5,
210
+ fontWeight: 500,
211
+ }}>
212
+ <Avatar src={data.paymentCurrency?.logo} sx={{ width: 16, height: 16 }} alt={data.paymentCurrency?.name} />
213
+ <Box display="flex" alignItems="baseline">
214
+ {formatBNStr(overdraftProtection?.unused, data.paymentCurrency.decimal)}
215
+ <Typography
216
+ sx={{
217
+ color: 'text.secondary',
218
+ fontSize: '14px',
219
+ ml: 0.5,
220
+ }}>
221
+ {data.paymentCurrency.symbol}({formatEstimatedDuration(Math.ceil(remainingStake / estimateAmount))})
222
+ </Typography>
223
+ </Box>
224
+ </Typography>
225
+ </Stack>
226
+ );
85
227
  };
86
228
 
87
229
  return (
@@ -119,7 +261,7 @@ export default function CustomerSubscriptionDetail() {
119
261
  onChange: () => refreshOverdraftProtection(),
120
262
  }}
121
263
  showRecharge
122
- mode={isMobile ? 'menu' : 'btn'}
264
+ mode={isMobile ? 'menu-only' : 'primary-buttons'}
123
265
  actionProps={{
124
266
  cancel: {
125
267
  variant: 'outlined',
@@ -134,6 +276,7 @@ export default function CustomerSubscriptionDetail() {
134
276
  color: 'primary',
135
277
  },
136
278
  }}
279
+ setUp={actionSetUp}
137
280
  />
138
281
  </Stack>
139
282
  </Stack>
@@ -78,6 +78,7 @@ export default function SubscriptionEmbed() {
78
78
  ignore_zero: true,
79
79
  include_staking: true,
80
80
  customer_id: subscription?.customer_id,
81
+ include_overdraft_protection: false,
81
82
  }),
82
83
  {
83
84
  refreshDeps: [subscriptionId, authToken, subscription?.customer_id],