payment-kit 1.13.95 → 1.13.97
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/libs/product.ts +10 -1
- package/api/src/routes/subscriptions.ts +6 -0
- package/api/src/routes/usage-records.ts +31 -27
- package/api/tests/libs/session.spec.ts +79 -0
- package/blocklet.yml +1 -1
- package/package.json +3 -3
- package/src/components/invoice/table.tsx +2 -2
- package/src/components/portal/subscription/list.tsx +1 -1
- package/src/components/subscription/items/index.tsx +7 -2
- package/src/components/subscription/items/usage-records.tsx +10 -6
- package/src/pages/customer/index.tsx +7 -4
- package/src/pages/customer/subscription/detail.tsx +1 -1
package/api/src/libs/product.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import
|
|
1
|
+
import isEmpty from 'lodash/isEmpty';
|
|
2
|
+
|
|
3
|
+
import { CheckoutSession, Price, Product, Subscription } from '../store/models';
|
|
2
4
|
|
|
3
5
|
export async function getMainProductName(subscriptionId: string): Promise<string> {
|
|
6
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
7
|
+
|
|
8
|
+
// @see: https://github.com/ArcBlock/did-spaces/issues/962#issuecomment-1880549421
|
|
9
|
+
if (subscription && !isEmpty(subscription.description)) {
|
|
10
|
+
return subscription.description!;
|
|
11
|
+
}
|
|
12
|
+
|
|
4
13
|
const checkoutSession = await CheckoutSession.findOne({
|
|
5
14
|
where: {
|
|
6
15
|
subscription_id: subscriptionId,
|
|
@@ -37,6 +37,7 @@ import type { LineItem, SubscriptionUpdateItem } from '../store/models/types';
|
|
|
37
37
|
import { UsageRecord } from '../store/models/usage-record';
|
|
38
38
|
import { sequelize } from '../store/sequelize';
|
|
39
39
|
import { ensureInvoiceAndItems } from './connect/shared';
|
|
40
|
+
import { createUsageRecordQueryFn } from './usage-records';
|
|
40
41
|
|
|
41
42
|
const router = Router();
|
|
42
43
|
const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -1068,4 +1069,9 @@ router.delete('/:id', auth, async (req, res) => {
|
|
|
1068
1069
|
return res.json(doc);
|
|
1069
1070
|
});
|
|
1070
1071
|
|
|
1072
|
+
// Get usage records
|
|
1073
|
+
router.get('/:id/usage-records', authPortal, (req, res) => {
|
|
1074
|
+
createUsageRecordQueryFn(req.doc)(req, res);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1071
1077
|
export default router;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint-disable consistent-return */
|
|
2
|
-
import { Router } from 'express';
|
|
2
|
+
import { Request, Response, Router } from 'express';
|
|
3
3
|
import Joi from 'joi';
|
|
4
4
|
import pick from 'lodash/pick';
|
|
5
5
|
import { Op } from 'sequelize';
|
|
@@ -121,34 +121,38 @@ router.get('/summary', auth, async (req, res) => {
|
|
|
121
121
|
}
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
export function createUsageRecordQueryFn(doc?: Subscription) {
|
|
125
|
+
return async (req: Request, res: Response) => {
|
|
126
|
+
const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
128
|
+
try {
|
|
129
|
+
const item = await SubscriptionItem.findByPk(query.subscription_item_id);
|
|
130
|
+
if (!item) {
|
|
131
|
+
return res.status(400).json({ error: `SubscriptionItem not found: ${query.subscription_item_id}` });
|
|
132
|
+
}
|
|
133
|
+
const subscription = doc || (await Subscription.findByPk(item.subscription_id));
|
|
134
|
+
if (!subscription) {
|
|
135
|
+
return res.status(400).json({ error: `Subscription not found: ${item.subscription_id}` });
|
|
136
|
+
}
|
|
136
137
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
const { rows: list, count } = await UsageRecord.findAndCountAll({
|
|
139
|
+
where: {
|
|
140
|
+
subscription_item_id: query.subscription_item_id,
|
|
141
|
+
timestamp: { [Op.gte]: subscription.current_period_start, [Op.lt]: subscription.current_period_end },
|
|
142
|
+
},
|
|
143
|
+
order: [['created_at', 'DESC']],
|
|
144
|
+
offset: (page - 1) * pageSize,
|
|
145
|
+
limit: pageSize,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
res.json({ count, list });
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error(err);
|
|
151
|
+
res.json({ count: 0, list: [] });
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
146
155
|
|
|
147
|
-
|
|
148
|
-
} catch (err) {
|
|
149
|
-
console.error(err);
|
|
150
|
-
res.json({ count: 0, list: [] });
|
|
151
|
-
}
|
|
152
|
-
});
|
|
156
|
+
router.get('/', auth, createUsageRecordQueryFn());
|
|
153
157
|
|
|
154
158
|
export default router;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import dayjs from '../../src/libs/dayjs';
|
|
1
2
|
import {
|
|
2
3
|
getCheckoutMode,
|
|
3
4
|
getPriceCurrencyOptions,
|
|
4
5
|
getPriceUintAmountByCurrency,
|
|
5
6
|
getRecurringPeriod,
|
|
7
|
+
getSubscriptionCreateSetup,
|
|
6
8
|
} from '../../src/libs/session';
|
|
7
9
|
import type { TLineItemExpanded } from '../../src/store/models';
|
|
8
10
|
|
|
@@ -60,6 +62,7 @@ describe('getPriceCurrencyOptions', () => {
|
|
|
60
62
|
expect(result).toEqual([{ currency_id: 'usd', unit_amount: 1000, tiers: null, custom_unit_amount: null }]);
|
|
61
63
|
});
|
|
62
64
|
});
|
|
65
|
+
|
|
63
66
|
describe('getPriceUintAmountByCurrency', () => {
|
|
64
67
|
it('should return the unit_amount of the matching currency_id in currency_options', () => {
|
|
65
68
|
const price = {
|
|
@@ -96,6 +99,7 @@ describe('getPriceUintAmountByCurrency', () => {
|
|
|
96
99
|
expect(result).toBe(1000);
|
|
97
100
|
});
|
|
98
101
|
});
|
|
102
|
+
|
|
99
103
|
describe('getRecurringPeriod', () => {
|
|
100
104
|
it('should return the correct period in milliseconds when interval is "hour"', () => {
|
|
101
105
|
const recurring = { interval: 'hour', interval_count: '1' };
|
|
@@ -133,3 +137,78 @@ describe('getRecurringPeriod', () => {
|
|
|
133
137
|
expect(result).toBe(0);
|
|
134
138
|
});
|
|
135
139
|
});
|
|
140
|
+
|
|
141
|
+
describe('getSubscriptionCreateSetup', () => {
|
|
142
|
+
const currencies = [
|
|
143
|
+
{
|
|
144
|
+
currency_id: 'usd',
|
|
145
|
+
unit_amount: '1',
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
it('should calculate setup for recurring licensed price type', () => {
|
|
150
|
+
const items = [
|
|
151
|
+
{
|
|
152
|
+
price: { type: 'one_time', currency_options: currencies },
|
|
153
|
+
quantity: 1,
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
price: {
|
|
157
|
+
type: 'recurring',
|
|
158
|
+
currency_options: currencies,
|
|
159
|
+
recurring: { interval: 'day', interval_count: '1', usage_type: 'licensed' },
|
|
160
|
+
},
|
|
161
|
+
quantity: 2,
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd');
|
|
165
|
+
expect(result.amount.setup).toBe('3');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should not calculate setup for recurring metered price type', () => {
|
|
169
|
+
const items = [
|
|
170
|
+
{
|
|
171
|
+
price: { type: 'one_time', currency_options: currencies },
|
|
172
|
+
quantity: 1,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
price: {
|
|
176
|
+
type: 'recurring',
|
|
177
|
+
currency_options: currencies,
|
|
178
|
+
recurring: { interval: 'day', interval_count: '1', usage_type: 'metered' },
|
|
179
|
+
},
|
|
180
|
+
quantity: 2,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd');
|
|
184
|
+
expect(result.amount.setup).toBe('1');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should calculate cycle duration for recurring price type', () => {
|
|
188
|
+
const items = [
|
|
189
|
+
{
|
|
190
|
+
price: { type: 'recurring', currency_options: currencies, recurring: { interval: 'day', interval_count: '1' } },
|
|
191
|
+
quantity: 2,
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd');
|
|
195
|
+
expect(result.cycle.duration).toBe(24 * 60 * 60 * 1000);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should calculate trial period when trialInDays is provided', () => {
|
|
199
|
+
const items = [
|
|
200
|
+
{
|
|
201
|
+
price: { type: 'recurring', currency_options: currencies, recurring: { interval: 'day', interval_count: '1' } },
|
|
202
|
+
quantity: 2,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
const result = getSubscriptionCreateSetup(items as any, 'usd', 7);
|
|
206
|
+
const now = dayjs().unix();
|
|
207
|
+
expect(result.trail.start).toBe(now);
|
|
208
|
+
expect(result.trail.end).toBe(
|
|
209
|
+
dayjs()
|
|
210
|
+
.add(7 * 24 * 60 * 60 * 1000, 'millisecond')
|
|
211
|
+
.unix()
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.97",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"@abtnode/types": "1.16.21",
|
|
111
111
|
"@arcblock/eslint-config": "^0.2.4",
|
|
112
112
|
"@arcblock/eslint-config-ts": "^0.2.4",
|
|
113
|
-
"@did-pay/types": "1.13.
|
|
113
|
+
"@did-pay/types": "1.13.97",
|
|
114
114
|
"@types/cookie-parser": "^1.4.6",
|
|
115
115
|
"@types/cors": "^2.8.17",
|
|
116
116
|
"@types/dotenv-flow": "^3.3.3",
|
|
@@ -149,5 +149,5 @@
|
|
|
149
149
|
"parser": "typescript"
|
|
150
150
|
}
|
|
151
151
|
},
|
|
152
|
-
"gitHead": "
|
|
152
|
+
"gitHead": "f9ca9bf2030d0a2552b71c66470cbaf3964df0f8"
|
|
153
153
|
}
|
|
@@ -19,8 +19,8 @@ InvoiceTable.defaultProps = {
|
|
|
19
19
|
|
|
20
20
|
export function getAppliedBalance(invoice: TInvoiceExpanded) {
|
|
21
21
|
if (invoice.paymentMethod.type === 'stripe') {
|
|
22
|
-
const starting = toBN(invoice.starting_balance);
|
|
23
|
-
const ending = toBN(invoice.ending_balance);
|
|
22
|
+
const starting = toBN(invoice.starting_balance || '0');
|
|
23
|
+
const ending = toBN(invoice.ending_balance || '0');
|
|
24
24
|
return ending.sub(starting).toString();
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -48,7 +48,7 @@ export default function CurrentSubscriptions({ id, onChange, onClickSubscription
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
if (data && data.list.length === 0) {
|
|
51
|
-
return <Typography color="text.secondary">{t('customer.
|
|
51
|
+
return <Typography color="text.secondary">{t('customer.subscriptions.empty')}</Typography>;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const hasMore = data && data.list.length < data.count;
|
|
@@ -32,7 +32,8 @@ export default function SubscriptionItemList({ data, currency, mode }: ListProps
|
|
|
32
32
|
return (
|
|
33
33
|
<Stack>
|
|
34
34
|
<Typography color="text.primary" fontWeight={600}>
|
|
35
|
-
{item?.price.product.name}
|
|
35
|
+
{item?.price.product.name}
|
|
36
|
+
{mode === 'customer' ? '' : ` - ${item?.price_id}`}
|
|
36
37
|
</Typography>
|
|
37
38
|
<Typography color="text.secondary">
|
|
38
39
|
{formatPrice(item.price, currency, item?.price.product.unit_label)}
|
|
@@ -58,7 +59,11 @@ export default function SubscriptionItemList({ data, currency, mode }: ListProps
|
|
|
58
59
|
options: {
|
|
59
60
|
customBodyRenderLite: (_: string, index: number) => {
|
|
60
61
|
const item = data[index] as TSubscriptionItemExpanded;
|
|
61
|
-
return item.price.recurring?.usage_type === 'metered' ?
|
|
62
|
+
return item.price.recurring?.usage_type === 'metered' ? (
|
|
63
|
+
<UsageRecords subscriptionId={item.subscription_id} id={item.id} />
|
|
64
|
+
) : (
|
|
65
|
+
item.quantity
|
|
66
|
+
);
|
|
62
67
|
},
|
|
63
68
|
},
|
|
64
69
|
},
|
|
@@ -9,13 +9,17 @@ import { formatToDatetime } from '../../../libs/util';
|
|
|
9
9
|
import ConfirmDialog from '../../confirm';
|
|
10
10
|
import Table from '../../table';
|
|
11
11
|
|
|
12
|
-
const fetchData = (id: string): Promise<{ list: TUsageRecord[]; count: number }> => {
|
|
13
|
-
return api
|
|
12
|
+
const fetchData = (subscriptionId: string, id: string): Promise<{ list: TUsageRecord[]; count: number }> => {
|
|
13
|
+
return api
|
|
14
|
+
.get(`/api/subscriptions/${subscriptionId}/usage-records?subscription_item_id=${id}&pageSize=100`)
|
|
15
|
+
.then((res) => res.data);
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
export function UsageRecordDialog(props: { id: string; onConfirm: any }) {
|
|
18
|
+
export function UsageRecordDialog(props: { subscriptionId: string; id: string; onConfirm: any }) {
|
|
17
19
|
const { t } = useLocaleContext();
|
|
18
|
-
const { loading, error, data } = useRequest(() => fetchData(props.
|
|
20
|
+
const { loading, error, data } = useRequest(() => fetchData(props.subscriptionId, props.id), {
|
|
21
|
+
refreshDeps: [props.subscriptionId, props.id],
|
|
22
|
+
});
|
|
19
23
|
|
|
20
24
|
if (error) {
|
|
21
25
|
return (
|
|
@@ -84,7 +88,7 @@ export function UsageRecordDialog(props: { id: string; onConfirm: any }) {
|
|
|
84
88
|
);
|
|
85
89
|
}
|
|
86
90
|
|
|
87
|
-
export default function UsageRecords({ id }: { id: string }) {
|
|
91
|
+
export default function UsageRecords({ subscriptionId, id }: { subscriptionId: string; id: string }) {
|
|
88
92
|
const { t } = useLocaleContext();
|
|
89
93
|
const [open, setOpen] = useState(false);
|
|
90
94
|
return (
|
|
@@ -92,7 +96,7 @@ export default function UsageRecords({ id }: { id: string }) {
|
|
|
92
96
|
<Button size="small" variant="text" color="info" onClick={() => setOpen(true)}>
|
|
93
97
|
{t('admin.subscription.usage.view')}
|
|
94
98
|
</Button>
|
|
95
|
-
{open && <UsageRecordDialog id={id} onConfirm={() => setOpen(false)} />}
|
|
99
|
+
{open && <UsageRecordDialog subscriptionId={subscriptionId} id={id} onConfirm={() => setOpen(false)} />}
|
|
96
100
|
</Box>
|
|
97
101
|
);
|
|
98
102
|
}
|
|
@@ -13,6 +13,7 @@ import InfoRow from '../../components/info-row';
|
|
|
13
13
|
import CustomerInvoiceList from '../../components/portal/invoice/list';
|
|
14
14
|
import CurrentSubscriptions from '../../components/portal/subscription/list';
|
|
15
15
|
import SectionHeader from '../../components/section/header';
|
|
16
|
+
import { useSessionContext } from '../../contexts/session';
|
|
16
17
|
import api from '../../libs/api';
|
|
17
18
|
import { formatError } from '../../libs/util';
|
|
18
19
|
|
|
@@ -22,14 +23,16 @@ const fetchData = (): Promise<TCustomerExpanded> => {
|
|
|
22
23
|
|
|
23
24
|
export default function CustomerHome() {
|
|
24
25
|
const { t } = useLocaleContext();
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
loading: false,
|
|
28
|
-
});
|
|
26
|
+
const { events } = useSessionContext();
|
|
27
|
+
const [state, setState] = useSetState({ editing: false, loading: false });
|
|
29
28
|
const navigate = useNavigate();
|
|
30
29
|
|
|
31
30
|
const { loading, error, data, runAsync } = useRequest(fetchData);
|
|
32
31
|
|
|
32
|
+
events.once('switch-did', () => {
|
|
33
|
+
runAsync().catch(console.error);
|
|
34
|
+
});
|
|
35
|
+
|
|
33
36
|
if (error) {
|
|
34
37
|
return <Alert severity="error">{formatError(error)}</Alert>;
|
|
35
38
|
}
|
|
@@ -117,7 +117,7 @@ export default function CustomerSubscriptionDetail() {
|
|
|
117
117
|
</Box>
|
|
118
118
|
|
|
119
119
|
<Box className="section">
|
|
120
|
-
<SectionHeader title={t('admin.
|
|
120
|
+
<SectionHeader title={t('admin.products')} mb={0} />
|
|
121
121
|
<Box className="section-body">
|
|
122
122
|
<SubscriptionItemList data={data.items} currency={data.paymentCurrency} mode="customer" />
|
|
123
123
|
</Box>
|