payment-kit 1.13.15

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 (222) hide show
  1. package/.eslintrc.js +15 -0
  2. package/README.md +3 -0
  3. package/api/dev.ts +6 -0
  4. package/api/hooks/pre-start.js +12 -0
  5. package/api/src/hooks/pre-start.ts +21 -0
  6. package/api/src/index.ts +92 -0
  7. package/api/src/jobs/event.ts +72 -0
  8. package/api/src/jobs/invoice.ts +148 -0
  9. package/api/src/jobs/payment.ts +208 -0
  10. package/api/src/jobs/subscription.ts +301 -0
  11. package/api/src/jobs/webhook.ts +113 -0
  12. package/api/src/libs/audit.ts +73 -0
  13. package/api/src/libs/auth.ts +40 -0
  14. package/api/src/libs/chain/arcblock.ts +13 -0
  15. package/api/src/libs/dayjs.ts +17 -0
  16. package/api/src/libs/env.ts +5 -0
  17. package/api/src/libs/hooks.ts +42 -0
  18. package/api/src/libs/logger.ts +27 -0
  19. package/api/src/libs/middleware.ts +12 -0
  20. package/api/src/libs/payment.ts +53 -0
  21. package/api/src/libs/queue/index.ts +263 -0
  22. package/api/src/libs/queue/store.ts +47 -0
  23. package/api/src/libs/security.ts +95 -0
  24. package/api/src/libs/session.ts +164 -0
  25. package/api/src/libs/util.ts +93 -0
  26. package/api/src/locales/en.ts +3 -0
  27. package/api/src/locales/index.ts +37 -0
  28. package/api/src/locales/zh.ts +3 -0
  29. package/api/src/routes/checkout-sessions.ts +536 -0
  30. package/api/src/routes/connect/collect.ts +109 -0
  31. package/api/src/routes/connect/pay.ts +116 -0
  32. package/api/src/routes/connect/setup.ts +121 -0
  33. package/api/src/routes/connect/shared.ts +410 -0
  34. package/api/src/routes/connect/subscribe.ts +128 -0
  35. package/api/src/routes/customers.ts +70 -0
  36. package/api/src/routes/events.ts +76 -0
  37. package/api/src/routes/index.ts +59 -0
  38. package/api/src/routes/invoices.ts +126 -0
  39. package/api/src/routes/payment-currencies.ts +38 -0
  40. package/api/src/routes/payment-intents.ts +122 -0
  41. package/api/src/routes/payment-links.ts +221 -0
  42. package/api/src/routes/payment-methods.ts +39 -0
  43. package/api/src/routes/prices.ts +134 -0
  44. package/api/src/routes/products.ts +191 -0
  45. package/api/src/routes/settings.ts +33 -0
  46. package/api/src/routes/subscription-items.ts +148 -0
  47. package/api/src/routes/subscriptions.ts +254 -0
  48. package/api/src/routes/usage-records.ts +120 -0
  49. package/api/src/routes/webhook-attempts.ts +57 -0
  50. package/api/src/routes/webhook-endpoints.ts +105 -0
  51. package/api/src/store/migrate.ts +16 -0
  52. package/api/src/store/migrations/20230905-genesis.ts +52 -0
  53. package/api/src/store/migrations/20230911-seeding.ts +145 -0
  54. package/api/src/store/models/checkout-session.ts +395 -0
  55. package/api/src/store/models/coupon.ts +137 -0
  56. package/api/src/store/models/customer.ts +199 -0
  57. package/api/src/store/models/discount.ts +116 -0
  58. package/api/src/store/models/event.ts +111 -0
  59. package/api/src/store/models/index.ts +165 -0
  60. package/api/src/store/models/invoice-item.ts +185 -0
  61. package/api/src/store/models/invoice.ts +492 -0
  62. package/api/src/store/models/job.ts +75 -0
  63. package/api/src/store/models/payment-currency.ts +139 -0
  64. package/api/src/store/models/payment-intent.ts +282 -0
  65. package/api/src/store/models/payment-link.ts +219 -0
  66. package/api/src/store/models/payment-method.ts +169 -0
  67. package/api/src/store/models/price.ts +266 -0
  68. package/api/src/store/models/product.ts +162 -0
  69. package/api/src/store/models/promotion-code.ts +112 -0
  70. package/api/src/store/models/setup-intent.ts +206 -0
  71. package/api/src/store/models/subscription-item.ts +103 -0
  72. package/api/src/store/models/subscription-schedule.ts +157 -0
  73. package/api/src/store/models/subscription.ts +307 -0
  74. package/api/src/store/models/types.ts +406 -0
  75. package/api/src/store/models/usage-record.ts +132 -0
  76. package/api/src/store/models/webhook-attempt.ts +96 -0
  77. package/api/src/store/models/webhook-endpoint.ts +96 -0
  78. package/api/src/store/sequelize.ts +15 -0
  79. package/api/third.d.ts +28 -0
  80. package/blocklet.md +3 -0
  81. package/blocklet.yml +89 -0
  82. package/index.html +14 -0
  83. package/logo.png +0 -0
  84. package/package.json +133 -0
  85. package/public/.gitkeep +0 -0
  86. package/screenshots/.gitkeep +0 -0
  87. package/screenshots/1-subscription.png +0 -0
  88. package/screenshots/2-customer-1.png +0 -0
  89. package/screenshots/3-customer-2.png +0 -0
  90. package/screenshots/4-admin-3.png +0 -0
  91. package/screenshots/5-admin-4.png +0 -0
  92. package/scripts/build-clean.js +6 -0
  93. package/scripts/bump-version.mjs +35 -0
  94. package/src/app.tsx +68 -0
  95. package/src/components/actions.tsx +85 -0
  96. package/src/components/blockchain/tx.tsx +29 -0
  97. package/src/components/checkout/amount.tsx +24 -0
  98. package/src/components/checkout/error.tsx +30 -0
  99. package/src/components/checkout/footer.tsx +12 -0
  100. package/src/components/checkout/form/address.tsx +38 -0
  101. package/src/components/checkout/form/index.tsx +295 -0
  102. package/src/components/checkout/header.tsx +23 -0
  103. package/src/components/checkout/pay.tsx +222 -0
  104. package/src/components/checkout/product-card.tsx +56 -0
  105. package/src/components/checkout/product-item.tsx +37 -0
  106. package/src/components/checkout/skeleton/overview.tsx +21 -0
  107. package/src/components/checkout/skeleton/payment.tsx +35 -0
  108. package/src/components/checkout/success.tsx +183 -0
  109. package/src/components/checkout/summary.tsx +34 -0
  110. package/src/components/collapse.tsx +50 -0
  111. package/src/components/confirm.tsx +55 -0
  112. package/src/components/copyable.tsx +38 -0
  113. package/src/components/currency.tsx +15 -0
  114. package/src/components/customer/actions.tsx +73 -0
  115. package/src/components/data.tsx +20 -0
  116. package/src/components/drawer-form.tsx +77 -0
  117. package/src/components/error-fallback.tsx +7 -0
  118. package/src/components/error.tsx +39 -0
  119. package/src/components/event/list.tsx +217 -0
  120. package/src/components/info-card.tsx +40 -0
  121. package/src/components/info-metric.tsx +35 -0
  122. package/src/components/info-row.tsx +28 -0
  123. package/src/components/input.tsx +40 -0
  124. package/src/components/invoice/action.tsx +94 -0
  125. package/src/components/invoice/list.tsx +225 -0
  126. package/src/components/invoice/table.tsx +110 -0
  127. package/src/components/layout.tsx +70 -0
  128. package/src/components/livemode.tsx +23 -0
  129. package/src/components/metadata/editor.tsx +57 -0
  130. package/src/components/metadata/form.tsx +45 -0
  131. package/src/components/payment-intent/actions.tsx +81 -0
  132. package/src/components/payment-intent/list.tsx +204 -0
  133. package/src/components/payment-link/actions.tsx +114 -0
  134. package/src/components/payment-link/after-pay.tsx +87 -0
  135. package/src/components/payment-link/before-pay.tsx +175 -0
  136. package/src/components/payment-link/item.tsx +135 -0
  137. package/src/components/payment-link/product-select.tsx +66 -0
  138. package/src/components/payment-link/rename.tsx +64 -0
  139. package/src/components/portal/invoice/list.tsx +110 -0
  140. package/src/components/portal/subscription/cancel.tsx +83 -0
  141. package/src/components/portal/subscription/list.tsx +232 -0
  142. package/src/components/price/actions.tsx +21 -0
  143. package/src/components/price/form.tsx +292 -0
  144. package/src/components/product/actions.tsx +125 -0
  145. package/src/components/product/add-price.tsx +59 -0
  146. package/src/components/product/create.tsx +97 -0
  147. package/src/components/product/edit-price.tsx +75 -0
  148. package/src/components/product/edit.tsx +67 -0
  149. package/src/components/product/features.tsx +32 -0
  150. package/src/components/product/form.tsx +76 -0
  151. package/src/components/relative-time.tsx +41 -0
  152. package/src/components/section/header.tsx +29 -0
  153. package/src/components/status.tsx +12 -0
  154. package/src/components/subscription/actions/cancel.tsx +66 -0
  155. package/src/components/subscription/actions/index.tsx +172 -0
  156. package/src/components/subscription/actions/pause.tsx +83 -0
  157. package/src/components/subscription/items/actions.tsx +31 -0
  158. package/src/components/subscription/items/index.tsx +107 -0
  159. package/src/components/subscription/list.tsx +200 -0
  160. package/src/components/switch.tsx +48 -0
  161. package/src/components/table.tsx +66 -0
  162. package/src/components/uploader.tsx +81 -0
  163. package/src/components/webhook/attempts.tsx +149 -0
  164. package/src/contexts/products.tsx +42 -0
  165. package/src/contexts/session.ts +10 -0
  166. package/src/contexts/settings.tsx +54 -0
  167. package/src/env.d.ts +17 -0
  168. package/src/global.css +97 -0
  169. package/src/hooks/mobile.ts +15 -0
  170. package/src/index.tsx +6 -0
  171. package/src/libs/api.ts +19 -0
  172. package/src/libs/dayjs.ts +17 -0
  173. package/src/libs/util.ts +474 -0
  174. package/src/locales/en.tsx +395 -0
  175. package/src/locales/index.tsx +8 -0
  176. package/src/locales/zh.tsx +389 -0
  177. package/src/pages/admin/billing/index.tsx +56 -0
  178. package/src/pages/admin/billing/invoices/detail.tsx +215 -0
  179. package/src/pages/admin/billing/invoices/index.tsx +5 -0
  180. package/src/pages/admin/billing/subscriptions/detail.tsx +237 -0
  181. package/src/pages/admin/billing/subscriptions/index.tsx +5 -0
  182. package/src/pages/admin/customers/customers/detail.tsx +209 -0
  183. package/src/pages/admin/customers/customers/index.tsx +109 -0
  184. package/src/pages/admin/customers/index.tsx +47 -0
  185. package/src/pages/admin/developers/events/detail.tsx +77 -0
  186. package/src/pages/admin/developers/events/index.tsx +5 -0
  187. package/src/pages/admin/developers/index.tsx +60 -0
  188. package/src/pages/admin/developers/logs.tsx +3 -0
  189. package/src/pages/admin/developers/overview.tsx +3 -0
  190. package/src/pages/admin/developers/webhooks/detail.tsx +109 -0
  191. package/src/pages/admin/developers/webhooks/index.tsx +102 -0
  192. package/src/pages/admin/index.tsx +120 -0
  193. package/src/pages/admin/overview.tsx +3 -0
  194. package/src/pages/admin/payments/index.tsx +65 -0
  195. package/src/pages/admin/payments/intents/detail.tsx +205 -0
  196. package/src/pages/admin/payments/intents/index.tsx +5 -0
  197. package/src/pages/admin/payments/links/create.tsx +141 -0
  198. package/src/pages/admin/payments/links/detail.tsx +318 -0
  199. package/src/pages/admin/payments/links/index.tsx +167 -0
  200. package/src/pages/admin/products/coupons/index.tsx +3 -0
  201. package/src/pages/admin/products/index.tsx +81 -0
  202. package/src/pages/admin/products/prices/actions.tsx +151 -0
  203. package/src/pages/admin/products/prices/detail.tsx +203 -0
  204. package/src/pages/admin/products/prices/list.tsx +95 -0
  205. package/src/pages/admin/products/pricing-tables.tsx +3 -0
  206. package/src/pages/admin/products/products/create.tsx +105 -0
  207. package/src/pages/admin/products/products/detail.tsx +246 -0
  208. package/src/pages/admin/products/products/index.tsx +154 -0
  209. package/src/pages/admin/settings/branding.tsx +3 -0
  210. package/src/pages/admin/settings/business.tsx +3 -0
  211. package/src/pages/admin/settings/index.tsx +47 -0
  212. package/src/pages/admin/settings/payment-methods.tsx +80 -0
  213. package/src/pages/checkout/index.tsx +38 -0
  214. package/src/pages/checkout/pay.tsx +89 -0
  215. package/src/pages/customer/index.tsx +93 -0
  216. package/src/pages/customer/invoice.tsx +147 -0
  217. package/src/pages/home.tsx +9 -0
  218. package/tsconfig.api.json +9 -0
  219. package/tsconfig.eslint.json +7 -0
  220. package/tsconfig.json +99 -0
  221. package/tsconfig.types.json +11 -0
  222. package/vite.config.ts +19 -0
@@ -0,0 +1,135 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import Toast from '@arcblock/ux/lib/Toast';
3
+ import type { TPrice, TProduct, TProductExpanded } from '@did-pay/types';
4
+ import { Box, Checkbox, FormControlLabel, FormLabel, Stack, TextField, Typography } from '@mui/material';
5
+ import { useSetState } from 'ahooks';
6
+ import { Controller, useFormContext, useWatch } from 'react-hook-form';
7
+
8
+ import { useSettingsContext } from '../../contexts/settings';
9
+ import api from '../../libs/api';
10
+ import { formatError, formatPrice } from '../../libs/util';
11
+ import Actions from '../actions';
12
+ import InfoCard from '../info-card';
13
+ import EditProduct from '../product/edit';
14
+
15
+ type Props = {
16
+ prefix: string;
17
+ product: TProductExpanded;
18
+ valid: boolean;
19
+ onUpdate: () => void;
20
+ onRemove: () => void;
21
+ };
22
+
23
+ export default function LineItem({ prefix, product, valid, onUpdate, onRemove }: Props) {
24
+ const { t } = useLocaleContext();
25
+ const getFieldName = (name: string) => (prefix ? `${prefix}.${name}` : name);
26
+ const { settings } = useSettingsContext();
27
+ const { control, setValue } = useFormContext();
28
+ const [state, setState] = useSetState({ editing: false, loading: false });
29
+ const adjustable = useWatch({ control, name: getFieldName('adjustable_quantity.enabled') });
30
+
31
+ const onSave = async (updates: TProduct) => {
32
+ try {
33
+ setState({ loading: true });
34
+ await api.put(`/api/products/${product.id}`, updates).then((res) => res.data);
35
+ Toast.success(t('common.saved'));
36
+ onUpdate();
37
+ } catch (err) {
38
+ console.error(err);
39
+ Toast.error(formatError(err));
40
+ } finally {
41
+ setState({ loading: false });
42
+ }
43
+ };
44
+
45
+ return (
46
+ <Box
47
+ sx={{
48
+ p: 2,
49
+ borderWidth: valid ? 1 : 2,
50
+ borderStyle: 'solid',
51
+ borderColor: valid ? '#eee' : 'error.main',
52
+ borderRadius: 2,
53
+ position: 'relative',
54
+ }}>
55
+ <Actions
56
+ sx={{ position: 'absolute', top: 0, right: 0 }}
57
+ actions={[
58
+ {
59
+ label: t('admin.product.edit'),
60
+ handler: () => setState({ editing: true }),
61
+ color: 'primary',
62
+ },
63
+ {
64
+ label: t('admin.product.remove'),
65
+ handler: onRemove,
66
+ color: 'error',
67
+ },
68
+ ]}
69
+ />
70
+ <Stack direction="column" alignItems="flex-start">
71
+ <InfoCard
72
+ logo={product.images[0]}
73
+ name={product.name}
74
+ description={formatPrice(product.prices[0] as TPrice, settings.baseCurrency)}
75
+ />
76
+ <Controller
77
+ name={getFieldName('quantity')}
78
+ control={control}
79
+ render={({ field }) => (
80
+ <Stack direction="row" alignItems="center" mt={1}>
81
+ <TextField
82
+ sx={{ width: 40, mr: 1 }}
83
+ inputProps={{ style: { padding: '4px 8px' } }}
84
+ size="small"
85
+ {...field}
86
+ />
87
+ <FormLabel style={{ marginBottom: 0 }}>Quantity</FormLabel>
88
+ </Stack>
89
+ )}
90
+ />
91
+ <Controller
92
+ name={getFieldName('adjustable_quantity.enabled')}
93
+ control={control}
94
+ render={({ field }) => (
95
+ <FormControlLabel
96
+ sx={{ mt: 1, marginLeft: '20px' }}
97
+ control={
98
+ <Checkbox sx={{ ml: 0.5 }} {...field} onChange={(_, checked) => setValue(field.name, checked)} />
99
+ }
100
+ label={t('admin.paymentLink.adjustableQuantity')}
101
+ />
102
+ )}
103
+ />
104
+ {adjustable && (
105
+ <Stack direction="row" alignItems="center" mt={1} ml={6}>
106
+ <Typography sx={{ mr: 0.5 }}>Between</Typography>
107
+ <Controller
108
+ name={getFieldName('adjustable_quantity.minimum')}
109
+ control={control}
110
+ render={({ field }) => (
111
+ <TextField sx={{ width: 40 }} inputProps={{ style: { padding: '4px 8px' } }} size="small" {...field} />
112
+ )}
113
+ />
114
+ <Typography sx={{ mx: 0.5 }}>and</Typography>
115
+ <Controller
116
+ name={getFieldName('adjustable_quantity.maximum')}
117
+ control={control}
118
+ render={({ field }) => (
119
+ <TextField sx={{ width: 40 }} inputProps={{ style: { padding: '4px 8px' } }} size="small" {...field} />
120
+ )}
121
+ />
122
+ </Stack>
123
+ )}
124
+ </Stack>
125
+ {state.editing && (
126
+ <EditProduct
127
+ product={product}
128
+ loading={state.loading}
129
+ onSave={onSave}
130
+ onCancel={() => setState({ editing: false })}
131
+ />
132
+ )}
133
+ </Box>
134
+ );
135
+ }
@@ -0,0 +1,66 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import type { TProductExpanded } from '@did-pay/types';
3
+ import { AddOutlined } from '@mui/icons-material';
4
+ import { Box, ListSubheader, MenuItem, Select, Typography } from '@mui/material';
5
+ import cloneDeep from 'lodash/cloneDeep';
6
+ import { useState } from 'react';
7
+ import type { LiteralUnion } from 'type-fest';
8
+
9
+ import { useProductsContext } from '../../contexts/products';
10
+ import { useSettingsContext } from '../../contexts/settings';
11
+ import { formatPrice } from '../../libs/util';
12
+
13
+ type Props = {
14
+ mode: LiteralUnion<'waiting' | 'selecting', string>;
15
+ hasSelected: (price: any) => boolean;
16
+ onSelect: (priceId: string) => void;
17
+ };
18
+
19
+ const filterPrices = (product: TProductExpanded, hasSelected: (price: any) => boolean) => {
20
+ product.prices = product.prices.filter((x) => x.active && !hasSelected(x));
21
+ return product;
22
+ };
23
+
24
+ const filterProducts = (products: TProductExpanded[], hasSelected: (price: any) => boolean) => {
25
+ const filtered = cloneDeep(products).map((x) => filterPrices(x, hasSelected));
26
+ return filtered.filter((x) => x.prices.length);
27
+ };
28
+
29
+ export default function ProductSelect({ mode: initialMode, hasSelected, onSelect }: Props) {
30
+ const { t } = useLocaleContext();
31
+ const { products } = useProductsContext();
32
+ const { settings } = useSettingsContext();
33
+ const [mode, setMode] = useState(initialMode);
34
+
35
+ const handleSelect = (e: any) => {
36
+ setMode('waiting');
37
+ onSelect(e.target.value);
38
+ };
39
+
40
+ if (mode === 'selecting') {
41
+ return (
42
+ <Select value="" fullWidth size="small" onChange={handleSelect}>
43
+ <MenuItem value="add">
44
+ <AddOutlined />
45
+ {t('admin.product.add')}
46
+ </MenuItem>
47
+ {filterProducts(products, hasSelected).map((product) => [
48
+ <ListSubheader key={product.id} sx={{ fontSize: '1rem', color: 'text.secondary', lineHeight: '2.5rem' }}>
49
+ {product.name}
50
+ </ListSubheader>,
51
+ ...product.prices.map((price) => (
52
+ <MenuItem key={price.id} sx={{ pl: 3 }} value={price.id}>
53
+ {formatPrice(price, settings.baseCurrency)}
54
+ </MenuItem>
55
+ )),
56
+ ])}
57
+ </Select>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <Box sx={{ cursor: 'pointer' }} onClick={() => setMode('selecting')}>
63
+ <Typography color="primary">{t('admin.paymentLink.addProduct')}</Typography>
64
+ </Box>
65
+ );
66
+ }
@@ -0,0 +1,64 @@
1
+ import Dialog from '@arcblock/ux/lib/Dialog';
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import type { TPaymentLink } from '@did-pay/types';
4
+ import { Button, CircularProgress, Stack } from '@mui/material';
5
+ import type { EventHandler } from 'react';
6
+ import { FormProvider, useForm } from 'react-hook-form';
7
+
8
+ import TextInput from '../input';
9
+
10
+ export default function RenamePaymentLink({
11
+ paymentLink,
12
+ loading,
13
+ onSave,
14
+ onCancel,
15
+ }: {
16
+ paymentLink: TPaymentLink;
17
+ loading: boolean;
18
+ onSave: EventHandler<any>;
19
+ onCancel: EventHandler<any>;
20
+ }) {
21
+ const { t } = useLocaleContext();
22
+ const methods = useForm<TPaymentLink>({
23
+ defaultValues: {
24
+ name: paymentLink.name,
25
+ },
26
+ });
27
+
28
+ const { handleSubmit, reset } = methods;
29
+ const onSubmit = async (data: any) => {
30
+ await handleSubmit(onSave)(data);
31
+ reset();
32
+ onCancel(null);
33
+ };
34
+
35
+ return (
36
+ <Dialog
37
+ open
38
+ disableEscapeKeyDown
39
+ fullWidth
40
+ maxWidth="sm"
41
+ onClose={() => onCancel(null)}
42
+ showCloseButton={false}
43
+ title={t('admin.product.edit')}
44
+ actions={
45
+ <Stack direction="row">
46
+ <Button size="small" sx={{ mr: 2 }} onClick={onCancel}>
47
+ {t('common.cancel')}
48
+ </Button>
49
+ <Button variant="contained" color="primary" size="small" disabled={loading} onClick={onSubmit}>
50
+ {loading && <CircularProgress size="small" />} {t('common.save')}
51
+ </Button>
52
+ </Stack>
53
+ }>
54
+ <FormProvider {...methods}>
55
+ <TextInput
56
+ name="name"
57
+ label={t('admin.paymentLink.name.label')}
58
+ placeholder={t('admin.paymentLink.name.placeholder')}
59
+ autoFocus
60
+ />
61
+ </FormProvider>
62
+ </Dialog>
63
+ );
64
+ }
@@ -0,0 +1,110 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import type { Paginated, TInvoiceExpanded } from '@did-pay/types';
4
+ import { Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
5
+ import { fromUnitToToken } from '@ocap/util';
6
+ import { useInfiniteScroll } from 'ahooks';
7
+ import React from 'react';
8
+ import { Link } from 'react-router-dom';
9
+
10
+ import api from '../../../libs/api';
11
+ import { formatToDate, getInvoiceStatusColor } from '../../../libs/util';
12
+ import Status from '../../status';
13
+
14
+ const groupByDate = (items: TInvoiceExpanded[]) => {
15
+ const grouped: { [key: string]: TInvoiceExpanded[] } = {};
16
+ items.forEach((item) => {
17
+ const date = new Date(item.created_at).toLocaleDateString();
18
+ if (!grouped[date]) {
19
+ grouped[date] = [];
20
+ }
21
+ grouped[date]?.push(item);
22
+ });
23
+ return grouped;
24
+ };
25
+
26
+ const fetchData = (params: Record<string, any> = {}): Promise<Paginated<TInvoiceExpanded>> => {
27
+ const search = new URLSearchParams();
28
+ Object.keys(params).forEach((key) => {
29
+ search.set(key, String(params[key]));
30
+ });
31
+ return api.get(`/api/invoices?${search.toString()}`).then((res) => res.data);
32
+ };
33
+
34
+ type Props = {
35
+ customer_id: string;
36
+ };
37
+
38
+ const pageSize = 10;
39
+
40
+ export default function CustomerInvoiceList({ customer_id }: Props) {
41
+ const { t } = useLocaleContext();
42
+
43
+ const { data, loadMore, loadingMore, loading } = useInfiniteScroll<Paginated<TInvoiceExpanded>>(
44
+ (d) => {
45
+ const page = d ? Math.ceil(d.list.length / pageSize) + 1 : 1;
46
+ return fetchData({ page, size: pageSize, status: 'open,paid', customer_id });
47
+ },
48
+ {
49
+ reloadDeps: [customer_id],
50
+ }
51
+ );
52
+
53
+ if (loading || !data) {
54
+ return <CircularProgress />;
55
+ }
56
+
57
+ if (data && data.list.length === 0) {
58
+ return <Typography color="text.secondary">{t('admin.invoice.empty')}</Typography>;
59
+ }
60
+
61
+ const hasMore = data && data.list.length < data.count;
62
+
63
+ const grouped = groupByDate(data.list);
64
+
65
+ return (
66
+ <Stack direction="column" spacing={3} sx={{ mt: 1 }}>
67
+ {Object.entries(grouped).map(([date, invoices]) => (
68
+ <React.Fragment key={date}>
69
+ <Typography sx={{ fontWeight: 'bold', color: 'text.secondary', mt: 2 }}>{date}</Typography>
70
+ {invoices.map((invoice) => (
71
+ <Stack key={invoice.id} direction="row" justifyContent="space-between" spacing={3} flexWrap="nowrap">
72
+ <Box flex={2}>
73
+ <Link to={`/customer/invoice/${invoice.id}`}>
74
+ <Typography component="span">{invoice.number}</Typography>
75
+ </Link>
76
+ </Box>
77
+ <Box flex={1}>
78
+ <Typography>{formatToDate(invoice.created_at)}</Typography>
79
+ </Box>
80
+ <Box flex={1}>
81
+ <Typography>
82
+ {fromUnitToToken(invoice.total, invoice.paymentCurrency.decimal)}&nbsp;
83
+ {invoice.paymentCurrency.symbol}
84
+ </Typography>
85
+ </Box>
86
+ <Box flex={1}>
87
+ <Status label={invoice.status} color={getInvoiceStatusColor(invoice.status)} />
88
+ </Box>
89
+ <Box flex={3}>
90
+ <Typography>{invoice.description || invoice.id}</Typography>
91
+ </Box>
92
+ </Stack>
93
+ ))}
94
+ </React.Fragment>
95
+ ))}
96
+ <Box>
97
+ {hasMore && (
98
+ <Button variant="text" type="button" color="inherit" onClick={loadMore} disabled={loadingMore}>
99
+ {loadingMore
100
+ ? t('common.loadingMore', { resource: t('admin.invoices') })
101
+ : t('common.loadMore', { resource: t('admin.invoices') })}
102
+ </Button>
103
+ )}
104
+ {!hasMore && data.count > pageSize && (
105
+ <Typography color="text.secondary">{t('common.noMore', { resource: t('admin.invoices') })}</Typography>
106
+ )}
107
+ </Box>
108
+ </Stack>
109
+ );
110
+ }
@@ -0,0 +1,83 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import type { TSubscriptionExpanded } from '@did-pay/types';
3
+ import { FormControlLabel, Radio, RadioGroup, Stack, TextField, Typography } from '@mui/material';
4
+ import { Controller, useFormContext } from 'react-hook-form';
5
+
6
+ import { formatTime } from '../../../libs/util';
7
+
8
+ export default function CustomerCancelForm({ data }: { data: TSubscriptionExpanded }) {
9
+ const { t } = useLocaleContext();
10
+ const { control } = useFormContext();
11
+
12
+ return (
13
+ <Stack direction="column" spacing={1} alignItems="flex-start">
14
+ <Typography>{t('customer.cancel.description', { date: formatTime(data.current_period_end * 1000) })}</Typography>
15
+ <Controller
16
+ name="cancel.feedback"
17
+ control={control}
18
+ render={({ field }) => (
19
+ <RadioGroup {...field}>
20
+ <FormControlLabel
21
+ value="too_expensive"
22
+ control={<Radio checked={field.value === 'too_expensive'} />}
23
+ label={t('customer.cancel.feedback.too_expensive')}
24
+ />
25
+ <FormControlLabel
26
+ value="missing_features"
27
+ control={<Radio checked={field.value === 'missing_features'} />}
28
+ label={t('customer.cancel.feedback.missing_features', {
29
+ date: formatTime(new Date(data.current_period_end * 1000)),
30
+ })}
31
+ />
32
+ <FormControlLabel
33
+ value="switched_service"
34
+ control={<Radio checked={field.value === 'switched_service'} />}
35
+ label={t('customer.cancel.feedback.switched_service')}
36
+ />
37
+ <FormControlLabel
38
+ value="unused"
39
+ control={<Radio checked={field.value === 'unused'} />}
40
+ label={t('customer.cancel.feedback.unused')}
41
+ />
42
+ <FormControlLabel
43
+ value="customer_service"
44
+ control={<Radio checked={field.value === 'customer_service'} />}
45
+ label={t('customer.cancel.feedback.customer_service')}
46
+ />
47
+ <FormControlLabel
48
+ value="too_complex"
49
+ control={<Radio checked={field.value === 'too_complex'} />}
50
+ label={t('customer.cancel.feedback.too_complex')}
51
+ />
52
+ <FormControlLabel
53
+ value="low_quality"
54
+ control={<Radio checked={field.value === 'low_quality'} />}
55
+ label={t('customer.cancel.feedback.low_quality')}
56
+ />
57
+ <FormControlLabel
58
+ value="other"
59
+ control={<Radio checked={field.value === 'other'} />}
60
+ label={t('customer.cancel.feedback.other')}
61
+ />
62
+ </RadioGroup>
63
+ )}
64
+ />
65
+ <Controller
66
+ name="cancel.comment"
67
+ control={control}
68
+ render={({ field }) => (
69
+ <TextField
70
+ variant="outlined"
71
+ size="small"
72
+ fullWidth
73
+ multiline
74
+ minRows={2}
75
+ maxRows={4}
76
+ placeholder="Any additional feedback?"
77
+ {...field}
78
+ />
79
+ )}
80
+ />
81
+ </Stack>
82
+ );
83
+ }
@@ -0,0 +1,232 @@
1
+ /* eslint-disable react/no-unstable-nested-components */
2
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
+ import Toast from '@arcblock/ux/lib/Toast';
4
+ import type { Paginated, TSubscriptionExpanded } from '@did-pay/types';
5
+ import { ScheduleOutlined } from '@mui/icons-material';
6
+ import { Avatar, AvatarGroup, Box, Button, CircularProgress, Stack, Typography } from '@mui/material';
7
+ import { useInfiniteScroll, useSetState } from 'ahooks';
8
+ import { FormProvider, useForm, useFormContext } from 'react-hook-form';
9
+
10
+ import api from '../../../libs/api';
11
+ import {
12
+ formatError,
13
+ formatPrice,
14
+ formatSubscriptionProduct,
15
+ formatToDate,
16
+ getSubscriptionStatusColor,
17
+ } from '../../../libs/util';
18
+ import ConfirmDialog from '../../confirm';
19
+ import Status from '../../status';
20
+ import CustomerCancelForm from './cancel';
21
+
22
+ const fetchData = (params: Record<string, any> = {}): Promise<Paginated<TSubscriptionExpanded>> => {
23
+ const search = new URLSearchParams();
24
+ Object.keys(params).forEach((key) => {
25
+ search.set(key, String(params[key]));
26
+ });
27
+ return api.get(`/api/subscriptions?${search.toString()}`).then((res) => res.data);
28
+ };
29
+
30
+ type Props = {
31
+ id: string;
32
+ onChange: Function;
33
+ };
34
+
35
+ const pageSize = 4;
36
+
37
+ export function CurrentSubscriptionsInner({ id, onChange }: Props) {
38
+ const { t } = useLocaleContext();
39
+ const { reset, getValues } = useFormContext();
40
+
41
+ const { data, loadMore, loadingMore, loading } = useInfiniteScroll<Paginated<TSubscriptionExpanded>>(
42
+ (d) => {
43
+ const page = d ? Math.ceil(d.list.length / pageSize) + 1 : 1;
44
+ return fetchData({ page, size: pageSize, status: 'active,trialing,paused', customer_id: id });
45
+ },
46
+ {
47
+ reloadDeps: [id],
48
+ }
49
+ );
50
+
51
+ const [state, setState] = useSetState({
52
+ action: '',
53
+ subscription: '',
54
+ loading: false,
55
+ });
56
+
57
+ const handleCancel = async () => {
58
+ try {
59
+ setState({ loading: true });
60
+ await api
61
+ .put(`/api/subscriptions/${state.subscription}/cancel`, { at: 'current_period_end', ...getValues().cancel })
62
+ .then((res) => res.data);
63
+ Toast.success(t('common.saved'));
64
+ onChange(state.action);
65
+ } catch (err) {
66
+ console.error(err);
67
+ Toast.error(formatError(err));
68
+ } finally {
69
+ setState({ loading: false, action: '', subscription: '' });
70
+ reset();
71
+ }
72
+ };
73
+
74
+ const handleRecover = async () => {
75
+ try {
76
+ setState({ loading: true });
77
+ await api.put(`/api/subscriptions/${state.subscription}/recover`).then((res) => res.data);
78
+ Toast.success(t('common.saved'));
79
+ onChange(state.action);
80
+ } catch (err) {
81
+ console.error(err);
82
+ Toast.error(formatError(err));
83
+ } finally {
84
+ setState({ loading: false, action: '', subscription: '' });
85
+ }
86
+ };
87
+
88
+ if (loading || !data) {
89
+ return <CircularProgress />;
90
+ }
91
+
92
+ if (data && data.list.length === 0) {
93
+ return <Typography color="text.secondary">{t('customer.subscription.empty')}</Typography>;
94
+ }
95
+
96
+ const hasMore = data && data.list.length < data.count;
97
+ const size = { width: 48, height: 48 };
98
+
99
+ return (
100
+ <Stack direction="column" spacing={4} sx={{ mt: 2 }}>
101
+ {data.list.map((subscription) => (
102
+ <Stack key={subscription.id} direction="row" justifyContent="space-between" spacing={3}>
103
+ <Stack direction="column" spacing={0.5}>
104
+ <Stack direction="row" spacing={1} alignItems="center">
105
+ <AvatarGroup max={3}>
106
+ {subscription.items.map((item) =>
107
+ item.price.product.images.length > 0 ? (
108
+ // @ts-ignore
109
+ <Avatar
110
+ key={item.price.product_id}
111
+ src={item.price.product.images[0]}
112
+ alt={item.price.product.name}
113
+ variant="rounded"
114
+ sx={size}
115
+ />
116
+ ) : (
117
+ <Avatar key={item.price.product_id} variant="rounded" sx={size}>
118
+ {item.price.product.name.slice(0, 1)}
119
+ </Avatar>
120
+ )
121
+ )}
122
+ </AvatarGroup>
123
+ <Stack direction="column" spacing={0.5}>
124
+ <Stack direction="row" spacing={2} alignItems="center">
125
+ <Typography variant="body1" fontWeight={600}>
126
+ {formatSubscriptionProduct(subscription.items)}
127
+ </Typography>
128
+ <Status
129
+ size="small"
130
+ sx={{ height: 18 }}
131
+ label={subscription.status}
132
+ color={getSubscriptionStatusColor(subscription.status)}
133
+ />
134
+ </Stack>
135
+ <Typography variant="subtitle1" fontWeight={500}>
136
+ {
137
+ // @ts-ignore
138
+ formatPrice(subscription.items[0].price, subscription.paymentCurrency)
139
+ }
140
+ </Typography>
141
+ </Stack>
142
+ </Stack>
143
+ <Stack direction="row" spacing={2} alignItems="center">
144
+ <Typography variant="body1" color="text.secondary">
145
+ Started on {formatToDate(subscription.start_date * 1000)}, will{' '}
146
+ {subscription.cancel_at_period_end ? 'end' : 'renew'} on{' '}
147
+ {formatToDate(subscription.current_period_end * 1000)}
148
+ </Typography>
149
+ </Stack>
150
+ </Stack>
151
+ <Stack direction="column" alignItems="flex-end" spacing={1}>
152
+ <Button
153
+ variant="outlined"
154
+ color={subscription.cancel_at_period_end ? 'inherit' : 'error'}
155
+ size="small"
156
+ onClick={() =>
157
+ setState({
158
+ action: subscription.cancel_at_period_end ? 'recover' : 'cancel',
159
+ subscription: subscription.id,
160
+ })
161
+ }>
162
+ {t(`customer.${subscription.cancel_at_period_end ? 'recover' : 'cancel'}.button`)}
163
+ </Button>
164
+ {subscription.cancel_at_period_end && (
165
+ <Stack direction="row" alignItems="center" spacing={0.5}>
166
+ <ScheduleOutlined fontSize="small" sx={{ color: 'text.secondary' }} />
167
+ <Typography component="span" variant="body1" color="text.secondary">
168
+ will {subscription.cancel_at_period_end ? 'cancel' : 'renew'} on{' '}
169
+ {formatToDate(subscription.current_period_end * 1000)}
170
+ </Typography>
171
+ </Stack>
172
+ )}
173
+ </Stack>
174
+ </Stack>
175
+ ))}
176
+ <Box>
177
+ {hasMore && (
178
+ <Button variant="text" type="button" color="inherit" onClick={loadMore} disabled={loadingMore}>
179
+ {loadingMore
180
+ ? t('common.loadingMore', { resource: t('admin.subscriptions') })
181
+ : t('common.loadMore', { resource: t('admin.subscriptions') })}
182
+ </Button>
183
+ )}
184
+ {!hasMore && data.count > pageSize && (
185
+ <Typography color="text.secondary">{t('common.noMore', { resource: t('admin.subscriptions') })}</Typography>
186
+ )}
187
+ </Box>
188
+ {state.action === 'cancel' && state.subscription && (
189
+ <ConfirmDialog
190
+ onConfirm={handleCancel}
191
+ onCancel={() => setState({ action: '', subscription: '' })}
192
+ title={t('customer.cancel.title')}
193
+ message={
194
+ <CustomerCancelForm data={data.list.find((x) => x.id === state.subscription) as TSubscriptionExpanded} />
195
+ }
196
+ loading={state.loading}
197
+ />
198
+ )}
199
+ {state.action === 'recover' && state.subscription && (
200
+ <ConfirmDialog
201
+ onConfirm={handleRecover}
202
+ onCancel={() => setState({ action: '', subscription: '' })}
203
+ title={t('customer.recover.title')}
204
+ message={t('customer.recover.description', {
205
+ date: formatToDate(
206
+ (data.list.find((x) => x.id === state.subscription) as TSubscriptionExpanded).current_period_end * 1000
207
+ ),
208
+ })}
209
+ loading={state.loading}
210
+ />
211
+ )}
212
+ </Stack>
213
+ );
214
+ }
215
+
216
+ export default function CurrentSubscriptions(props: Props) {
217
+ const methods = useForm({
218
+ defaultValues: {
219
+ cancel: {
220
+ at: 'current_period_end',
221
+ feedback: 'unused',
222
+ comment: '',
223
+ },
224
+ },
225
+ });
226
+
227
+ return (
228
+ <FormProvider {...methods}>
229
+ <CurrentSubscriptionsInner {...props} />
230
+ </FormProvider>
231
+ );
232
+ }