payment-kit 1.13.17 → 1.13.19
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/README.md +14 -0
- package/api/src/index.ts +17 -6
- package/api/src/integrations/stripe/handlers/index.ts +53 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +252 -0
- package/api/src/integrations/stripe/handlers/payment-intent.ts +172 -0
- package/api/src/integrations/stripe/handlers/setup-intent.ts +42 -0
- package/api/src/integrations/stripe/handlers/subscription.ts +61 -0
- package/api/src/integrations/stripe/resource.ts +317 -0
- package/api/src/integrations/stripe/setup.ts +50 -0
- package/api/src/jobs/invoice.ts +11 -0
- package/api/src/jobs/payment.ts +15 -7
- package/api/src/jobs/subscription.ts +18 -2
- package/api/src/libs/session.ts +104 -8
- package/api/src/libs/util.ts +47 -1
- package/api/src/routes/checkout-sessions.ts +134 -27
- package/api/src/routes/connect/collect.ts +12 -4
- package/api/src/routes/connect/pay.ts +30 -20
- package/api/src/routes/connect/setup.ts +12 -4
- package/api/src/routes/connect/shared.ts +28 -4
- package/api/src/routes/connect/subscribe.ts +12 -5
- package/api/src/routes/customers.ts +5 -5
- package/api/src/routes/events.ts +9 -6
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/integrations/stripe.ts +64 -0
- package/api/src/routes/invoices.ts +19 -9
- package/api/src/routes/payment-intents.ts +19 -9
- package/api/src/routes/payment-links.ts +57 -15
- package/api/src/routes/payment-methods.ts +98 -1
- package/api/src/routes/prices.ts +71 -14
- package/api/src/routes/products.ts +79 -22
- package/api/src/routes/settings.ts +10 -11
- package/api/src/routes/subscription-items.ts +5 -5
- package/api/src/routes/subscriptions.ts +61 -10
- package/api/src/routes/usage-records.ts +52 -18
- package/api/src/routes/webhook-attempts.ts +5 -5
- package/api/src/routes/webhook-endpoints.ts +5 -5
- package/api/src/store/migrations/20230905-genesis.ts +2 -2
- package/api/src/store/migrations/20230911-seeding.ts +4 -3
- package/api/src/store/models/checkout-session.ts +15 -7
- package/api/src/store/models/index.ts +31 -7
- package/api/src/store/models/invoice.ts +1 -1
- package/api/src/store/models/payment-intent.ts +2 -5
- package/api/src/store/models/payment-link.ts +1 -1
- package/api/src/store/models/payment-method.ts +54 -33
- package/api/src/store/models/price.ts +52 -17
- package/api/src/store/models/product.ts +0 -3
- package/api/src/store/models/subscription.ts +3 -5
- package/api/src/store/models/types.ts +56 -2
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +36 -29
- package/public/currencies/dai.png +0 -0
- package/public/currencies/dollar.png +0 -0
- package/public/currencies/usdc.png +0 -0
- package/public/currencies/usdt.png +0 -0
- package/public/methods/arcblock.png +0 -0
- package/public/methods/binance.png +0 -0
- package/public/methods/coinbase.png +0 -0
- package/public/methods/ethereum.jpg +0 -0
- package/public/methods/stripe.png +0 -0
- package/src/components/checkout/form/address.tsx +86 -10
- package/src/components/checkout/form/index.tsx +169 -83
- package/src/components/checkout/form/phone.tsx +96 -0
- package/src/components/checkout/form/stripe.tsx +195 -0
- package/src/components/checkout/pay.tsx +115 -34
- package/src/components/checkout/product-item.tsx +4 -3
- package/src/components/checkout/summary.tsx +5 -4
- package/src/components/drawer-form.tsx +4 -4
- package/src/components/input.tsx +22 -4
- package/src/components/invoice/table.tsx +8 -3
- package/src/components/payment-link/before-pay.tsx +11 -6
- package/src/components/payment-link/chrome.tsx +13 -0
- package/src/components/payment-link/preview.tsx +31 -0
- package/src/components/payment-link/product-select.tsx +8 -3
- package/src/components/payment-method/arcblock.tsx +53 -0
- package/src/components/payment-method/bitcoin.tsx +53 -0
- package/src/components/payment-method/ethereum.tsx +53 -0
- package/src/components/payment-method/form.tsx +54 -0
- package/src/components/payment-method/stripe.tsx +45 -0
- package/src/components/portal/invoice/list.tsx +1 -1
- package/src/components/portal/subscription/list.tsx +1 -1
- package/src/components/price/currency-select.tsx +53 -0
- package/src/components/price/form.tsx +118 -24
- package/src/components/product/add-price.tsx +1 -1
- package/src/components/product/edit-price.tsx +6 -2
- package/src/components/subscription/items/index.tsx +7 -6
- package/src/components/subscription/items/usage-records.tsx +98 -0
- package/src/components/subscription/list.tsx +3 -2
- package/src/components/subscription/status.tsx +68 -0
- package/src/contexts/settings.tsx +2 -2
- package/src/env.d.ts +2 -0
- package/src/libs/util.ts +116 -21
- package/src/locales/en.tsx +71 -3
- package/src/pages/admin/billing/invoices/detail.tsx +5 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +6 -6
- package/src/pages/admin/customers/customers/detail.tsx +13 -1
- package/src/pages/admin/payments/intents/detail.tsx +8 -3
- package/src/pages/admin/payments/links/create.tsx +23 -3
- package/src/pages/admin/payments/links/detail.tsx +13 -26
- package/src/pages/admin/products/prices/detail.tsx +55 -11
- package/src/pages/admin/products/prices/list.tsx +7 -1
- package/src/pages/admin/products/products/create.tsx +1 -1
- package/src/pages/admin/products/products/detail.tsx +14 -7
- package/src/pages/admin/settings/index.tsx +16 -6
- package/src/pages/admin/settings/payment-methods/create.tsx +81 -0
- package/src/pages/admin/settings/{payment-methods.tsx → payment-methods/index.tsx} +9 -6
- package/src/pages/checkout/pay.tsx +3 -1
- package/src/pages/customer/index.tsx +12 -1
- package/public/.gitkeep +0 -0
package/api/src/routes/prices.ts
CHANGED
|
@@ -23,15 +23,22 @@ router.post('/', auth, async (req, res) => {
|
|
|
23
23
|
raw.created_via = req.user?.via as string;
|
|
24
24
|
|
|
25
25
|
if (!raw.unit_amount) {
|
|
26
|
-
return res.status(400).json({ error: 'unit_amount is required' });
|
|
26
|
+
return res.status(400).json({ error: 'price unit_amount is required' });
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
if (raw.currency_options?.length === 0) {
|
|
30
|
+
raw.currency_options = [
|
|
31
|
+
{ currency_id: raw.currency_id, unit_amount: raw.unit_amount, tiers: null, custom_unit_amount: null },
|
|
32
|
+
];
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
const currencies = await PaymentCurrency.findAll({ where: { active: true } });
|
|
36
|
+
const currency = currencies.find((x) => x.id === raw.currency_id);
|
|
37
|
+
if (!currency) {
|
|
38
|
+
return res.status(400).json({ error: `currency used in price or not active: ${raw.currency_id}` });
|
|
39
|
+
}
|
|
34
40
|
raw.unit_amount = fromTokenToUnit(raw.unit_amount, currency.decimal).toString();
|
|
41
|
+
raw.currency_options = Price.formatCurrencies(raw.currency_options, currencies);
|
|
35
42
|
|
|
36
43
|
const price = await Price.insert(raw);
|
|
37
44
|
|
|
@@ -47,7 +54,30 @@ router.get('/:id', auth, async (req, res) => {
|
|
|
47
54
|
],
|
|
48
55
|
});
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
if (price) {
|
|
58
|
+
const doc = price.toJSON();
|
|
59
|
+
if (doc.currency_options) {
|
|
60
|
+
const currencies = await PaymentCurrency.findAll();
|
|
61
|
+
doc.currency_options.forEach((x) => {
|
|
62
|
+
// @ts-ignore
|
|
63
|
+
x.currency = currencies.find((c) => c.id === x.currency_id);
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
doc.currency_options = [
|
|
67
|
+
{
|
|
68
|
+
currency_id: doc.currency_id,
|
|
69
|
+
unit_amount: doc.unit_amount,
|
|
70
|
+
// @ts-ignore
|
|
71
|
+
currency: doc.currency,
|
|
72
|
+
tiers: null,
|
|
73
|
+
custom_unit_amount: null,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
res.json(doc);
|
|
78
|
+
} else {
|
|
79
|
+
res.json(null);
|
|
80
|
+
}
|
|
51
81
|
});
|
|
52
82
|
|
|
53
83
|
// update price
|
|
@@ -62,28 +92,55 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
62
92
|
return res.status(403).json({ error: 'price archived' });
|
|
63
93
|
}
|
|
64
94
|
|
|
65
|
-
const
|
|
95
|
+
const updates: Partial<Price> = Price.format(
|
|
66
96
|
pick(
|
|
67
97
|
req.body,
|
|
68
98
|
price.locked
|
|
69
99
|
? ['nickname', 'description', 'metadata']
|
|
70
|
-
: ['type', 'model', 'active', 'livemode', 'nickname', 'recurring', 'description', 'tiers', 'unit_amount', 'transform_quantity', 'metadata', 'lookup_key'] // prettier-ignore
|
|
100
|
+
: ['type', 'model', 'active', 'livemode', 'nickname', 'recurring', 'description', 'tiers', 'unit_amount', 'transform_quantity', 'metadata', 'lookup_key', 'currency_options'] // prettier-ignore
|
|
71
101
|
)
|
|
72
102
|
);
|
|
73
103
|
|
|
74
|
-
if (
|
|
75
|
-
const exist = await Price.findOne({ where: { lookup_key:
|
|
104
|
+
if (updates.lookup_key) {
|
|
105
|
+
const exist = await Price.findOne({ where: { lookup_key: updates.lookup_key } });
|
|
76
106
|
if (exist && exist.id !== price.id) {
|
|
77
|
-
return res.status(400).json({ error: `lookup_key ${
|
|
107
|
+
return res.status(400).json({ error: `lookup_key ${updates.lookup_key} already used by ${exist.id}` });
|
|
78
108
|
}
|
|
79
109
|
}
|
|
80
110
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
111
|
+
const currencies = await PaymentCurrency.findAll({ where: { active: true } });
|
|
112
|
+
if (updates.unit_amount) {
|
|
113
|
+
const currency = currencies.find((x) => x.id === price.currency_id);
|
|
114
|
+
if (!currency) {
|
|
115
|
+
return res.status(400).json({ error: `currency used in price not found or not active: ${price.currency_id}` });
|
|
116
|
+
}
|
|
117
|
+
updates.unit_amount = fromTokenToUnit(updates.unit_amount, currency.decimal).toString();
|
|
118
|
+
}
|
|
119
|
+
if (updates.currency_options) {
|
|
120
|
+
updates.currency_options = Price.formatCurrencies(updates.currency_options, currencies);
|
|
121
|
+
const base = updates.currency_options.find((x) => x.currency_id === price.currency_id);
|
|
122
|
+
if (!base) {
|
|
123
|
+
updates.currency_options.unshift({
|
|
124
|
+
currency_id: price.currency_id,
|
|
125
|
+
unit_amount: price.unit_amount,
|
|
126
|
+
tiers: null,
|
|
127
|
+
custom_unit_amount: null,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (updates.unit_amount) {
|
|
131
|
+
const exist = updates.currency_options.find((x) => x.currency_id === price.currency_id);
|
|
132
|
+
if (exist) {
|
|
133
|
+
exist.unit_amount = updates.unit_amount;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} else if (updates.unit_amount) {
|
|
137
|
+
const exist = price.currency_options.find((x) => x.currency_id === price.currency_id);
|
|
138
|
+
if (exist) {
|
|
139
|
+
exist.unit_amount = updates.unit_amount;
|
|
140
|
+
}
|
|
84
141
|
}
|
|
85
142
|
|
|
86
|
-
await price.update(Price.format(
|
|
143
|
+
await price.update(Price.format(updates));
|
|
87
144
|
|
|
88
145
|
return res.json(price);
|
|
89
146
|
});
|
|
@@ -25,6 +25,7 @@ router.post('/', auth, async (req, res) => {
|
|
|
25
25
|
'metadata',
|
|
26
26
|
'statement_descriptor',
|
|
27
27
|
'unit_label',
|
|
28
|
+
'nft_factory',
|
|
28
29
|
'features',
|
|
29
30
|
'metadata',
|
|
30
31
|
]);
|
|
@@ -41,16 +42,34 @@ router.post('/', auth, async (req, res) => {
|
|
|
41
42
|
return res.status(400).json({ error: 'unit_amount is required for price' });
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
const
|
|
45
|
-
if (!currency) {
|
|
46
|
-
return res.status(400).json({ error: 'currency_id not set for price' });
|
|
47
|
-
}
|
|
48
|
-
|
|
45
|
+
const currencies = await PaymentCurrency.findAll({ where: { active: true } });
|
|
49
46
|
const pricesRaw = req.body.prices.map((price: Price & { model: 'string' }) => {
|
|
50
47
|
price.product_id = product.id;
|
|
51
48
|
price.active = product.active;
|
|
52
49
|
price.livemode = product.livemode;
|
|
50
|
+
price.currency_id = price.currency_id || req.currency.id;
|
|
51
|
+
|
|
52
|
+
const currency = currencies.find((x) => x.id === price.currency_id);
|
|
53
|
+
if (!currency) {
|
|
54
|
+
throw new Error(`currency ${price.currency_id} used in price not found or inactive`);
|
|
55
|
+
}
|
|
56
|
+
if (!price.unit_amount) {
|
|
57
|
+
return res.status(400).json({ error: 'price.unit_amount is required' });
|
|
58
|
+
}
|
|
53
59
|
price.unit_amount = fromTokenToUnit(price.unit_amount, currency.decimal).toString();
|
|
60
|
+
|
|
61
|
+
if (Array.isArray(price.currency_options)) {
|
|
62
|
+
price.currency_options = Price.formatCurrencies(price.currency_options, currencies);
|
|
63
|
+
} else {
|
|
64
|
+
price.currency_options = [];
|
|
65
|
+
}
|
|
66
|
+
price.currency_options.unshift({
|
|
67
|
+
currency_id: price.currency_id,
|
|
68
|
+
unit_amount: price.unit_amount,
|
|
69
|
+
tiers: null,
|
|
70
|
+
custom_unit_amount: null,
|
|
71
|
+
});
|
|
72
|
+
|
|
54
73
|
return price;
|
|
55
74
|
});
|
|
56
75
|
|
|
@@ -68,41 +87,51 @@ router.post('/', auth, async (req, res) => {
|
|
|
68
87
|
// list products and prices
|
|
69
88
|
const paginationSchema = Joi.object<{
|
|
70
89
|
page: number;
|
|
71
|
-
|
|
90
|
+
pageSize: number;
|
|
91
|
+
livemode?: boolean;
|
|
72
92
|
active?: boolean;
|
|
73
93
|
name?: string;
|
|
74
94
|
description?: string;
|
|
75
|
-
livemode?: boolean;
|
|
76
95
|
}>({
|
|
77
96
|
page: Joi.number().integer().min(1).default(1),
|
|
78
|
-
|
|
97
|
+
pageSize: Joi.number().integer().min(1).max(100).default(20),
|
|
98
|
+
livemode: Joi.boolean().empty(''),
|
|
79
99
|
active: Joi.boolean().empty(''),
|
|
80
100
|
name: Joi.string().empty(''),
|
|
81
101
|
description: Joi.string().empty(''),
|
|
82
|
-
livemode: Joi.boolean().empty(''),
|
|
83
102
|
});
|
|
84
103
|
router.get('/', auth, async (req, res) => {
|
|
85
|
-
const { page,
|
|
104
|
+
const { page, pageSize, active, livemode, name, description, ...query } = await paginationSchema.validateAsync(
|
|
105
|
+
req.query,
|
|
106
|
+
{ stripUnknown: false, allowUnknown: true }
|
|
107
|
+
);
|
|
86
108
|
const where: WhereOptions<Product> = {};
|
|
87
109
|
|
|
88
|
-
if (typeof
|
|
89
|
-
where.active =
|
|
110
|
+
if (typeof active === 'boolean') {
|
|
111
|
+
where.active = active;
|
|
90
112
|
}
|
|
91
|
-
if (typeof
|
|
92
|
-
where.livemode =
|
|
113
|
+
if (typeof livemode === 'boolean') {
|
|
114
|
+
where.livemode = livemode;
|
|
93
115
|
}
|
|
94
|
-
if (
|
|
95
|
-
where.name = { [Op.like]: `%${
|
|
116
|
+
if (name) {
|
|
117
|
+
where.name = { [Op.like]: `%${name}%` };
|
|
96
118
|
}
|
|
97
|
-
if (
|
|
98
|
-
where.description = { [Op.like]: `%${
|
|
119
|
+
if (description) {
|
|
120
|
+
where.description = { [Op.like]: `%${description}%` };
|
|
99
121
|
}
|
|
100
122
|
|
|
123
|
+
Object.keys(query)
|
|
124
|
+
.filter((x) => x.startsWith('metadata.'))
|
|
125
|
+
.forEach((key: string) => {
|
|
126
|
+
// @ts-ignore
|
|
127
|
+
where[key] = query[key];
|
|
128
|
+
});
|
|
129
|
+
|
|
101
130
|
const { rows: list, count } = await Product.findAndCountAll({
|
|
102
131
|
where,
|
|
103
132
|
order: [['created_at', 'DESC']],
|
|
104
|
-
offset: (page - 1) *
|
|
105
|
-
limit:
|
|
133
|
+
offset: (page - 1) * pageSize,
|
|
134
|
+
limit: pageSize,
|
|
106
135
|
include: [{ model: Price, as: 'prices' }],
|
|
107
136
|
});
|
|
108
137
|
|
|
@@ -113,10 +142,37 @@ router.get('/', auth, async (req, res) => {
|
|
|
113
142
|
router.get('/:id', auth, async (req, res) => {
|
|
114
143
|
const product = await Product.findOne({
|
|
115
144
|
where: { id: req.params.id },
|
|
116
|
-
include: [
|
|
145
|
+
include: [
|
|
146
|
+
{ model: Price, as: 'prices', include: [{ model: PaymentCurrency, as: 'currency' }] },
|
|
147
|
+
{ model: Price, as: 'default_price' },
|
|
148
|
+
],
|
|
117
149
|
});
|
|
118
150
|
|
|
119
|
-
|
|
151
|
+
if (product) {
|
|
152
|
+
const doc = product.toJSON();
|
|
153
|
+
const currencies = await PaymentCurrency.findAll();
|
|
154
|
+
// @ts-ignore
|
|
155
|
+
for (const price of doc.prices) {
|
|
156
|
+
if (Array.isArray(price.currency_options)) {
|
|
157
|
+
price.currency_options.forEach((x: any) => {
|
|
158
|
+
x.currency = currencies.find((c) => c.id === x.currency_id);
|
|
159
|
+
});
|
|
160
|
+
const base = price.currency_options.find((x: any) => x.currency_id === price.currency_id);
|
|
161
|
+
if (!base) {
|
|
162
|
+
price.currency_options.unshift(
|
|
163
|
+
pick(price, ['currency_id', 'unit_amount', 'tiers', 'custom_unit_amount', 'currency'])
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
price.currency_options = [
|
|
168
|
+
pick(price, ['currency_id', 'unit_amount', 'tiers', 'custom_unit_amount', 'currency']),
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
res.json(doc);
|
|
173
|
+
} else {
|
|
174
|
+
res.json(null);
|
|
175
|
+
}
|
|
120
176
|
});
|
|
121
177
|
|
|
122
178
|
// update product
|
|
@@ -142,6 +198,7 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
142
198
|
'default_price_id',
|
|
143
199
|
'unit_label',
|
|
144
200
|
'features',
|
|
201
|
+
'nft_factory',
|
|
145
202
|
'metadata',
|
|
146
203
|
]);
|
|
147
204
|
if (updates.metadata) {
|
|
@@ -8,27 +8,26 @@ import { PaymentMethod } from '../store/models/payment-method';
|
|
|
8
8
|
const router = Router();
|
|
9
9
|
|
|
10
10
|
router.get('/', async (req, res) => {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
where.livemode = req.livemode;
|
|
11
|
+
const attributes = ['id', 'name', 'symbol', 'decimal', 'logo'];
|
|
12
|
+
const where: WhereOptions<PaymentMethod> = { livemode: req.livemode, active: true };
|
|
14
13
|
|
|
15
14
|
const methods = await PaymentMethod.findAll({
|
|
16
15
|
where,
|
|
17
|
-
order: [['created_at', '
|
|
18
|
-
include: [{ model: PaymentCurrency, as: 'payment_currencies' }],
|
|
16
|
+
order: [['created_at', 'ASC']],
|
|
17
|
+
include: [{ model: PaymentCurrency, as: 'payment_currencies', where: { active: true } }],
|
|
19
18
|
});
|
|
20
19
|
|
|
21
20
|
methods.forEach((method) => {
|
|
22
21
|
// @ts-ignore
|
|
23
|
-
method.
|
|
24
|
-
pick(x, ['id', 'name', 'symbol', 'decimal', 'logo', 'is_base_currency'])
|
|
25
|
-
);
|
|
22
|
+
method.payment_currencies = method.payment_currencies?.map((x) => pick(x, attributes));
|
|
26
23
|
});
|
|
27
24
|
|
|
28
25
|
res.json({
|
|
29
|
-
paymentMethods: methods.map((x) => pick(x, ['id', 'name', 'type', 'logo', '
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
paymentMethods: methods.map((x) => pick(x, ['id', 'name', 'type', 'logo', 'payment_currencies'])),
|
|
27
|
+
baseCurrency: await PaymentCurrency.findOne({
|
|
28
|
+
where: { is_base_currency: true, livemode: req.livemode },
|
|
29
|
+
attributes,
|
|
30
|
+
}),
|
|
32
31
|
});
|
|
33
32
|
});
|
|
34
33
|
|
|
@@ -40,17 +40,17 @@ router.post('/', auth, async (req, res) => {
|
|
|
40
40
|
// @link https://stripe.com/docs/api/subscription_items/list
|
|
41
41
|
const schema = Joi.object<{
|
|
42
42
|
page: number;
|
|
43
|
-
|
|
43
|
+
pageSize: number;
|
|
44
44
|
subscription_id: string;
|
|
45
45
|
livemode?: boolean;
|
|
46
46
|
}>({
|
|
47
47
|
page: Joi.number().integer().min(1).default(1),
|
|
48
|
-
|
|
48
|
+
pageSize: Joi.number().integer().min(1).max(100).default(20),
|
|
49
49
|
subscription_id: Joi.string().required(),
|
|
50
50
|
livemode: Joi.boolean().empty(''),
|
|
51
51
|
});
|
|
52
52
|
router.get('/', auth, async (req, res) => {
|
|
53
|
-
const { page,
|
|
53
|
+
const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
54
54
|
const where: WhereOptions<SubscriptionItem> = { subscription_id: query.subscription_id };
|
|
55
55
|
|
|
56
56
|
if (typeof query.livemode === 'boolean') {
|
|
@@ -61,8 +61,8 @@ router.get('/', auth, async (req, res) => {
|
|
|
61
61
|
const { rows, count } = await SubscriptionItem.findAndCountAll({
|
|
62
62
|
where,
|
|
63
63
|
order: [['created_at', 'DESC']],
|
|
64
|
-
offset: (page - 1) *
|
|
65
|
-
limit:
|
|
64
|
+
offset: (page - 1) * pageSize,
|
|
65
|
+
limit: pageSize,
|
|
66
66
|
include: [],
|
|
67
67
|
});
|
|
68
68
|
|
|
@@ -29,27 +29,40 @@ const authPortal = authenticate<Subscription>({
|
|
|
29
29
|
},
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
const updateStripSubscription = async (doc: Subscription, updates: any) => {
|
|
33
|
+
if (doc.payment_details?.stripe?.subscription_id) {
|
|
34
|
+
const method = await PaymentMethod.findByPk(doc.default_payment_method_id);
|
|
35
|
+
if (method && method.type === 'stripe') {
|
|
36
|
+
const client = method.getStripe();
|
|
37
|
+
await client.subscriptions.update(doc.payment_details.stripe.subscription_id, updates);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
32
42
|
const schema = Joi.object<{
|
|
33
43
|
page: number;
|
|
34
|
-
|
|
44
|
+
pageSize: number;
|
|
35
45
|
status?: string;
|
|
36
46
|
customer_id?: string;
|
|
37
47
|
customer_did?: string;
|
|
38
48
|
livemode?: boolean;
|
|
39
49
|
}>({
|
|
40
50
|
page: Joi.number().integer().min(1).default(1),
|
|
41
|
-
|
|
51
|
+
pageSize: Joi.number().integer().min(1).max(100).default(20),
|
|
42
52
|
status: Joi.string().empty(''),
|
|
43
53
|
customer_id: Joi.string().empty(''),
|
|
44
54
|
customer_did: Joi.string().empty(''),
|
|
45
55
|
livemode: Joi.boolean().empty(''),
|
|
46
56
|
});
|
|
47
57
|
router.get('/', authMine, async (req, res) => {
|
|
48
|
-
const { page,
|
|
58
|
+
const { page, pageSize, status, livemode, ...query } = await schema.validateAsync(req.query, {
|
|
59
|
+
stripUnknown: false,
|
|
60
|
+
allowUnknown: true,
|
|
61
|
+
});
|
|
49
62
|
const where: WhereOptions<Subscription> = {};
|
|
50
63
|
|
|
51
|
-
if (
|
|
52
|
-
where.status =
|
|
64
|
+
if (status) {
|
|
65
|
+
where.status = status
|
|
53
66
|
.split(',')
|
|
54
67
|
.map((x) => x.trim())
|
|
55
68
|
.filter(Boolean);
|
|
@@ -63,16 +76,23 @@ router.get('/', authMine, async (req, res) => {
|
|
|
63
76
|
where.customer_id = customer.id;
|
|
64
77
|
}
|
|
65
78
|
}
|
|
66
|
-
if (typeof
|
|
67
|
-
where.livemode =
|
|
79
|
+
if (typeof livemode === 'boolean') {
|
|
80
|
+
where.livemode = livemode;
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
Object.keys(query)
|
|
84
|
+
.filter((x) => x.startsWith('metadata.'))
|
|
85
|
+
.forEach((key: string) => {
|
|
86
|
+
// @ts-ignore
|
|
87
|
+
where[key] = query[key];
|
|
88
|
+
});
|
|
89
|
+
|
|
70
90
|
try {
|
|
71
91
|
const { rows: list, count } = await Subscription.findAndCountAll({
|
|
72
92
|
where,
|
|
73
93
|
order: [['created_at', 'DESC']],
|
|
74
|
-
offset: (page - 1) *
|
|
75
|
-
limit:
|
|
94
|
+
offset: (page - 1) * pageSize,
|
|
95
|
+
limit: pageSize,
|
|
76
96
|
include: [
|
|
77
97
|
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
78
98
|
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
@@ -147,6 +167,7 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
147
167
|
updates.cancel_at_period_end = true;
|
|
148
168
|
updates.cancel_at = doc.current_period_end;
|
|
149
169
|
updates.cancelation_details = { reason: 'cancellation_requested', feedback, comment };
|
|
170
|
+
updates.canceled_at = dayjs().unix();
|
|
150
171
|
} else if (at === 'now') {
|
|
151
172
|
updates.status = 'canceled';
|
|
152
173
|
updates.cancel_at = dayjs().unix();
|
|
@@ -154,8 +175,10 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
154
175
|
} else if (at === 'current_period_end') {
|
|
155
176
|
updates.cancel_at_period_end = true;
|
|
156
177
|
updates.cancel_at = doc.current_period_end;
|
|
178
|
+
updates.canceled_at = dayjs().unix();
|
|
157
179
|
} else {
|
|
158
180
|
updates.cancel_at = dayjs(time).unix();
|
|
181
|
+
updates.canceled_at = dayjs().unix();
|
|
159
182
|
subscriptionQueue.push({
|
|
160
183
|
id: `cancel-${doc.id}`,
|
|
161
184
|
job: { subscriptionId: doc.id, action: 'cancel' },
|
|
@@ -163,6 +186,23 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
163
186
|
});
|
|
164
187
|
}
|
|
165
188
|
|
|
189
|
+
if (doc.payment_details?.stripe?.subscription_id) {
|
|
190
|
+
const method = await PaymentMethod.findByPk(doc.default_payment_method_id);
|
|
191
|
+
if (method && method.type === 'stripe') {
|
|
192
|
+
const client = method.getStripe();
|
|
193
|
+
if (updates.cancel_at_period_end) {
|
|
194
|
+
await client.subscriptions.update(doc.payment_details.stripe.subscription_id, {
|
|
195
|
+
cancel_at_period_end: updates.cancel_at_period_end,
|
|
196
|
+
});
|
|
197
|
+
} else {
|
|
198
|
+
await client.subscriptions.update(doc.payment_details.stripe.subscription_id, {
|
|
199
|
+
cancel_at: updates.cancel_at,
|
|
200
|
+
proration_behavior: 'none',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
166
206
|
await doc.update(updates);
|
|
167
207
|
|
|
168
208
|
return res.json(doc);
|
|
@@ -181,7 +221,8 @@ router.put('/:id/recover', authPortal, async (req, res) => {
|
|
|
181
221
|
return res.status(400).json({ error: 'Subscription not recoverable from cancellation' });
|
|
182
222
|
}
|
|
183
223
|
|
|
184
|
-
await doc
|
|
224
|
+
await updateStripSubscription(doc, { cancel_at_period_end: false });
|
|
225
|
+
await doc.update({ cancel_at_period_end: false });
|
|
185
226
|
|
|
186
227
|
// reschedule jobs
|
|
187
228
|
subscriptionQueue
|
|
@@ -213,6 +254,14 @@ router.put('/:id/pause', auth, async (req, res) => {
|
|
|
213
254
|
}
|
|
214
255
|
|
|
215
256
|
const timestamp = type === 'custom' ? dayjs(resumesAt).unix() : 0;
|
|
257
|
+
|
|
258
|
+
await updateStripSubscription(doc, {
|
|
259
|
+
pause_collection: {
|
|
260
|
+
resumes_at: timestamp || null,
|
|
261
|
+
behavior: behavior || 'keep_as_draft',
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
216
265
|
await doc.update({
|
|
217
266
|
status: 'paused',
|
|
218
267
|
pause_collection: {
|
|
@@ -242,7 +291,9 @@ router.put('/:id/resume', auth, async (req, res) => {
|
|
|
242
291
|
return res.status(400).json({ error: 'Subscription not paused' });
|
|
243
292
|
}
|
|
244
293
|
|
|
294
|
+
await updateStripSubscription(doc, { pause_collection: null });
|
|
245
295
|
await doc.update({ status: 'active', pause_collection: undefined });
|
|
296
|
+
|
|
246
297
|
subscriptionQueue
|
|
247
298
|
.cancel(`resume-${doc.id}`)
|
|
248
299
|
.then(() => logger.info('subscription resume job is canceled'))
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import { Router } from 'express';
|
|
3
3
|
import Joi from 'joi';
|
|
4
4
|
import pick from 'lodash/pick';
|
|
5
|
+
import { Op } from 'sequelize';
|
|
5
6
|
|
|
7
|
+
import { forwardUsageRecordToStripe } from '../integrations/stripe/resource';
|
|
6
8
|
import dayjs from '../libs/dayjs';
|
|
7
9
|
import { authenticate } from '../libs/security';
|
|
8
|
-
import { formatMetadata } from '../libs/util';
|
|
9
10
|
import { Invoice, Price, Subscription, SubscriptionItem, UsageRecord } from '../store/models';
|
|
10
11
|
|
|
11
12
|
const router = Router();
|
|
@@ -13,11 +14,7 @@ const auth = authenticate<UsageRecord>({ component: true, roles: ['owner', 'admi
|
|
|
13
14
|
|
|
14
15
|
// @link https://stripe.com/docs/api/usage_records/create
|
|
15
16
|
router.post('/', auth, async (req, res) => {
|
|
16
|
-
const raw: Partial<UsageRecord> = pick(req.body, ['timestamp', 'quantity', 'subscription_item_id'
|
|
17
|
-
if (raw.metadata) {
|
|
18
|
-
raw.metadata = formatMetadata(raw.metadata);
|
|
19
|
-
}
|
|
20
|
-
|
|
17
|
+
const raw: Partial<UsageRecord> = pick(req.body, ['timestamp', 'quantity', 'subscription_item_id']);
|
|
21
18
|
const item = await SubscriptionItem.findByPk(raw.subscription_item_id);
|
|
22
19
|
if (!item) {
|
|
23
20
|
return res.status(400).json({ error: `SubscriptionItem not found: ${raw.subscription_item_id}` });
|
|
@@ -28,10 +25,10 @@ router.post('/', auth, async (req, res) => {
|
|
|
28
25
|
raw.timestamp = dayjs().unix();
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
if (
|
|
28
|
+
let doc = await UsageRecord.findOne({ where: { timestamp: raw.timestamp } });
|
|
29
|
+
if (doc) {
|
|
33
30
|
if (req.body.action === 'increment') {
|
|
34
|
-
await
|
|
31
|
+
await doc.increment('quantity', { by: raw.quantity });
|
|
35
32
|
} else {
|
|
36
33
|
const subscription = await Subscription.findByPk(item.subscription_id);
|
|
37
34
|
if (subscription?.billing_thresholds) {
|
|
@@ -39,29 +36,36 @@ router.post('/', auth, async (req, res) => {
|
|
|
39
36
|
.status(400)
|
|
40
37
|
.json({ error: 'UsageRecord action must be increment for subscriptions with billing_thresholds' });
|
|
41
38
|
}
|
|
42
|
-
await
|
|
39
|
+
await doc.update({ quantity: raw.quantity });
|
|
43
40
|
}
|
|
41
|
+
} else {
|
|
42
|
+
raw.livemode = req.livemode;
|
|
43
|
+
doc = await UsageRecord.create(raw as UsageRecord);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
await forwardUsageRecordToStripe(item, {
|
|
47
|
+
quantity: Number(raw.quantity),
|
|
48
|
+
timestamp: raw.timestamp,
|
|
49
|
+
action: req.body.action,
|
|
50
|
+
});
|
|
51
|
+
|
|
48
52
|
return res.json(doc);
|
|
49
53
|
});
|
|
50
54
|
|
|
51
55
|
// @link https://stripe.com/docs/api/usage_records/subscription_item_summary_list
|
|
52
56
|
const schema = Joi.object<{
|
|
53
57
|
page: number;
|
|
54
|
-
|
|
58
|
+
pageSize: number;
|
|
55
59
|
subscription_item_id: string;
|
|
56
60
|
livemode?: boolean;
|
|
57
61
|
}>({
|
|
58
62
|
page: Joi.number().integer().min(1).default(1),
|
|
59
|
-
|
|
63
|
+
pageSize: Joi.number().integer().min(1).max(100).default(20),
|
|
60
64
|
subscription_item_id: Joi.string().required(),
|
|
61
65
|
livemode: Joi.boolean().empty(''),
|
|
62
66
|
});
|
|
63
|
-
router.get('/', auth, async (req, res) => {
|
|
64
|
-
const { page,
|
|
67
|
+
router.get('/summary', auth, async (req, res) => {
|
|
68
|
+
const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
65
69
|
|
|
66
70
|
try {
|
|
67
71
|
const item = await SubscriptionItem.findByPk(query.subscription_item_id, {
|
|
@@ -85,8 +89,8 @@ router.get('/', auth, async (req, res) => {
|
|
|
85
89
|
where: { subscription_id: item.subscription_id },
|
|
86
90
|
attributes: ['id', 'period_end', 'period_start'],
|
|
87
91
|
order: [['created_at', 'DESC']],
|
|
88
|
-
offset: (page - 1) *
|
|
89
|
-
limit:
|
|
92
|
+
offset: (page - 1) * pageSize,
|
|
93
|
+
limit: pageSize,
|
|
90
94
|
});
|
|
91
95
|
|
|
92
96
|
const list = await Promise.all(
|
|
@@ -117,4 +121,34 @@ router.get('/', auth, async (req, res) => {
|
|
|
117
121
|
}
|
|
118
122
|
});
|
|
119
123
|
|
|
124
|
+
router.get('/', auth, async (req, res) => {
|
|
125
|
+
const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const item = await SubscriptionItem.findByPk(query.subscription_item_id);
|
|
129
|
+
if (!item) {
|
|
130
|
+
return res.status(400).json({ error: `SubscriptionItem not found: ${query.subscription_item_id}` });
|
|
131
|
+
}
|
|
132
|
+
const subscription = await Subscription.findByPk(item.subscription_id);
|
|
133
|
+
if (!subscription) {
|
|
134
|
+
return res.status(400).json({ error: `Subscription not found: ${item.subscription_id}` });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { rows: list, count } = await UsageRecord.findAndCountAll({
|
|
138
|
+
where: {
|
|
139
|
+
subscription_item_id: query.subscription_item_id,
|
|
140
|
+
timestamp: { [Op.gte]: subscription.current_period_start, [Op.lt]: subscription.current_period_end },
|
|
141
|
+
},
|
|
142
|
+
order: [['created_at', 'DESC']],
|
|
143
|
+
offset: (page - 1) * pageSize,
|
|
144
|
+
limit: pageSize,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
res.json({ count, list });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(err);
|
|
150
|
+
res.json({ count: 0, list: [] });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
120
154
|
export default router;
|
|
@@ -10,19 +10,19 @@ const auth = authenticate<WebhookAttempt>({ component: true, roles: ['owner', 'a
|
|
|
10
10
|
|
|
11
11
|
const schema = Joi.object<{
|
|
12
12
|
page: number;
|
|
13
|
-
|
|
13
|
+
pageSize: number;
|
|
14
14
|
livemode?: boolean;
|
|
15
15
|
event_id?: string;
|
|
16
16
|
webhook_endpoint_id?: string;
|
|
17
17
|
}>({
|
|
18
18
|
page: Joi.number().integer().min(1).default(1),
|
|
19
|
-
|
|
19
|
+
pageSize: Joi.number().integer().min(1).max(100).default(20),
|
|
20
20
|
livemode: Joi.boolean().empty(''),
|
|
21
21
|
event_id: Joi.string().empty(''),
|
|
22
22
|
webhook_endpoint_id: Joi.string().empty(''),
|
|
23
23
|
});
|
|
24
24
|
router.get('/', auth, async (req, res) => {
|
|
25
|
-
const { page,
|
|
25
|
+
const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
26
26
|
const where: WhereOptions<WebhookAttempt> = {};
|
|
27
27
|
|
|
28
28
|
if (typeof query.livemode === 'boolean') {
|
|
@@ -39,8 +39,8 @@ router.get('/', auth, async (req, res) => {
|
|
|
39
39
|
const { rows: list, count } = await WebhookAttempt.findAndCountAll({
|
|
40
40
|
where,
|
|
41
41
|
order: [['created_at', 'DESC']],
|
|
42
|
-
offset: (page - 1) *
|
|
43
|
-
limit:
|
|
42
|
+
offset: (page - 1) * pageSize,
|
|
43
|
+
limit: pageSize,
|
|
44
44
|
include: [
|
|
45
45
|
{ model: Event, as: 'event' },
|
|
46
46
|
{ model: WebhookEndpoint, as: 'endpoint' },
|