payment-kit 1.15.34 → 1.15.35

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 (36) hide show
  1. package/api/src/libs/notification/template/subscription-canceled.ts +4 -0
  2. package/api/src/libs/refund.ts +4 -0
  3. package/api/src/libs/subscription.ts +25 -0
  4. package/api/src/queues/subscription.ts +2 -2
  5. package/api/src/routes/checkout-sessions.ts +2 -2
  6. package/api/src/routes/connect/recharge.ts +28 -3
  7. package/api/src/routes/connect/shared.ts +88 -0
  8. package/api/src/routes/customers.ts +2 -2
  9. package/api/src/routes/invoices.ts +5 -1
  10. package/api/src/routes/payment-links.ts +3 -0
  11. package/api/src/routes/refunds.ts +22 -1
  12. package/api/src/routes/subscriptions.ts +47 -5
  13. package/api/src/routes/webhook-attempts.ts +14 -1
  14. package/api/src/store/models/invoice.ts +2 -1
  15. package/blocklet.yml +1 -1
  16. package/package.json +4 -4
  17. package/src/app.tsx +3 -1
  18. package/src/components/invoice/list.tsx +40 -11
  19. package/src/components/invoice/recharge.tsx +244 -0
  20. package/src/components/payment-intent/actions.tsx +2 -1
  21. package/src/components/payment-link/actions.tsx +6 -6
  22. package/src/components/payment-link/item.tsx +53 -18
  23. package/src/components/pricing-table/actions.tsx +14 -3
  24. package/src/components/refund/actions.tsx +43 -1
  25. package/src/components/refund/list.tsx +1 -1
  26. package/src/components/subscription/portal/actions.tsx +22 -1
  27. package/src/components/subscription/portal/list.tsx +1 -0
  28. package/src/components/webhook/attempts.tsx +19 -121
  29. package/src/components/webhook/request-info.tsx +139 -0
  30. package/src/locales/en.tsx +4 -0
  31. package/src/locales/zh.tsx +8 -0
  32. package/src/pages/admin/payments/refunds/detail.tsx +2 -2
  33. package/src/pages/admin/products/links/create.tsx +4 -1
  34. package/src/pages/customer/invoice/detail.tsx +6 -0
  35. package/src/pages/customer/recharge.tsx +45 -35
  36. package/src/pages/customer/subscription/detail.tsx +8 -18
@@ -0,0 +1,244 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import {
4
+ Status,
5
+ api,
6
+ formatBNStr,
7
+ getInvoiceStatusColor,
8
+ Table,
9
+ useDefaultPageSize,
10
+ getInvoiceDescriptionAndReason,
11
+ getTxLink,
12
+ formatToDate,
13
+ } from '@blocklet/payment-react';
14
+ import type { TInvoiceExpanded } from '@blocklet/payment-types';
15
+ import { CircularProgress, Typography, Box } from '@mui/material';
16
+ import { useLocalStorageState } from 'ahooks';
17
+ import { useEffect, useState } from 'react';
18
+ import { styled } from '@mui/system';
19
+
20
+ const fetchData = (
21
+ subscriptionId: string,
22
+ params: Record<string, any> = {}
23
+ ): Promise<{ list: TInvoiceExpanded[]; count: number }> => {
24
+ const search = new URLSearchParams();
25
+ Object.keys(params).forEach((key) => {
26
+ let v = params[key];
27
+ if (key === 'q') {
28
+ v = Object.entries(v)
29
+ .map((x) => x.join(':'))
30
+ .join(' ');
31
+ }
32
+ search.set(key, String(v));
33
+ });
34
+
35
+ return api.get(`/api/subscriptions/${subscriptionId}/recharge?${search.toString()}`).then((res) => res.data);
36
+ };
37
+
38
+ type SearchProps = {
39
+ status?: string;
40
+ pageSize: number;
41
+ page: number;
42
+ currency_id?: string;
43
+ subscription_id?: string;
44
+ q?: any;
45
+ o?: string;
46
+ };
47
+
48
+ type ListProps = {
49
+ features?: {
50
+ customer?: boolean;
51
+ toolbar?: boolean;
52
+ filter?: boolean;
53
+ footer?: boolean;
54
+ };
55
+ currency_id?: string;
56
+ subscription_id?: string;
57
+ };
58
+
59
+ const getListKey = (props: ListProps) => {
60
+ if (props.subscription_id) {
61
+ return `subscription-recharge-${props.subscription_id}`;
62
+ }
63
+ return 'invoices';
64
+ };
65
+
66
+ RechargeList.defaultProps = {
67
+ features: {
68
+ customer: true,
69
+ filter: true,
70
+ },
71
+ currency_id: '',
72
+ subscription_id: '',
73
+ };
74
+
75
+ export default function RechargeList({ currency_id, subscription_id, features }: ListProps) {
76
+ const listKey = getListKey({ currency_id, subscription_id });
77
+
78
+ const { t, locale } = useLocaleContext();
79
+ const defaultPageSize = useDefaultPageSize(20);
80
+ const [search, setSearch] = useLocalStorageState<SearchProps>(listKey, {
81
+ defaultValue: {
82
+ currency_id,
83
+ pageSize: defaultPageSize,
84
+ page: 1,
85
+ },
86
+ });
87
+
88
+ const [data, setData] = useState({}) as any;
89
+
90
+ const refresh = () =>
91
+ fetchData(subscription_id!, {
92
+ ...search,
93
+ }).then((res: any) => {
94
+ setData(res);
95
+ });
96
+
97
+ useEffect(() => {
98
+ refresh();
99
+ }, [search]);
100
+
101
+ if (!data.list) {
102
+ return <CircularProgress />;
103
+ }
104
+
105
+ const getInvoiceLink = (invoice: TInvoiceExpanded) => {
106
+ return {
107
+ external: true,
108
+ connect: false,
109
+ url: getTxLink(invoice.paymentMethod, invoice.metadata?.payment_details).link,
110
+ };
111
+ };
112
+
113
+ const columns = [
114
+ {
115
+ label: t('common.amount'),
116
+ name: 'total',
117
+ width: 80,
118
+ align: 'right',
119
+ options: {
120
+ customBodyRenderLite: (_: string, index: number) => {
121
+ const invoice = data?.list[index] as TInvoiceExpanded;
122
+ const link = getInvoiceLink(invoice);
123
+ return (
124
+ <a href={link.url} target="_blank" rel="noreferrer">
125
+ <Typography>
126
+ {formatBNStr(invoice.total, invoice.paymentCurrency.decimal)}&nbsp;
127
+ {invoice.paymentCurrency.symbol}
128
+ </Typography>
129
+ </a>
130
+ );
131
+ },
132
+ },
133
+ },
134
+ {
135
+ label: t('payment.customer.invoice.invoiceNumber'),
136
+ name: 'number',
137
+ options: {
138
+ customBodyRenderLite: (_: string, index: number) => {
139
+ const invoice = data?.list[index] as TInvoiceExpanded;
140
+ const link = getInvoiceLink(invoice);
141
+ return (
142
+ <a href={link.url} target="_blank" rel="noreferrer">
143
+ {invoice?.number}
144
+ </a>
145
+ );
146
+ },
147
+ },
148
+ },
149
+ {
150
+ label: t('common.rechargeTime'),
151
+ name: 'name',
152
+ options: {
153
+ customBodyRenderLite: (_: string, index: number) => {
154
+ const invoice = data?.list[index] as TInvoiceExpanded;
155
+ const link = getInvoiceLink(invoice);
156
+ return (
157
+ <a href={link.url} target="_blank" rel="noreferrer">
158
+ {formatToDate(invoice.created_at, locale, 'YYYY-MM-DD HH:mm:ss')}
159
+ </a>
160
+ );
161
+ },
162
+ },
163
+ },
164
+ {
165
+ label: t('common.description'),
166
+ name: '',
167
+ options: {
168
+ sort: false,
169
+ customBodyRenderLite: (_: string, index: number) => {
170
+ const invoice = data?.list[index] as TInvoiceExpanded;
171
+ const link = getInvoiceLink(invoice);
172
+ return (
173
+ <a href={link.url} target="_blank" rel="noreferrer">
174
+ {getInvoiceDescriptionAndReason(invoice, locale)?.description || invoice.id}
175
+ </a>
176
+ );
177
+ },
178
+ },
179
+ },
180
+ {
181
+ label: t('common.status'),
182
+ name: 'status',
183
+ options: {
184
+ customBodyRenderLite: (_: string, index: number) => {
185
+ const invoice = data?.list[index] as TInvoiceExpanded;
186
+ const link = getInvoiceLink(invoice);
187
+ return (
188
+ <a href={link.url} target="_blank" rel="noreferrer">
189
+ <Status label={invoice.status} color={getInvoiceStatusColor(invoice.status)} />
190
+ </a>
191
+ );
192
+ },
193
+ },
194
+ },
195
+ ];
196
+
197
+ const onTableChange = ({ page, rowsPerPage }: any) => {
198
+ if (search!.pageSize !== rowsPerPage) {
199
+ setSearch((x) => ({ ...x, pageSize: rowsPerPage, page: 1 }));
200
+ } else if (search!.page !== page + 1) {
201
+ // @ts-ignore
202
+ setSearch((x) => ({ ...x, page: page + 1 }));
203
+ }
204
+ };
205
+
206
+ return (
207
+ <InvoiceTableRoot>
208
+ <Table
209
+ hasRowLink
210
+ data={data.list}
211
+ durable={`__${listKey}__`}
212
+ durableKeys={['searchText']}
213
+ columns={columns}
214
+ loading={!data.list}
215
+ onChange={onTableChange}
216
+ mobileTDFlexDirection="row"
217
+ options={{
218
+ count: data.count,
219
+ page: search!.page - 1,
220
+ rowsPerPage: search!.pageSize,
221
+ }}
222
+ toolbar={false}
223
+ showMobile={false}
224
+ footer={features?.footer}
225
+ emptyNodeText={`${t('empty.invoices')}`}
226
+ />
227
+ </InvoiceTableRoot>
228
+ );
229
+ }
230
+
231
+ const InvoiceTableRoot = styled(Box)`
232
+ @media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) {
233
+ .MuiTable-root > .MuiTableBody-root > .MuiTableRow-root > td.MuiTableCell-root {
234
+ > div {
235
+ width: fit-content;
236
+ flex: inherit;
237
+ font-size: 14px;
238
+ }
239
+ }
240
+ .invoice-summary {
241
+ padding-right: 20px;
242
+ }
243
+ }
244
+ `;
@@ -187,6 +187,7 @@ export function PaymentIntentActionsInner({ data, variant, onChange }: Props) {
187
187
  const amount = formatBNStr(res?.amount, data.paymentCurrency.decimal);
188
188
  setRefundMaxAmount(amount);
189
189
  },
190
+ manual: true,
190
191
  }
191
192
  );
192
193
 
@@ -247,7 +248,7 @@ export function PaymentIntentActionsInner({ data, variant, onChange }: Props) {
247
248
 
248
249
  return (
249
250
  <ClickBoundary>
250
- <Actions variant={variant} actions={actions} />
251
+ <Actions variant={variant} actions={actions} onOpenCallback={runRefundAmountAsync} />
251
252
  {state.action === 'refund' && (
252
253
  <ConfirmDialog
253
254
  onConfirm={handleSubmit(onRefund)}
@@ -87,12 +87,12 @@ export default function PaymentLinkActions({ data, variant, onChange }: Props) {
87
87
  handler: onOpenLink,
88
88
  color: 'primary',
89
89
  },
90
- {
91
- label: t('admin.paymentLink.edit'),
92
- handler: () => setState({ action: 'edit' }),
93
- color: 'primary',
94
- disabled: true,
95
- },
90
+ // {
91
+ // label: t('admin.paymentLink.edit'),
92
+ // handler: () => setState({ action: 'edit' }),
93
+ // color: 'primary',
94
+ // disabled: true,
95
+ // },
96
96
  {
97
97
  label: t('admin.paymentLink.copyLink'),
98
98
  handler: onCopyLink,
@@ -26,6 +26,10 @@ export default function LineItem({ prefix, product, valid, onUpdate, onRemove }:
26
26
  const { control, setValue } = useFormContext();
27
27
  const [state, setState] = useSetState({ editing: false, loading: false });
28
28
  const adjustable = useWatch({ control, name: getFieldName('adjustable_quantity.enabled') });
29
+ const minQuantity = useWatch({ control, name: getFieldName('adjustable_quantity.minimum') });
30
+ const maxQuantity = useWatch({ control, name: getFieldName('adjustable_quantity.maximum') });
31
+
32
+ const adjustableError = Number(minQuantity) >= Number(maxQuantity);
29
33
 
30
34
  const onSave = async (updates: TProduct) => {
31
35
  try {
@@ -110,24 +114,55 @@ export default function LineItem({ prefix, product, valid, onUpdate, onRemove }:
110
114
  )}
111
115
  />
112
116
  {adjustable && (
113
- <Stack direction="row" alignItems="center" mt={1} ml={6}>
114
- <Typography sx={{ mr: 0.5 }}>Between</Typography>
115
- <Controller
116
- name={getFieldName('adjustable_quantity.minimum')}
117
- control={control}
118
- render={({ field }) => (
119
- <TextField sx={{ width: 40 }} inputProps={{ style: { padding: '4px 8px' } }} size="small" {...field} />
120
- )}
121
- />
122
- <Typography sx={{ mx: 0.5 }}>and</Typography>
123
- <Controller
124
- name={getFieldName('adjustable_quantity.maximum')}
125
- control={control}
126
- render={({ field }) => (
127
- <TextField sx={{ width: 40 }} inputProps={{ style: { padding: '4px 8px' } }} size="small" {...field} />
128
- )}
129
- />
130
- </Stack>
117
+ <>
118
+ <Stack direction="row" alignItems="center" mt={1} ml={6}>
119
+ <Typography sx={{ mr: 0.5 }}>Between</Typography>
120
+ <Controller
121
+ name={getFieldName('adjustable_quantity.minimum')}
122
+ control={control}
123
+ rules={{
124
+ validate: (value) => {
125
+ return !maxQuantity || Number(value) < Number(maxQuantity) || 'Minimum must be less than maximum';
126
+ },
127
+ }}
128
+ render={({ field }) => (
129
+ <TextField
130
+ sx={{ width: 40 }}
131
+ inputProps={{ style: { padding: '4px 8px' } }}
132
+ size="small"
133
+ error={!!adjustableError}
134
+ {...field}
135
+ />
136
+ )}
137
+ />
138
+ <Typography sx={{ mx: 0.5 }}>and</Typography>
139
+ <Controller
140
+ name={getFieldName('adjustable_quantity.maximum')}
141
+ control={control}
142
+ rules={{
143
+ validate: (value) => {
144
+ return (
145
+ !minQuantity || Number(value) > Number(minQuantity) || 'Maximum must be greater than minimum'
146
+ );
147
+ },
148
+ }}
149
+ render={({ field }) => (
150
+ <TextField
151
+ sx={{ width: 40 }}
152
+ inputProps={{ style: { padding: '4px 8px' } }}
153
+ size="small"
154
+ error={!!adjustableError}
155
+ {...field}
156
+ />
157
+ )}
158
+ />
159
+ </Stack>
160
+ {adjustableError && (
161
+ <Typography sx={{ mt: 0.5, ml: 6 }} color="error">
162
+ {t('admin.paymentLink.adjustableQuantityError')}
163
+ </Typography>
164
+ )}
165
+ </>
131
166
  )}
132
167
  </Stack>
133
168
  {state.editing && (
@@ -73,17 +73,28 @@ export default function PricingTableActions({ data, variant, onChange }: Props)
73
73
  Toast.success(t('common.copied'));
74
74
  };
75
75
 
76
+ const onOpenLink = () => {
77
+ window.open(
78
+ joinURL(window.blocklet.appUrl, window.blocklet.prefix, `/checkout/pricing-table/${data.id}`),
79
+ '_blank'
80
+ );
81
+ };
76
82
  return (
77
83
  <ClickBoundary>
78
84
  <Actions
79
85
  variant={variant}
80
86
  actions={[
81
87
  {
82
- label: t('admin.pricingTable.edit'),
83
- handler: () => setState({ action: 'edit' }),
88
+ label: t('admin.pricingTable.openLink'),
89
+ handler: onOpenLink,
84
90
  color: 'primary',
85
- disabled: true,
86
91
  },
92
+ // {
93
+ // label: t('admin.pricingTable.edit'),
94
+ // handler: () => setState({ action: 'edit' }),
95
+ // color: 'primary',
96
+ // disabled: true,
97
+ // },
87
98
  {
88
99
  label: t('admin.pricingTable.copyLink'),
89
100
  handler: onCopyLink,
@@ -3,21 +3,31 @@ import type { TRefundExpanded } from '@blocklet/payment-types';
3
3
  import { useNavigate } from 'react-router-dom';
4
4
  import type { LiteralUnion } from 'type-fest';
5
5
 
6
+ import { api, ConfirmDialog, formatError } from '@blocklet/payment-react';
7
+ import { useSetState } from 'ahooks';
8
+ import Toast from '@arcblock/ux/lib/Toast';
6
9
  import Actions from '../actions';
7
10
  import ClickBoundary from '../click-boundary';
8
11
 
9
12
  type Props = {
10
13
  data: TRefundExpanded;
14
+ onChange?: (action: string) => void;
11
15
  variant?: LiteralUnion<'compact' | 'normal', string>;
12
16
  };
13
17
 
14
18
  RefundActions.defaultProps = {
15
19
  variant: 'compact',
20
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
21
+ onChange: () => {},
16
22
  };
17
23
 
18
- export default function RefundActions({ data, variant }: Props) {
24
+ export default function RefundActions({ data, variant, onChange }: Props) {
19
25
  const { t } = useLocaleContext();
20
26
  const navigate = useNavigate();
27
+ const [state, setState] = useSetState({
28
+ action: '',
29
+ loading: false,
30
+ });
21
31
 
22
32
  const actions = [
23
33
  {
@@ -35,10 +45,42 @@ export default function RefundActions({ data, variant }: Props) {
35
45
  disabled: false,
36
46
  });
37
47
  }
48
+ if (!['succeeded', 'canceled'].includes(data.status)) {
49
+ actions.push({
50
+ label: t('admin.paymentIntent.cancelRefund'),
51
+ handler: () => setState({ action: 'cancel' }),
52
+ color: 'error',
53
+ disabled: false,
54
+ });
55
+ }
56
+
57
+ const onCancel = async () => {
58
+ try {
59
+ setState({ loading: true });
60
+ await api.post(`/api/refunds/${data?.id}/cancel`);
61
+ Toast.success(t('admin.paymentIntent.refundCanceled'));
62
+ // @ts-ignore
63
+ onChange(state.action);
64
+ } catch (err) {
65
+ console.error(err);
66
+ Toast.error(formatError(err));
67
+ } finally {
68
+ setState({ loading: false, action: '' });
69
+ }
70
+ };
38
71
 
39
72
  return (
40
73
  <ClickBoundary>
41
74
  <Actions variant={variant} actions={actions} />
75
+ {state.action === 'cancel' && (
76
+ <ConfirmDialog
77
+ onConfirm={onCancel}
78
+ onCancel={() => setState({ action: '' })}
79
+ title={t('admin.paymentIntent.cancelRefund')}
80
+ message={t('admin.paymentIntent.refundCanceledTip')}
81
+ loading={state.loading}
82
+ />
83
+ )}
42
84
  </ClickBoundary>
43
85
  );
44
86
  }
@@ -222,7 +222,7 @@ export default function RefundList({
222
222
  options: {
223
223
  customBodyRenderLite: (_: string, index: number) => {
224
224
  const item = data.list[index] as TRefundExpanded;
225
- return <RefundActions data={item} />;
225
+ return <RefundActions data={item} onChange={() => refresh()} />;
226
226
  },
227
227
  },
228
228
  },
@@ -26,12 +26,14 @@ type ActionProps = {
26
26
  type Props = {
27
27
  subscription: TSubscriptionExpanded;
28
28
  showExtra?: boolean;
29
+ showRecharge?: boolean;
29
30
  onChange?: (action?: string) => any | Promise<any>;
30
31
  actionProps?: ActionProps;
31
32
  };
32
33
 
33
34
  SubscriptionActions.defaultProps = {
34
35
  showExtra: false,
36
+ showRecharge: false,
35
37
  onChange: null,
36
38
  actionProps: {},
37
39
  };
@@ -67,7 +69,14 @@ const fetchExtraActions = async ({
67
69
  return { changePlan, batchPay };
68
70
  };
69
71
 
70
- export function SubscriptionActionsInner({ subscription, showExtra, onChange, actionProps }: Props) {
72
+ const supportRecharge = (subscription: TSubscriptionExpanded) => {
73
+ return (
74
+ ['active', 'trialing', 'past_due'].includes(subscription?.status) &&
75
+ ['arcblock', 'ethereum'].includes(subscription?.paymentMethod?.type)
76
+ );
77
+ };
78
+
79
+ export function SubscriptionActionsInner({ subscription, showExtra, showRecharge, onChange, actionProps }: Props) {
71
80
  const { t, locale } = useLocaleContext();
72
81
  const { reset, getValues } = useFormContext();
73
82
  const navigate = useNavigate();
@@ -121,6 +130,17 @@ export function SubscriptionActionsInner({ subscription, showExtra, onChange, ac
121
130
 
122
131
  return (
123
132
  <Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
133
+ {showRecharge && supportRecharge(subscription) && (
134
+ <Button
135
+ variant="outlined"
136
+ color="primary"
137
+ onClick={(e) => {
138
+ e.stopPropagation();
139
+ navigate(`/customer/subscription/${subscription.id}/recharge`);
140
+ }}>
141
+ {t('customer.recharge.title')}
142
+ </Button>
143
+ )}
124
144
  {!extraActions?.batchPay && action && (
125
145
  <Button
126
146
  variant={action.variant as any}
@@ -233,6 +253,7 @@ export default function SubscriptionActions(props: Props) {
233
253
 
234
254
  SubscriptionActionsInner.defaultProps = {
235
255
  showExtra: false,
256
+ showRecharge: false,
236
257
  onChange: null,
237
258
  actionProps: {},
238
259
  };
@@ -192,6 +192,7 @@ export default function CurrentSubscriptions({
192
192
  onChange(v);
193
193
  }
194
194
  }}
195
+ showRecharge
195
196
  actionProps={{
196
197
  cancel: {
197
198
  variant: 'outlined',