payment-kit 1.13.30 → 1.13.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/integrations/blockchain/nft.ts +0 -1
- package/api/src/integrations/blocklet/passport.ts +1 -1
- package/api/src/integrations/stripe/handlers/invoice.ts +44 -2
- package/api/src/integrations/stripe/handlers/payment-intent.ts +32 -8
- package/api/src/integrations/stripe/resource.ts +7 -4
- package/api/src/jobs/subscription.ts +1 -1
- package/api/src/libs/payment.ts +6 -1
- package/api/src/libs/session.ts +78 -27
- package/api/src/libs/util.ts +15 -0
- package/api/src/routes/checkout-sessions.ts +161 -20
- package/api/src/routes/connect/collect.ts +5 -9
- package/api/src/routes/connect/pay.ts +5 -9
- package/api/src/routes/connect/setup.ts +22 -10
- package/api/src/routes/connect/shared.ts +13 -10
- package/api/src/routes/connect/subscribe.ts +29 -20
- package/api/src/routes/invoices.ts +5 -1
- package/api/src/routes/payment-intents.ts +5 -1
- package/api/src/routes/payment-links.ts +3 -2
- package/api/src/routes/prices.ts +32 -21
- package/api/src/store/migrations/20231023-upsell.ts +11 -0
- package/api/src/store/models/index.ts +10 -2
- package/api/src/store/models/price.ts +89 -23
- package/api/src/store/models/types.ts +1 -0
- package/blocklet.yml +1 -1
- package/package.json +17 -17
- package/src/components/blockchain/tx.tsx +3 -1
- package/src/components/checkout/pay.tsx +39 -19
- package/src/components/checkout/product-card.tsx +2 -6
- package/src/components/checkout/product-item.tsx +84 -21
- package/src/components/checkout/summary.tsx +11 -2
- package/src/components/info-row.tsx +3 -1
- package/src/components/invoice/table.tsx +1 -1
- package/src/components/price/upsell-select.tsx +83 -0
- package/src/components/price/upsell.tsx +74 -0
- package/src/components/status.tsx +1 -1
- package/src/components/subscription/actions/cancel.tsx +25 -27
- package/src/components/subscription/items/index.tsx +1 -1
- package/src/libs/util.ts +51 -31
- package/src/locales/en.tsx +23 -2
- package/src/locales/zh.tsx +52 -40
- package/src/pages/admin/billing/index.tsx +3 -3
- package/src/pages/admin/customers/customers/detail.tsx +1 -0
- package/src/pages/admin/index.tsx +1 -0
- package/src/pages/admin/products/prices/detail.tsx +7 -0
- package/src/pages/customer/invoice.tsx +7 -6
|
@@ -1,38 +1,101 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
|
+
import type { PriceRecurring, TCheckoutSessionExpanded, TLineItemExpanded, TPaymentCurrency } from '@did-pay/types';
|
|
2
3
|
import { Stack, Typography } from '@mui/material';
|
|
3
4
|
|
|
4
|
-
import { formatLineItemPricing, formatRecurring } from '../../libs/util';
|
|
5
|
+
import { formatLineItemPricing, formatPrice, formatRecurring, formatUpsellSaving } from '../../libs/util';
|
|
6
|
+
import Status from '../status';
|
|
7
|
+
import Switch from '../switch';
|
|
5
8
|
import ProductCard from './product-card';
|
|
6
9
|
|
|
7
10
|
type Props = {
|
|
8
11
|
item: TLineItemExpanded;
|
|
9
12
|
session: TCheckoutSessionExpanded;
|
|
10
13
|
currency: TPaymentCurrency;
|
|
14
|
+
onUpsell: Function;
|
|
15
|
+
onDownsell: Function;
|
|
11
16
|
};
|
|
12
17
|
|
|
13
|
-
export default function ProductItem({ item, session, currency }: Props) {
|
|
18
|
+
export default function ProductItem({ item, session, currency, onUpsell, onDownsell }: Props) {
|
|
19
|
+
const { t } = useLocaleContext();
|
|
14
20
|
const pricing = formatLineItemPricing(item, currency, session.subscription_data?.trial_period_days || 0);
|
|
21
|
+
const saving = formatUpsellSaving(session, currency);
|
|
15
22
|
const metered = item.price?.recurring?.usage_type === 'metered' ? ' based on usage' : '';
|
|
23
|
+
const canUpsell = session.line_items.length === 1;
|
|
16
24
|
return (
|
|
17
|
-
<Stack direction="
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
<
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
<Stack direction="column" alignItems="flex-start" spacing={1} sx={{ width: '100%' }}>
|
|
26
|
+
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
|
|
27
|
+
<ProductCard
|
|
28
|
+
logo={item.price.product?.images[0]}
|
|
29
|
+
name={item.price.product?.name}
|
|
30
|
+
description={item.price.product?.description}
|
|
31
|
+
extra={
|
|
32
|
+
item.price.type === 'recurring' && item.price.recurring
|
|
33
|
+
? `Billed ${formatRecurring(item.upsell_price?.recurring || item.price.recurring)} ${metered}`
|
|
34
|
+
: undefined
|
|
35
|
+
}
|
|
36
|
+
/>
|
|
37
|
+
<Stack direction="column" alignItems="flex-end" flex={1}>
|
|
38
|
+
<Typography sx={{ color: 'text.primary', fontWeight: 500 }} gutterBottom>
|
|
39
|
+
{pricing.primary}
|
|
40
|
+
</Typography>
|
|
41
|
+
{pricing.secondary && (
|
|
42
|
+
<Typography sx={{ fontSize: '0.85rem', color: 'text.secondary' }}>{pricing.secondary}</Typography>
|
|
43
|
+
)}
|
|
44
|
+
</Stack>
|
|
35
45
|
</Stack>
|
|
46
|
+
{canUpsell && !item.upsell_price_id && item.price.upsell?.upsells_to && (
|
|
47
|
+
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
|
|
48
|
+
<Typography
|
|
49
|
+
component="label"
|
|
50
|
+
htmlFor="upsell-switch"
|
|
51
|
+
sx={{
|
|
52
|
+
fontSize: 12,
|
|
53
|
+
cursor: 'pointer',
|
|
54
|
+
color: 'text.primary',
|
|
55
|
+
}}>
|
|
56
|
+
<Switch
|
|
57
|
+
id="upsell-switch"
|
|
58
|
+
sx={{ mr: 1 }}
|
|
59
|
+
variant="success"
|
|
60
|
+
checked={false}
|
|
61
|
+
onChange={() => onUpsell(item.price_id, item.price.upsell?.upsells_to_id)}
|
|
62
|
+
/>
|
|
63
|
+
{t('checkout.upsell.save', {
|
|
64
|
+
recurring: formatRecurring(item.price.upsell.upsells_to.recurring as PriceRecurring),
|
|
65
|
+
})}
|
|
66
|
+
<Status label={t('checkout.upsell.off', { saving })} color="primary" variant="outlined" sx={{ ml: 0.5 }} />
|
|
67
|
+
</Typography>
|
|
68
|
+
<Typography component="span" sx={{ fontSize: 12 }}>
|
|
69
|
+
{formatPrice(item.price.upsell.upsells_to, currency)}
|
|
70
|
+
</Typography>
|
|
71
|
+
</Stack>
|
|
72
|
+
)}
|
|
73
|
+
{canUpsell && item.upsell_price_id && (
|
|
74
|
+
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
|
|
75
|
+
<Typography
|
|
76
|
+
component="label"
|
|
77
|
+
htmlFor="upsell-switch"
|
|
78
|
+
sx={{
|
|
79
|
+
fontSize: 12,
|
|
80
|
+
cursor: 'pointer',
|
|
81
|
+
color: 'text.secondary',
|
|
82
|
+
}}>
|
|
83
|
+
<Switch
|
|
84
|
+
id="upsell-switch"
|
|
85
|
+
sx={{ mr: 1 }}
|
|
86
|
+
variant="success"
|
|
87
|
+
checked
|
|
88
|
+
onChange={() => onDownsell(item.upsell_price_id)}
|
|
89
|
+
/>
|
|
90
|
+
{t('checkout.upsell.revert', {
|
|
91
|
+
recurring: formatRecurring(item.price.recurring as PriceRecurring),
|
|
92
|
+
})}
|
|
93
|
+
</Typography>
|
|
94
|
+
<Typography component="span" sx={{ fontSize: 12 }}>
|
|
95
|
+
{formatPrice(item.price, currency)}
|
|
96
|
+
</Typography>
|
|
97
|
+
</Stack>
|
|
98
|
+
)}
|
|
36
99
|
</Stack>
|
|
37
100
|
);
|
|
38
101
|
}
|
|
@@ -8,9 +8,11 @@ import ProductItem from './product-item';
|
|
|
8
8
|
type Props = {
|
|
9
9
|
checkoutSession: TCheckoutSessionExpanded;
|
|
10
10
|
currency: TPaymentCurrency;
|
|
11
|
+
onUpsell: Function;
|
|
12
|
+
onDownsell: Function;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
|
-
export default function PaymentSummary({ checkoutSession, currency }: Props) {
|
|
15
|
+
export default function PaymentSummary({ checkoutSession, currency, onUpsell, onDownsell }: Props) {
|
|
14
16
|
const headlines = formatCheckoutHeadlines(checkoutSession, currency);
|
|
15
17
|
return (
|
|
16
18
|
<Fade in>
|
|
@@ -26,7 +28,14 @@ export default function PaymentSummary({ checkoutSession, currency }: Props) {
|
|
|
26
28
|
</Stack>
|
|
27
29
|
<Stack spacing={2}>
|
|
28
30
|
{checkoutSession.line_items.map((x: TLineItemExpanded) => (
|
|
29
|
-
<ProductItem
|
|
31
|
+
<ProductItem
|
|
32
|
+
key={x.price_id}
|
|
33
|
+
item={x}
|
|
34
|
+
session={checkoutSession}
|
|
35
|
+
currency={currency}
|
|
36
|
+
onUpsell={onUpsell}
|
|
37
|
+
onDownsell={onDownsell}
|
|
38
|
+
/>
|
|
30
39
|
))}
|
|
31
40
|
</Stack>
|
|
32
41
|
</Stack>
|
|
@@ -4,19 +4,21 @@ import type { ReactNode } from 'react';
|
|
|
4
4
|
type Props = {
|
|
5
5
|
label: string | ReactNode;
|
|
6
6
|
value?: string | ReactNode;
|
|
7
|
+
alignItems?: string;
|
|
7
8
|
sizes?: [number, number];
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
InfoRow.defaultProps = {
|
|
11
12
|
value: undefined,
|
|
12
13
|
sizes: [1, 3],
|
|
14
|
+
alignItems: 'center',
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export default function InfoRow(props: Props) {
|
|
16
18
|
const isNone = props.value === '' || typeof props.value === 'undefined';
|
|
17
19
|
const sizes = props.sizes || [1, 3];
|
|
18
20
|
return (
|
|
19
|
-
<Stack direction="row" alignItems=
|
|
21
|
+
<Stack direction="row" alignItems={props.alignItems} justifyContent="space-between" flexWrap="wrap" sx={{ mb: 1 }}>
|
|
20
22
|
<Box flex={sizes[0]} color="text.secondary">
|
|
21
23
|
{props.label}
|
|
22
24
|
</Box>
|
|
@@ -62,7 +62,7 @@ export default function InvoiceTable({ invoice, simple }: Props) {
|
|
|
62
62
|
<TableCell align="right">{formatAmount(line.amount, invoice.paymentCurrency.decimal)}</TableCell>
|
|
63
63
|
{!simple && (
|
|
64
64
|
<TableCell align="right">
|
|
65
|
-
<LineItemActions data={line} />
|
|
65
|
+
<LineItemActions data={line as any} />
|
|
66
66
|
</TableCell>
|
|
67
67
|
)}
|
|
68
68
|
</TableRow>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
3
|
+
import type { TPriceExpanded } from '@did-pay/types';
|
|
4
|
+
import { AddOutlined } from '@mui/icons-material';
|
|
5
|
+
import { Alert, CircularProgress, MenuItem, Select, Stack, Typography } from '@mui/material';
|
|
6
|
+
import { useRequest, useSetState } from 'ahooks';
|
|
7
|
+
|
|
8
|
+
import api from '../../libs/api';
|
|
9
|
+
import { formatError, formatPrice } from '../../libs/util';
|
|
10
|
+
import AddPrice from '../product/add-price';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
price: TPriceExpanded;
|
|
14
|
+
onSelect: Function;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const fetchData = (id: string): Promise<TPriceExpanded[]> => {
|
|
18
|
+
return api.get(`/api/prices/${id}/upsell`).then((res) => res.data);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default function UpsellSelect({ price, onSelect }: Props) {
|
|
22
|
+
const { t } = useLocaleContext();
|
|
23
|
+
const [state, setState] = useSetState({ loading: false, adding: false, action: '' });
|
|
24
|
+
|
|
25
|
+
const { loading, error, data } = useRequest(() => fetchData(price.id));
|
|
26
|
+
|
|
27
|
+
if (error) {
|
|
28
|
+
return <Alert severity="error">{error.message}</Alert>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (loading || !data) {
|
|
32
|
+
return <CircularProgress />;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const onSelectPrice = (e: any) => {
|
|
36
|
+
if (e.target.value) {
|
|
37
|
+
if (e.target.value === 'add') {
|
|
38
|
+
setState({ action: 'add' });
|
|
39
|
+
} else {
|
|
40
|
+
onSelect(e.target.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const onAddPrice = async (formData: any) => {
|
|
46
|
+
try {
|
|
47
|
+
setState({ adding: true });
|
|
48
|
+
await api.post('/api/prices', { ...formData, product_id: price.product_id });
|
|
49
|
+
Toast.success(t('common.saved'));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(err);
|
|
52
|
+
Toast.error(formatError(err));
|
|
53
|
+
} finally {
|
|
54
|
+
setState({ adding: false });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<Select value="empty" sx={{ width: 300 }} size="small" onChange={onSelectPrice}>
|
|
61
|
+
<MenuItem value="empty">
|
|
62
|
+
<Stack direction="row" alignItems="center">
|
|
63
|
+
<Typography>{t('admin.price.find')}</Typography>
|
|
64
|
+
</Stack>
|
|
65
|
+
</MenuItem>
|
|
66
|
+
<MenuItem value="add">
|
|
67
|
+
<Stack direction="row" alignItems="center">
|
|
68
|
+
<AddOutlined />
|
|
69
|
+
<Typography>{t('admin.price.add')}</Typography>
|
|
70
|
+
</Stack>
|
|
71
|
+
</MenuItem>
|
|
72
|
+
{data.map((x) => (
|
|
73
|
+
<MenuItem key={x.id} value={x.id}>
|
|
74
|
+
{formatPrice(x, x.currency)}
|
|
75
|
+
</MenuItem>
|
|
76
|
+
))}
|
|
77
|
+
</Select>
|
|
78
|
+
{state.action === 'add' && (
|
|
79
|
+
<AddPrice loading={state.adding} onSave={onAddPrice} onCancel={() => setState({ action: '' })} />
|
|
80
|
+
)}
|
|
81
|
+
</>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
|
|
2
|
+
import Toast from '@arcblock/ux/lib/Toast';
|
|
3
|
+
import type { TPriceExpanded } from '@did-pay/types';
|
|
4
|
+
import { DeleteOutlineOutlined } from '@mui/icons-material';
|
|
5
|
+
import { CircularProgress, Grid, IconButton, Stack, Typography } from '@mui/material';
|
|
6
|
+
import { useSetState } from 'ahooks';
|
|
7
|
+
|
|
8
|
+
import { useSettingsContext } from '../../contexts/settings';
|
|
9
|
+
import api from '../../libs/api';
|
|
10
|
+
import { formatError, formatPrice } from '../../libs/util';
|
|
11
|
+
import InfoRow from '../info-row';
|
|
12
|
+
import UpsellSelect from './upsell-select';
|
|
13
|
+
|
|
14
|
+
export function UpsellForm({ data, onChange }: { data: TPriceExpanded; onChange: Function }) {
|
|
15
|
+
const { settings } = useSettingsContext();
|
|
16
|
+
const [state, setState] = useSetState({
|
|
17
|
+
loading: false,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const onRemoveUpsell = async () => {
|
|
21
|
+
try {
|
|
22
|
+
setState({ loading: true });
|
|
23
|
+
await api.put(`/api/prices/${data.id}`, { upsell: { upsells_to_id: '' } }).then((res) => res.data);
|
|
24
|
+
setState({ loading: false });
|
|
25
|
+
onChange();
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error(err);
|
|
28
|
+
Toast.error(formatError(err));
|
|
29
|
+
setState({ loading: false });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const onSelectUpsell = async (id: string) => {
|
|
34
|
+
try {
|
|
35
|
+
setState({ loading: true });
|
|
36
|
+
await api.put(`/api/prices/${data.id}`, { upsell: { upsells_to_id: id } }).then((res) => res.data);
|
|
37
|
+
setState({ loading: false });
|
|
38
|
+
onChange();
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(err);
|
|
41
|
+
Toast.error(formatError(err));
|
|
42
|
+
setState({ loading: false });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (state.loading) {
|
|
47
|
+
return <CircularProgress />;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (data.upsell?.upsells_to_id) {
|
|
51
|
+
return (
|
|
52
|
+
<Stack spacing={1} direction="row" alignItems="center">
|
|
53
|
+
<Typography>{formatPrice(data.upsell.upsells_to, settings.baseCurrency)}</Typography>
|
|
54
|
+
<IconButton size="small" sx={{ ml: 1 }} onClick={onRemoveUpsell}>
|
|
55
|
+
<DeleteOutlineOutlined color="error" sx={{ opacity: 0.75 }} />
|
|
56
|
+
</IconButton>
|
|
57
|
+
</Stack>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return <UpsellSelect price={data} onSelect={onSelectUpsell} />;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function PriceUpsell({ data, onChange }: { data: TPriceExpanded; onChange: Function }) {
|
|
65
|
+
const { t } = useLocaleContext();
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Grid container>
|
|
69
|
+
<Grid item xs={12} md={6}>
|
|
70
|
+
<InfoRow label={t('admin.price.upsell.to')} value={<UpsellForm data={data} onChange={onChange} />} />
|
|
71
|
+
</Grid>
|
|
72
|
+
</Grid>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -3,9 +3,9 @@ import { Chip, ChipProps } from '@mui/material';
|
|
|
3
3
|
export default function Status(props: ChipProps) {
|
|
4
4
|
return (
|
|
5
5
|
<Chip
|
|
6
|
-
{...props}
|
|
7
6
|
size="small"
|
|
8
7
|
variant="outlined"
|
|
8
|
+
{...props}
|
|
9
9
|
sx={{ ...(props.sx || {}), borderRadius: '4px', height: 20, lineHeight: 20, textTransform: 'capitalize' }}
|
|
10
10
|
/>
|
|
11
11
|
);
|
|
@@ -7,39 +7,37 @@ import { formatTime } from '../../../libs/util';
|
|
|
7
7
|
|
|
8
8
|
export default function SubscriptionCancelForm({ data }: { data: TSubscriptionExpanded }) {
|
|
9
9
|
const { t } = useLocaleContext();
|
|
10
|
-
const { control, formState } = useFormContext();
|
|
11
|
-
const
|
|
10
|
+
const { control, setValue, formState } = useFormContext();
|
|
11
|
+
const cancelAt = useWatch({ control, name: 'cancel.at' });
|
|
12
|
+
const isCustom = cancelAt === 'custom';
|
|
12
13
|
|
|
13
14
|
return (
|
|
14
15
|
<Box sx={{ width: 400 }}>
|
|
15
16
|
<Stack direction="row" spacing={3} alignItems="flex-start">
|
|
16
17
|
<Typography>{t('admin.subscription.cancel.at.title')}</Typography>
|
|
17
18
|
<Stack>
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
</RadioGroup>
|
|
41
|
-
)}
|
|
42
|
-
/>
|
|
19
|
+
<RadioGroup>
|
|
20
|
+
<FormControlLabel
|
|
21
|
+
value="now"
|
|
22
|
+
onClick={() => setValue('cancel.at', 'now')}
|
|
23
|
+
control={<Radio checked={cancelAt === 'now'} />}
|
|
24
|
+
label={t('admin.subscription.cancel.at.now', { date: formatTime(new Date()) })}
|
|
25
|
+
/>
|
|
26
|
+
<FormControlLabel
|
|
27
|
+
value="current_period_end"
|
|
28
|
+
onClick={() => setValue('cancel.at', 'current_period_end')}
|
|
29
|
+
control={<Radio checked={cancelAt === 'current_period_end'} />}
|
|
30
|
+
label={t('admin.subscription.cancel.at.current_period_end', {
|
|
31
|
+
date: formatTime(new Date(data.current_period_end * 1000)),
|
|
32
|
+
})}
|
|
33
|
+
/>
|
|
34
|
+
<FormControlLabel
|
|
35
|
+
value="custom"
|
|
36
|
+
onClick={() => setValue('cancel.at', 'custom')}
|
|
37
|
+
control={<Radio checked={cancelAt === 'custom'} />}
|
|
38
|
+
label={t('admin.subscription.cancel.at.custom')}
|
|
39
|
+
/>
|
|
40
|
+
</RadioGroup>
|
|
43
41
|
{isCustom && (
|
|
44
42
|
<Controller
|
|
45
43
|
name="cancel.time"
|
|
@@ -81,7 +81,7 @@ export default function SubscriptionItemList({ data, currency }: ListProps) {
|
|
|
81
81
|
sort: false,
|
|
82
82
|
customBodyRenderLite: (_: string, index: number) => {
|
|
83
83
|
const item = data[index] as TSubscriptionItemExpanded;
|
|
84
|
-
return <LineItemActions data={item} />;
|
|
84
|
+
return <LineItemActions data={item as any} />;
|
|
85
85
|
},
|
|
86
86
|
},
|
|
87
87
|
},
|
package/src/libs/util.ts
CHANGED
|
@@ -259,64 +259,61 @@ export function formatLineItemPricing(
|
|
|
259
259
|
currency: TPaymentCurrency,
|
|
260
260
|
trial: number
|
|
261
261
|
): { primary: string; secondary?: string } {
|
|
262
|
+
const price = item.upsell_price || item.price;
|
|
262
263
|
const amount = fromUnitToToken(
|
|
263
|
-
new BN(getPriceUintAmountByCurrency(
|
|
264
|
+
new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(item.quantity)),
|
|
264
265
|
currency.decimal
|
|
265
266
|
).toString();
|
|
266
267
|
|
|
267
|
-
if (
|
|
268
|
+
if (price.type === 'recurring' && price.recurring) {
|
|
268
269
|
if (trial > 0) {
|
|
269
|
-
const secondary =
|
|
270
|
-
? `${amount} ${currency.symbol} / ${
|
|
270
|
+
const secondary = price.product.unit_label
|
|
271
|
+
? `${amount} ${currency.symbol} / ${price.product.unit_label}`
|
|
271
272
|
: `${amount} ${currency.symbol}`;
|
|
272
273
|
return {
|
|
273
274
|
primary: `Free for ${trial} days`,
|
|
274
|
-
secondary: `${secondary} ${formatRecurring(
|
|
275
|
+
secondary: `${secondary} ${formatRecurring(price.recurring, false, '/')}`,
|
|
275
276
|
};
|
|
276
277
|
}
|
|
277
278
|
|
|
278
279
|
return {
|
|
279
280
|
primary: `${amount} ${currency.symbol}`,
|
|
280
|
-
secondary:
|
|
281
|
+
secondary: price.product.unit_label ? `${amount} ${currency.symbol} / ${price.product.unit_label}` : '',
|
|
281
282
|
};
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
return {
|
|
285
286
|
primary: `${amount} ${currency.symbol}`,
|
|
286
|
-
secondary:
|
|
287
|
+
secondary: price.product.unit_label ? `${amount} ${currency.symbol} / ${price.product.unit_label}` : '',
|
|
287
288
|
};
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
export function getCheckoutAmount(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (x.price.recurring?.usage_type === 'metered') {
|
|
298
|
-
return acc;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
return acc.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
|
|
302
|
-
}, new BN(0))
|
|
303
|
-
.toString();
|
|
291
|
+
export function getCheckoutAmount(
|
|
292
|
+
items: TLineItemExpanded[],
|
|
293
|
+
currency: TPaymentCurrency,
|
|
294
|
+
includeFreeTrial = false,
|
|
295
|
+
upsell = true
|
|
296
|
+
) {
|
|
297
|
+
let renew = new BN(0);
|
|
304
298
|
|
|
305
299
|
const total = items
|
|
306
300
|
.reduce((acc, x) => {
|
|
307
|
-
|
|
301
|
+
const price = upsell ? x.upsell_price || x.price : x.price;
|
|
302
|
+
if (price.type === 'recurring') {
|
|
303
|
+
renew = renew.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
|
|
304
|
+
|
|
308
305
|
if (includeFreeTrial) {
|
|
309
306
|
return acc;
|
|
310
307
|
}
|
|
311
|
-
if (
|
|
308
|
+
if (price.recurring?.usage_type === 'metered') {
|
|
312
309
|
return acc;
|
|
313
310
|
}
|
|
314
311
|
}
|
|
315
|
-
return acc.add(new BN(getPriceUintAmountByCurrency(
|
|
312
|
+
return acc.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
|
|
316
313
|
}, new BN(0))
|
|
317
314
|
.toString();
|
|
318
315
|
|
|
319
|
-
return { subtotal, total, discount: '0', shipping: '0', tax: '0' };
|
|
316
|
+
return { subtotal: total, total, renew: renew.toString(), discount: '0', shipping: '0', tax: '0' };
|
|
320
317
|
}
|
|
321
318
|
|
|
322
319
|
export function formatPaymentLinkPricing(link: TPaymentLinkExpanded, currency: TPaymentCurrency) {
|
|
@@ -336,6 +333,27 @@ export function formatPaymentLinkPricing(link: TPaymentLinkExpanded, currency: T
|
|
|
336
333
|
);
|
|
337
334
|
}
|
|
338
335
|
|
|
336
|
+
export function formatUpsellSaving(session: TCheckoutSessionExpanded, currency: TPaymentCurrency) {
|
|
337
|
+
const items = session.line_items as TLineItemExpanded[];
|
|
338
|
+
const from = getCheckoutAmount(items, currency, false, false);
|
|
339
|
+
const to = getCheckoutAmount(
|
|
340
|
+
items.map((x) => ({
|
|
341
|
+
...x,
|
|
342
|
+
upsell_price_id: x.price.upsell?.upsells_to_id,
|
|
343
|
+
upsell_price: x.price.upsell?.upsells_to,
|
|
344
|
+
})) as TLineItemExpanded[],
|
|
345
|
+
currency,
|
|
346
|
+
false,
|
|
347
|
+
true
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const factor = 12; // FIXME: interval
|
|
351
|
+
const before = new BN(from.total).mul(new BN(factor));
|
|
352
|
+
const after = new BN(to.total);
|
|
353
|
+
|
|
354
|
+
return Number(before.sub(after).mul(new BN(100)).div(after).toString()).toFixed(0);
|
|
355
|
+
}
|
|
356
|
+
|
|
339
357
|
export function formatCheckoutHeadlines(
|
|
340
358
|
session: TCheckoutSessionExpanded,
|
|
341
359
|
currency: TPaymentCurrency
|
|
@@ -354,7 +372,11 @@ export function formatCheckoutHeadlines(
|
|
|
354
372
|
|
|
355
373
|
// empty
|
|
356
374
|
if (items.length === 0) {
|
|
357
|
-
|
|
375
|
+
return {
|
|
376
|
+
action: 'No thing to pay',
|
|
377
|
+
amount: '0',
|
|
378
|
+
then: '',
|
|
379
|
+
};
|
|
358
380
|
}
|
|
359
381
|
|
|
360
382
|
const { name } = items[0]?.price.product || { name: '' };
|
|
@@ -368,10 +390,8 @@ export function formatCheckoutHeadlines(
|
|
|
368
390
|
return { action: `Pay ${brand}`, amount, then: '' };
|
|
369
391
|
}
|
|
370
392
|
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
false
|
|
374
|
-
);
|
|
393
|
+
const item = items.find((x) => x.price.type === 'recurring');
|
|
394
|
+
const recurring = formatRecurring((item?.upsell_price || item?.price)?.recurring as PriceRecurring, false);
|
|
375
395
|
|
|
376
396
|
// all recurring
|
|
377
397
|
if (items.every((x) => x.price.type === 'recurring')) {
|
|
@@ -566,7 +586,7 @@ export function isPriceAligned(list: LineItem[], products: TProductExpanded[], i
|
|
|
566
586
|
}
|
|
567
587
|
|
|
568
588
|
export function formatSubscriptionProduct(items: TSubscriptionItemExpanded[], maxLength = 2) {
|
|
569
|
-
const names = items.map((x) => x.price.product
|
|
589
|
+
const names = items.map((x) => x.price.product?.name).filter(Boolean);
|
|
570
590
|
return (
|
|
571
591
|
names.slice(0, maxLength).join(', ') + (names.length > maxLength ? ` and ${names.length - maxLength} more` : '')
|
|
572
592
|
);
|
package/src/locales/en.tsx
CHANGED
|
@@ -36,7 +36,7 @@ export default flat({
|
|
|
36
36
|
no: 'No',
|
|
37
37
|
email: 'Email',
|
|
38
38
|
did: 'DID',
|
|
39
|
-
txHash: 'Transaction
|
|
39
|
+
txHash: 'Transaction',
|
|
40
40
|
customer: 'Customer',
|
|
41
41
|
custom: 'Custom',
|
|
42
42
|
description: 'Description',
|
|
@@ -150,6 +150,7 @@ export default flat({
|
|
|
150
150
|
amountTip: 'Choose recurring for subscriptions and one-time for everything else.',
|
|
151
151
|
duplicate: 'Duplicate price',
|
|
152
152
|
edit: 'Edit price',
|
|
153
|
+
find: 'Find or add a price',
|
|
153
154
|
archive: 'Archive price',
|
|
154
155
|
archiveTip: 'Archiving will hide this price from new purchases. Are you sure you want to archive this price?',
|
|
155
156
|
remove: 'Remove price',
|
|
@@ -194,6 +195,11 @@ export default flat({
|
|
|
194
195
|
last_during_period: 'Most recent usage value during period',
|
|
195
196
|
last_ever: 'Most recent usage value',
|
|
196
197
|
},
|
|
198
|
+
upsell: {
|
|
199
|
+
title: 'Upsells',
|
|
200
|
+
to: 'Upsells to',
|
|
201
|
+
tip: '',
|
|
202
|
+
},
|
|
197
203
|
},
|
|
198
204
|
coupon: {
|
|
199
205
|
create: 'Create Coupon',
|
|
@@ -380,7 +386,7 @@ export default flat({
|
|
|
380
386
|
at: {
|
|
381
387
|
title: 'Cancel',
|
|
382
388
|
now: 'Immediately ({date})',
|
|
383
|
-
current_period_end: 'End of
|
|
389
|
+
current_period_end: 'End of current period ({date})',
|
|
384
390
|
custom: 'On a custom date',
|
|
385
391
|
},
|
|
386
392
|
},
|
|
@@ -509,6 +515,21 @@ export default flat({
|
|
|
509
515
|
phonePlaceholder: 'Phone number',
|
|
510
516
|
phoneTip: 'In case we need to contact you about your order',
|
|
511
517
|
},
|
|
518
|
+
upsell: {
|
|
519
|
+
save: 'Save with {recurring} billing',
|
|
520
|
+
revert: 'Switch to {recurring} billing',
|
|
521
|
+
off: '{saving}% off',
|
|
522
|
+
},
|
|
523
|
+
expired: {
|
|
524
|
+
title: 'Expired Link',
|
|
525
|
+
description:
|
|
526
|
+
'This link has expired. This means that your payment has already been processed or your session has expired.',
|
|
527
|
+
},
|
|
528
|
+
complete: {
|
|
529
|
+
title: 'Checkout Completed',
|
|
530
|
+
description:
|
|
531
|
+
'This checkout session has completed. This means that your payment has already been successfully processed.',
|
|
532
|
+
},
|
|
512
533
|
},
|
|
513
534
|
customer: {
|
|
514
535
|
subscriptions: 'Current Subscriptions',
|