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.
Files changed (45) hide show
  1. package/api/src/integrations/blockchain/nft.ts +0 -1
  2. package/api/src/integrations/blocklet/passport.ts +1 -1
  3. package/api/src/integrations/stripe/handlers/invoice.ts +44 -2
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +32 -8
  5. package/api/src/integrations/stripe/resource.ts +7 -4
  6. package/api/src/jobs/subscription.ts +1 -1
  7. package/api/src/libs/payment.ts +6 -1
  8. package/api/src/libs/session.ts +78 -27
  9. package/api/src/libs/util.ts +15 -0
  10. package/api/src/routes/checkout-sessions.ts +161 -20
  11. package/api/src/routes/connect/collect.ts +5 -9
  12. package/api/src/routes/connect/pay.ts +5 -9
  13. package/api/src/routes/connect/setup.ts +22 -10
  14. package/api/src/routes/connect/shared.ts +13 -10
  15. package/api/src/routes/connect/subscribe.ts +29 -20
  16. package/api/src/routes/invoices.ts +5 -1
  17. package/api/src/routes/payment-intents.ts +5 -1
  18. package/api/src/routes/payment-links.ts +3 -2
  19. package/api/src/routes/prices.ts +32 -21
  20. package/api/src/store/migrations/20231023-upsell.ts +11 -0
  21. package/api/src/store/models/index.ts +10 -2
  22. package/api/src/store/models/price.ts +89 -23
  23. package/api/src/store/models/types.ts +1 -0
  24. package/blocklet.yml +1 -1
  25. package/package.json +17 -17
  26. package/src/components/blockchain/tx.tsx +3 -1
  27. package/src/components/checkout/pay.tsx +39 -19
  28. package/src/components/checkout/product-card.tsx +2 -6
  29. package/src/components/checkout/product-item.tsx +84 -21
  30. package/src/components/checkout/summary.tsx +11 -2
  31. package/src/components/info-row.tsx +3 -1
  32. package/src/components/invoice/table.tsx +1 -1
  33. package/src/components/price/upsell-select.tsx +83 -0
  34. package/src/components/price/upsell.tsx +74 -0
  35. package/src/components/status.tsx +1 -1
  36. package/src/components/subscription/actions/cancel.tsx +25 -27
  37. package/src/components/subscription/items/index.tsx +1 -1
  38. package/src/libs/util.ts +51 -31
  39. package/src/locales/en.tsx +23 -2
  40. package/src/locales/zh.tsx +52 -40
  41. package/src/pages/admin/billing/index.tsx +3 -3
  42. package/src/pages/admin/customers/customers/detail.tsx +1 -0
  43. package/src/pages/admin/index.tsx +1 -0
  44. package/src/pages/admin/products/prices/detail.tsx +7 -0
  45. package/src/pages/customer/invoice.tsx +7 -6
@@ -1,38 +1,101 @@
1
- import type { TCheckoutSessionExpanded, TLineItemExpanded, TPaymentCurrency } from '@did-pay/types';
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="row" alignItems="center" justifyContent="space-between">
18
- <ProductCard
19
- logo={item.price.product?.images[0]}
20
- name={item.price.product?.name}
21
- description={item.price.product?.description}
22
- extra={
23
- item.price.type === 'recurring' && item.price.recurring
24
- ? `Billed ${formatRecurring(item.price.recurring)} ${metered}`
25
- : undefined
26
- }
27
- />
28
- <Stack direction="column" alignItems="flex-end" flex={1}>
29
- <Typography sx={{ color: 'text.primary', fontWeight: 500 }} gutterBottom>
30
- {pricing.primary}
31
- </Typography>
32
- {pricing.secondary && (
33
- <Typography sx={{ fontSize: '0.85rem', color: 'text.secondary' }}>{pricing.secondary}</Typography>
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 key={x.price_id} item={x} session={checkoutSession} currency={currency} />
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="flex-start" justifyContent="space-between" flexWrap="wrap" sx={{ mb: 1 }}>
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 isCustom = useWatch({ control, name: 'cancel.at' }) === 'custom';
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
- <Controller
19
- name="cancel.at"
20
- control={control}
21
- render={({ field }) => (
22
- <RadioGroup {...field}>
23
- <FormControlLabel
24
- value="now"
25
- control={<Radio checked={field.value === 'now'} />}
26
- label={t('admin.subscription.cancel.at.now', { date: formatTime(new Date()) })}
27
- />
28
- <FormControlLabel
29
- value="current_period_end"
30
- control={<Radio checked={field.value === 'current_period_end'} />}
31
- label={t('admin.subscription.cancel.at.current_period_end', {
32
- date: formatTime(new Date(data.current_period_end * 1000)),
33
- })}
34
- />
35
- <FormControlLabel
36
- value="custom"
37
- control={<Radio checked={field.value === 'custom'} />}
38
- label={t('admin.subscription.cancel.at.custom')}
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(item.price, currency)).mul(new BN(item.quantity)),
264
+ new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(item.quantity)),
264
265
  currency.decimal
265
266
  ).toString();
266
267
 
267
- if (item.price.type === 'recurring' && item.price.recurring) {
268
+ if (price.type === 'recurring' && price.recurring) {
268
269
  if (trial > 0) {
269
- const secondary = item.price.product.unit_label
270
- ? `${amount} ${currency.symbol} / ${item.price.product.unit_label}`
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(item.price.recurring, false, '/')}`,
275
+ secondary: `${secondary} ${formatRecurring(price.recurring, false, '/')}`,
275
276
  };
276
277
  }
277
278
 
278
279
  return {
279
280
  primary: `${amount} ${currency.symbol}`,
280
- secondary: item.price.product.unit_label ? `${amount} ${currency.symbol} / ${item.price.product.unit_label}` : '',
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: item.price.product.unit_label ? `${amount} ${currency.symbol} / ${item.price.product.unit_label}` : '',
287
+ secondary: price.product.unit_label ? `${amount} ${currency.symbol} / ${price.product.unit_label}` : '',
287
288
  };
288
289
  }
289
290
 
290
- export function getCheckoutAmount(items: TLineItemExpanded[], currency: TPaymentCurrency, includeFreeTrial = false) {
291
- const subtotal = items
292
- .reduce((acc, x) => {
293
- if (x.price.type === 'recurring') {
294
- if (includeFreeTrial) {
295
- return acc;
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
- if (x.price.type === 'recurring') {
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 (x.price.recurring?.usage_type === 'metered') {
308
+ if (price.recurring?.usage_type === 'metered') {
312
309
  return acc;
313
310
  }
314
311
  }
315
- return acc.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
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
- throw new Error('No line items for the checkout session');
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 recurring = formatRecurring(
372
- items.find((x) => x.price.type === 'recurring')?.price.recurring as PriceRecurring,
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.name);
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
  );
@@ -36,7 +36,7 @@ export default flat({
36
36
  no: 'No',
37
37
  email: 'Email',
38
38
  did: 'DID',
39
- txHash: 'Transaction Hash',
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 the current period ({date})',
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',