payment-kit 1.19.0 → 1.19.2
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/crons/index.ts +8 -0
- package/api/src/index.ts +4 -0
- package/api/src/libs/credit-grant.ts +146 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +4 -3
- package/api/src/libs/notification/template/base.ts +388 -2
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
- package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
- package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
- package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
- package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
- package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
- package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
- package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
- package/api/src/libs/payment.ts +69 -0
- package/api/src/libs/queue/index.ts +3 -2
- package/api/src/libs/session.ts +8 -0
- package/api/src/libs/subscription.ts +74 -3
- package/api/src/libs/util.ts +3 -1
- package/api/src/libs/ws.ts +23 -1
- package/api/src/locales/en.ts +33 -0
- package/api/src/locales/zh.ts +31 -0
- package/api/src/queues/credit-consume.ts +728 -0
- package/api/src/queues/credit-grant.ts +572 -0
- package/api/src/queues/notification.ts +173 -128
- package/api/src/queues/payment.ts +210 -122
- package/api/src/queues/subscription.ts +179 -0
- package/api/src/routes/checkout-sessions.ts +157 -9
- package/api/src/routes/connect/shared.ts +3 -2
- package/api/src/routes/credit-grants.ts +241 -0
- package/api/src/routes/credit-transactions.ts +208 -0
- package/api/src/routes/customers.ts +34 -5
- package/api/src/routes/index.ts +8 -0
- package/api/src/routes/meter-events.ts +347 -0
- package/api/src/routes/meters.ts +219 -0
- package/api/src/routes/payment-currencies.ts +20 -2
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +14 -2
- package/api/src/routes/prices.ts +43 -0
- package/api/src/routes/pricing-table.ts +13 -7
- package/api/src/routes/products.ts +63 -4
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/subscriptions.ts +4 -0
- package/api/src/routes/webhook-endpoints.ts +0 -3
- package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
- package/api/src/store/models/credit-grant.ts +486 -0
- package/api/src/store/models/credit-transaction.ts +268 -0
- package/api/src/store/models/customer.ts +8 -0
- package/api/src/store/models/index.ts +52 -1
- package/api/src/store/models/meter-event.ts +423 -0
- package/api/src/store/models/meter.ts +176 -0
- package/api/src/store/models/payment-currency.ts +66 -14
- package/api/src/store/models/price.ts +6 -0
- package/api/src/store/models/product.ts +2 -2
- package/api/src/store/models/subscription.ts +24 -0
- package/api/src/store/models/types.ts +28 -2
- package/api/tests/libs/subscription.spec.ts +53 -0
- package/blocklet.yml +9 -1
- package/package.json +4 -4
- package/scripts/sdk.js +233 -1
- package/src/app.tsx +10 -0
- package/src/components/collapse.tsx +11 -1
- package/src/components/conditional-section.tsx +87 -0
- package/src/components/customer/credit-grant-item-list.tsx +99 -0
- package/src/components/customer/credit-overview.tsx +246 -0
- package/src/components/customer/form.tsx +7 -3
- package/src/components/invoice/list.tsx +19 -1
- package/src/components/metadata/form.tsx +287 -91
- package/src/components/meter/actions.tsx +101 -0
- package/src/components/meter/add-usage-dialog.tsx +239 -0
- package/src/components/meter/events-list.tsx +657 -0
- package/src/components/meter/form.tsx +245 -0
- package/src/components/meter/products.tsx +264 -0
- package/src/components/meter/usage-guide.tsx +174 -0
- package/src/components/payment-currency/form.tsx +2 -0
- package/src/components/payment-intent/list.tsx +19 -1
- package/src/components/payment-link/item.tsx +2 -2
- package/src/components/payment-link/preview.tsx +1 -1
- package/src/components/payment-link/product-select.tsx +52 -12
- package/src/components/payment-method/arcblock.tsx +2 -0
- package/src/components/payment-method/base.tsx +2 -0
- package/src/components/payment-method/bitcoin.tsx +2 -0
- package/src/components/payment-method/ethereum.tsx +2 -0
- package/src/components/payment-method/stripe.tsx +2 -0
- package/src/components/payouts/list.tsx +19 -1
- package/src/components/payouts/portal/list.tsx +6 -11
- package/src/components/price/currency-select.tsx +56 -32
- package/src/components/price/form.tsx +912 -407
- package/src/components/pricing-table/preview.tsx +1 -1
- package/src/components/product/add-price.tsx +9 -7
- package/src/components/product/create.tsx +7 -4
- package/src/components/product/edit-price.tsx +21 -12
- package/src/components/product/features.tsx +17 -7
- package/src/components/product/form.tsx +100 -90
- package/src/components/refund/list.tsx +19 -1
- package/src/components/section/header.tsx +5 -18
- package/src/components/subscription/items/index.tsx +1 -1
- package/src/components/subscription/metrics.tsx +37 -5
- package/src/components/subscription/portal/actions.tsx +2 -1
- package/src/contexts/products.tsx +26 -9
- package/src/hooks/subscription.ts +34 -0
- package/src/libs/meter-utils.ts +196 -0
- package/src/libs/util.ts +4 -0
- package/src/locales/en.tsx +389 -5
- package/src/locales/zh.tsx +368 -1
- package/src/pages/admin/billing/index.tsx +61 -33
- package/src/pages/admin/billing/invoices/detail.tsx +1 -1
- package/src/pages/admin/billing/meters/create.tsx +60 -0
- package/src/pages/admin/billing/meters/detail.tsx +435 -0
- package/src/pages/admin/billing/meters/index.tsx +210 -0
- package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
- package/src/pages/admin/customers/customers/detail.tsx +14 -10
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/admin/developers/events/detail.tsx +1 -1
- package/src/pages/admin/developers/index.tsx +1 -1
- package/src/pages/admin/payments/intents/detail.tsx +1 -1
- package/src/pages/admin/payments/payouts/detail.tsx +1 -1
- package/src/pages/admin/payments/refunds/detail.tsx +1 -1
- package/src/pages/admin/products/index.tsx +3 -2
- package/src/pages/admin/products/links/detail.tsx +1 -1
- package/src/pages/admin/products/prices/actions.tsx +16 -4
- package/src/pages/admin/products/prices/detail.tsx +30 -3
- package/src/pages/admin/products/prices/list.tsx +8 -1
- package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
- package/src/pages/admin/products/products/create.tsx +233 -57
- package/src/pages/admin/products/products/detail.tsx +2 -1
- package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
- package/src/pages/customer/credit-grant/detail.tsx +308 -0
- package/src/pages/customer/index.tsx +44 -9
- package/src/pages/customer/recharge/account.tsx +5 -5
- package/src/pages/customer/subscription/change-payment.tsx +4 -2
- package/src/pages/customer/subscription/detail.tsx +48 -14
- package/src/pages/customer/subscription/embed.tsx +1 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import Joi from 'joi';
|
|
3
|
+
import pick from 'lodash/pick';
|
|
4
|
+
|
|
5
|
+
import { Op } from 'sequelize';
|
|
6
|
+
import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
|
|
7
|
+
import logger from '../libs/logger';
|
|
8
|
+
import { authenticate } from '../libs/security';
|
|
9
|
+
import { formatMetadata } from '../libs/util';
|
|
10
|
+
import { Meter, PaymentCurrency, PaymentMethod } from '../store/models';
|
|
11
|
+
|
|
12
|
+
const router = Router();
|
|
13
|
+
const auth = authenticate<Meter>({ component: true, roles: ['owner', 'admin'] });
|
|
14
|
+
|
|
15
|
+
const meterSchema = Joi.object({
|
|
16
|
+
name: Joi.string().max(64).required(),
|
|
17
|
+
event_name: Joi.string().max(64).required(),
|
|
18
|
+
aggregation_method: Joi.string().valid('sum', 'count', 'last').default('sum'),
|
|
19
|
+
unit: Joi.string().max(32).required(),
|
|
20
|
+
currency_id: Joi.string().max(40).optional(),
|
|
21
|
+
description: Joi.string().max(255).allow('').optional(),
|
|
22
|
+
metadata: MetadataSchema,
|
|
23
|
+
component_did: Joi.string().max(40).optional(),
|
|
24
|
+
}).unknown(true);
|
|
25
|
+
|
|
26
|
+
const updateMeterSchema = Joi.object({
|
|
27
|
+
name: Joi.string().max(64).optional(),
|
|
28
|
+
description: Joi.string().max(255).allow('').optional(),
|
|
29
|
+
status: Joi.string().valid('active', 'inactive').optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const listSchema = createListParamSchema<{ event_name?: string }>({
|
|
33
|
+
event_name: Joi.string().empty(''),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
router.get('/', auth, async (req, res) => {
|
|
37
|
+
try {
|
|
38
|
+
const { page, pageSize, ...query } = await listSchema.validateAsync(req.query, { stripUnknown: true });
|
|
39
|
+
const where = getWhereFromKvQuery(query.q);
|
|
40
|
+
|
|
41
|
+
if (typeof query.livemode === 'boolean') {
|
|
42
|
+
where.livemode = query.livemode;
|
|
43
|
+
}
|
|
44
|
+
if (query.event_name) {
|
|
45
|
+
where.event_name = query.event_name;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { rows: list, count } = await Meter.findAndCountAll({
|
|
49
|
+
where,
|
|
50
|
+
order: getOrder(query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
|
|
51
|
+
offset: (page - 1) * pageSize,
|
|
52
|
+
limit: pageSize,
|
|
53
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
res.json({ count, list, paging: { page, pageSize } });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logger.error('Error listing meters', err);
|
|
59
|
+
res.status(400).json({ error: err?.message });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
router.post('/', auth, async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
const { error } = meterSchema.validate(req.body);
|
|
66
|
+
if (error) {
|
|
67
|
+
return res.status(400).json({ error: `Meter create request invalid: ${error.message}` });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const existing = await Meter.findOne({
|
|
71
|
+
where: { event_name: req.body.event_name, livemode: !!req.livemode },
|
|
72
|
+
});
|
|
73
|
+
if (existing) {
|
|
74
|
+
return res.status(409).json({ error: `Meter with event_name "${req.body.event_name}" already exists` });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (['count', 'last'].includes(req.body.aggregation_method)) {
|
|
78
|
+
return res.status(400).json({ error: 'Aggregation method is not supported' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const meterData = {
|
|
82
|
+
...pick(req.body, ['name', 'event_name', 'aggregation_method', 'unit', 'currency_id', 'description', 'metadata']),
|
|
83
|
+
livemode: !!req.livemode,
|
|
84
|
+
created_via: req.user?.via || 'api',
|
|
85
|
+
status: req.body.status || 'active',
|
|
86
|
+
metadata: formatMetadata(req.body.metadata),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (!meterData.currency_id) {
|
|
90
|
+
const paymentMethod = await PaymentMethod.findOne({
|
|
91
|
+
where: {
|
|
92
|
+
livemode: !!req.livemode,
|
|
93
|
+
type: 'arcblock',
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
if (!paymentMethod) {
|
|
97
|
+
return res.status(400).json({ error: 'Payment method not found' });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const paymentCurrency = await PaymentCurrency.createForMeter(meterData, paymentMethod.id);
|
|
101
|
+
meterData.currency_id = paymentCurrency.id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const meter = await Meter.create(meterData);
|
|
105
|
+
|
|
106
|
+
const result = await Meter.findByPk(meter.id, {
|
|
107
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
logger.info('Meter created', { meterId: meter.id, eventName: meter.event_name });
|
|
111
|
+
return res.json(result);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logger.error('create meter failed', { error: err?.message, request: req.body });
|
|
114
|
+
return res.status(400).json({ error: err?.message });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
router.get('/:id', auth, async (req, res) => {
|
|
119
|
+
try {
|
|
120
|
+
const meter = await Meter.findOne({
|
|
121
|
+
where: {
|
|
122
|
+
[Op.or]: [{ id: req.params.id }, { event_name: req.params.id }],
|
|
123
|
+
},
|
|
124
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!meter) {
|
|
128
|
+
return res.status(404).json({ error: 'Meter not found' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return res.json(meter);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger.error('get meter failed', { error: err?.message, meterId: req.params.id });
|
|
134
|
+
return res.status(400).json({ error: err?.message });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
router.put('/:id', auth, async (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
const { error } = updateMeterSchema.validate(pick(req.body, ['name', 'description', 'status']));
|
|
141
|
+
if (error) {
|
|
142
|
+
return res.status(400).json({ error: `Meter update request invalid: ${error.message}` });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const meter = await Meter.findByPk(req.params.id, {
|
|
146
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
147
|
+
});
|
|
148
|
+
if (!meter) {
|
|
149
|
+
return res.status(404).json({ error: 'Meter not found' });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const updateData: any = {
|
|
153
|
+
...pick(req.body, ['name', 'description', 'status']),
|
|
154
|
+
updated_by: req.user?.did,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (req.body.metadata) {
|
|
158
|
+
updateData.metadata = formatMetadata(req.body.metadata);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await meter.update(updateData);
|
|
162
|
+
|
|
163
|
+
return res.json(meter);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
logger.error('update meter failed', { error: err?.message, meterId: req.params.id });
|
|
166
|
+
return res.status(400).json({ error: err?.message });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
router.put('/:id/activate', auth, async (req, res) => {
|
|
171
|
+
try {
|
|
172
|
+
const meter = await Meter.findByPk(req.params.id, {
|
|
173
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
174
|
+
});
|
|
175
|
+
if (!meter) {
|
|
176
|
+
return res.status(404).json({ error: 'Meter not found' });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (meter.status === 'active') {
|
|
180
|
+
return res.status(400).json({ error: 'Meter is already active' });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await meter.update({
|
|
184
|
+
status: 'active',
|
|
185
|
+
updated_by: req.user?.did,
|
|
186
|
+
});
|
|
187
|
+
return res.json(meter);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logger.error('activate meter failed', { error: err?.message, meterId: req.params.id });
|
|
190
|
+
return res.status(400).json({ error: err?.message });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
router.put('/:id/deactivate', auth, async (req, res) => {
|
|
195
|
+
try {
|
|
196
|
+
const meter = await Meter.findByPk(req.params.id, {
|
|
197
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
198
|
+
});
|
|
199
|
+
if (!meter) {
|
|
200
|
+
return res.status(404).json({ error: 'Meter not found' });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (meter.status === 'inactive') {
|
|
204
|
+
return res.status(400).json({ error: 'Meter is already inactive' });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await meter.update({
|
|
208
|
+
status: 'inactive',
|
|
209
|
+
updated_by: req.user?.did,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return res.json(meter);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
logger.error('deactivate meter failed', { error: err?.message, meterId: req.params.id });
|
|
215
|
+
return res.status(400).json({ error: err?.message });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
export default router;
|
|
@@ -3,6 +3,7 @@ import { Router } from 'express';
|
|
|
3
3
|
import { InferAttributes, Op, WhereOptions } from 'sequelize';
|
|
4
4
|
|
|
5
5
|
import Joi from 'joi';
|
|
6
|
+
import pick from 'lodash/pick';
|
|
6
7
|
import { fetchErc20Meta } from '../integrations/ethereum/token';
|
|
7
8
|
import logger from '../libs/logger';
|
|
8
9
|
import { authenticate } from '../libs/security';
|
|
@@ -19,9 +20,18 @@ const router = Router();
|
|
|
19
20
|
|
|
20
21
|
const auth = authenticate<PaymentCurrency>({ component: true, roles: ['owner', 'admin'] });
|
|
21
22
|
const authOwner = authenticate<PaymentCurrency>({ component: true, roles: ['owner'] });
|
|
23
|
+
const paymentCurrencyCreateSchema = Joi.object({
|
|
24
|
+
name: Joi.string().max(32).required(),
|
|
25
|
+
description: Joi.string().max(255).required(),
|
|
26
|
+
}).unknown(true);
|
|
22
27
|
router.post('/', auth, async (req, res) => {
|
|
23
28
|
const raw: Partial<TPaymentCurrency> = req.body;
|
|
24
29
|
|
|
30
|
+
const { error } = paymentCurrencyCreateSchema.validate(pick(req.body, ['name', 'description']));
|
|
31
|
+
if (error) {
|
|
32
|
+
return res.status(400).json({ error: error.message });
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
if (!raw.payment_method_id) {
|
|
26
36
|
return res.status(400).json({ error: 'payment_method_id is required' });
|
|
27
37
|
}
|
|
@@ -73,6 +83,7 @@ router.post('/', auth, async (req, res) => {
|
|
|
73
83
|
logo: raw.logo,
|
|
74
84
|
symbol: info.symbol,
|
|
75
85
|
decimal: info.decimal,
|
|
86
|
+
type: 'standard',
|
|
76
87
|
|
|
77
88
|
// FIXME: make these configurable
|
|
78
89
|
minimum_payment_amount: fromTokenToUnit(0.000001, info.decimal).toString(),
|
|
@@ -109,6 +120,7 @@ router.post('/', auth, async (req, res) => {
|
|
|
109
120
|
logo: raw.logo,
|
|
110
121
|
symbol: state.symbol,
|
|
111
122
|
decimal: state.decimal,
|
|
123
|
+
type: 'standard',
|
|
112
124
|
|
|
113
125
|
// FIXME: make these configurable
|
|
114
126
|
minimum_payment_amount: fromTokenToUnit(0.000001, state.decimal).toString(),
|
|
@@ -136,6 +148,12 @@ router.get('/', auth, async (req, res) => {
|
|
|
136
148
|
if (typeof query.livemode === 'string') {
|
|
137
149
|
where.livemode = JSON.parse(query.livemode);
|
|
138
150
|
}
|
|
151
|
+
where.type = 'standard';
|
|
152
|
+
if (query.credit) {
|
|
153
|
+
where.type = {
|
|
154
|
+
[Op.in]: ['standard', 'credit'],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
139
157
|
const list = await PaymentCurrency.findAll({
|
|
140
158
|
where,
|
|
141
159
|
order: [['created_at', 'DESC']],
|
|
@@ -283,8 +301,8 @@ router.put('/:id/vault-config', authOwner, async (req, res) => {
|
|
|
283
301
|
});
|
|
284
302
|
|
|
285
303
|
const updateCurrencySchema = Joi.object({
|
|
286
|
-
name: Joi.string().empty('').optional(),
|
|
287
|
-
description: Joi.string().empty('').optional(),
|
|
304
|
+
name: Joi.string().empty('').max(32).optional(),
|
|
305
|
+
description: Joi.string().empty('').max(255).optional(),
|
|
288
306
|
logo: Joi.string().empty('').optional(),
|
|
289
307
|
}).unknown(true);
|
|
290
308
|
router.put('/:id', auth, async (req, res) => {
|
|
@@ -132,7 +132,7 @@ export async function createPaymentLink(payload: any) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
const items = await Price.expand(raw.line_items);
|
|
135
|
-
if (items.find((x) => x.price
|
|
135
|
+
if (items.find((x) => x.price?.custom_unit_amount) && items.length > 1) {
|
|
136
136
|
throw new Error('Multiple items with custom unit amount are not supported in payment link');
|
|
137
137
|
}
|
|
138
138
|
|
|
@@ -21,12 +21,21 @@ const router = Router();
|
|
|
21
21
|
|
|
22
22
|
const auth = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'] });
|
|
23
23
|
|
|
24
|
+
const paymentMethodCreateSchema = Joi.object({
|
|
25
|
+
name: Joi.string().max(32).required(),
|
|
26
|
+
description: Joi.string().max(255).required(),
|
|
27
|
+
}).unknown(true);
|
|
28
|
+
|
|
24
29
|
router.post('/', auth, async (req, res) => {
|
|
25
30
|
const raw: Partial<TPaymentMethod> = req.body;
|
|
26
31
|
|
|
27
32
|
raw.livemode = req.livemode;
|
|
28
33
|
raw.locked = false;
|
|
29
34
|
raw.active = true;
|
|
35
|
+
const { error } = paymentMethodCreateSchema.validate(pick(req.body, ['name', 'description']));
|
|
36
|
+
if (error) {
|
|
37
|
+
return res.status(400).json({ error: error.message });
|
|
38
|
+
}
|
|
30
39
|
|
|
31
40
|
if (!raw.name) {
|
|
32
41
|
return res.status(400).json({ error: 'payment method name is required' });
|
|
@@ -77,12 +86,14 @@ router.post('/', auth, async (req, res) => {
|
|
|
77
86
|
locked: true,
|
|
78
87
|
is_base_currency: false,
|
|
79
88
|
payment_method_id: method.id,
|
|
89
|
+
type: 'standard',
|
|
80
90
|
|
|
81
91
|
name: 'Dollar',
|
|
82
92
|
description: 'US Dollar',
|
|
83
93
|
logo: getUrl('/currencies/dollar.png'),
|
|
84
94
|
symbol: 'USD', // same currency code as stripe
|
|
85
95
|
decimal: 2,
|
|
96
|
+
maximum_precision: 2,
|
|
86
97
|
|
|
87
98
|
minimum_payment_amount: '1', // cent
|
|
88
99
|
maximum_payment_amount: '100000000000', // billion
|
|
@@ -153,6 +164,7 @@ router.post('/', auth, async (req, res) => {
|
|
|
153
164
|
locked: true,
|
|
154
165
|
is_base_currency: false,
|
|
155
166
|
payment_method_id: method.id,
|
|
167
|
+
type: 'standard',
|
|
156
168
|
|
|
157
169
|
name: symbol,
|
|
158
170
|
description: symbol,
|
|
@@ -285,8 +297,8 @@ router.put('/:id/settings', auth, async (req, res) => {
|
|
|
285
297
|
});
|
|
286
298
|
|
|
287
299
|
const updateMethodSchema = Joi.object({
|
|
288
|
-
name: Joi.string().empty('').optional(),
|
|
289
|
-
description: Joi.string().empty('').optional(),
|
|
300
|
+
name: Joi.string().empty('').max(32).optional(),
|
|
301
|
+
description: Joi.string().empty('').max(255).optional(),
|
|
290
302
|
logo: Joi.string().empty('').optional(),
|
|
291
303
|
}).unknown(true);
|
|
292
304
|
router.put('/:id', auth, async (req, res) => {
|
package/api/src/routes/prices.ts
CHANGED
|
@@ -12,11 +12,21 @@ import { PaymentCurrency } from '../store/models/payment-currency';
|
|
|
12
12
|
import { Price } from '../store/models/price';
|
|
13
13
|
import { Product } from '../store/models/product';
|
|
14
14
|
import { checkCurrencySupportRecurring } from '../libs/product';
|
|
15
|
+
import { Meter } from '../store/models';
|
|
15
16
|
|
|
16
17
|
const router = Router();
|
|
17
18
|
|
|
18
19
|
const auth = authenticate<Price>({ component: true, roles: ['owner', 'admin'] });
|
|
19
20
|
|
|
21
|
+
const CreditConfigSchema = Joi.object({
|
|
22
|
+
valid_duration_value: Joi.number().default(0).optional(),
|
|
23
|
+
valid_duration_unit: Joi.string().valid('hours', 'days', 'weeks', 'months', 'years').default('days').optional(),
|
|
24
|
+
priority: Joi.number().min(0).max(100).default(50).optional(),
|
|
25
|
+
applicable_prices: Joi.array().items(Joi.string()).default([]).optional(),
|
|
26
|
+
credit_amount: Joi.number().greater(0).required(),
|
|
27
|
+
currency_id: Joi.string().required(),
|
|
28
|
+
});
|
|
29
|
+
|
|
20
30
|
export async function getExpandedPrice(id: string) {
|
|
21
31
|
const price = await Price.findByPkOrLookupKey(id, {
|
|
22
32
|
include: [
|
|
@@ -37,6 +47,10 @@ export async function getExpandedPrice(id: string) {
|
|
|
37
47
|
}
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
if (doc.recurring?.meter_id) {
|
|
51
|
+
// @ts-ignore
|
|
52
|
+
doc.meter = await Meter.findByPk(doc.recurring?.meter_id);
|
|
53
|
+
}
|
|
40
54
|
return doc;
|
|
41
55
|
}
|
|
42
56
|
|
|
@@ -143,6 +157,18 @@ export async function createPrice(payload: any) {
|
|
|
143
157
|
throw new Error(`product ${raw.product_id} not found for price`);
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
if (product.type === 'credit') {
|
|
161
|
+
const creditConfig = raw.metadata.credit_config;
|
|
162
|
+
if (!creditConfig) {
|
|
163
|
+
throw new Error('credit_config is required');
|
|
164
|
+
}
|
|
165
|
+
const { error, value: creditConfigValue } = CreditConfigSchema.validate(creditConfig);
|
|
166
|
+
if (error) {
|
|
167
|
+
throw new Error(`credit_config is invalid: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
raw.metadata.credit_config = creditConfigValue;
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
const currencies = await PaymentCurrency.findAll({ where: { active: true } });
|
|
147
173
|
const currency = currencies.find((x) => x.id === raw.currency_id);
|
|
148
174
|
if (!currency) {
|
|
@@ -296,6 +322,11 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
296
322
|
return res.status(404).json({ error: 'price not found' });
|
|
297
323
|
}
|
|
298
324
|
|
|
325
|
+
const product = await Product.findByPk(doc.product_id);
|
|
326
|
+
if (!product) {
|
|
327
|
+
return res.status(404).json({ error: 'product not found' });
|
|
328
|
+
}
|
|
329
|
+
|
|
299
330
|
if (doc.active === false) {
|
|
300
331
|
return res.status(403).json({ error: 'price archived' });
|
|
301
332
|
}
|
|
@@ -326,6 +357,18 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
326
357
|
}
|
|
327
358
|
}
|
|
328
359
|
|
|
360
|
+
if (product.type === 'credit' && updates.metadata) {
|
|
361
|
+
const creditConfig = updates.metadata.credit_config;
|
|
362
|
+
if (!creditConfig) {
|
|
363
|
+
return res.status(400).json({ error: 'credit_config is required' });
|
|
364
|
+
}
|
|
365
|
+
const { error: creditConfigError, value: creditConfigValue } = CreditConfigSchema.validate(creditConfig);
|
|
366
|
+
if (creditConfigError) {
|
|
367
|
+
return res.status(400).json({ error: `credit_config is invalid: ${creditConfigError.message}` });
|
|
368
|
+
}
|
|
369
|
+
updates.metadata.credit_config = creditConfigValue;
|
|
370
|
+
}
|
|
371
|
+
|
|
329
372
|
const currencies = await PaymentCurrency.findAll({ where: { active: true } });
|
|
330
373
|
const currency =
|
|
331
374
|
currencies.find((x) => x.id === updates?.currency_id || '') || currencies.find((x) => x.id === doc.currency_id);
|
|
@@ -11,7 +11,7 @@ import { checkPassportForPricingTable } from '../integrations/blocklet/passport'
|
|
|
11
11
|
import { createListParamSchema, getOrder, MetadataSchema } from '../libs/api';
|
|
12
12
|
import logger from '../libs/logger';
|
|
13
13
|
import { authenticate } from '../libs/security';
|
|
14
|
-
import { getBillingThreshold, getMinStakeAmount, isLineItemCurrencyAligned } from '../libs/session';
|
|
14
|
+
import { getBillingThreshold, getMinStakeAmount, isCreditMetered, isLineItemCurrencyAligned } from '../libs/session';
|
|
15
15
|
import { getDaysUntilCancel, getDaysUntilDue } from '../libs/subscription';
|
|
16
16
|
import { getDataObjectFromQuery } from '../libs/util';
|
|
17
17
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
@@ -292,13 +292,18 @@ router.post('/:id/checkout/:priceId', async (req, res) => {
|
|
|
292
292
|
return res.status(403).json({ error: 'pricing table locked' });
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
const
|
|
296
|
-
if (!
|
|
295
|
+
const lineItem = await doc.items.find((x) => x.price_id === req.params.priceId);
|
|
296
|
+
if (!lineItem) {
|
|
297
297
|
return res.status(403).json({ error: 'pricing table item not valid' });
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
const price = await Price.findByPk(lineItem.price_id);
|
|
301
|
+
if (!price) {
|
|
302
|
+
return res.status(403).json({ error: 'price not found' });
|
|
303
|
+
}
|
|
304
|
+
|
|
300
305
|
const raw: Partial<CheckoutSession> = await formatCheckoutSession({
|
|
301
|
-
line_items: [{ price_id:
|
|
306
|
+
line_items: [{ price_id: lineItem.price_id, quantity: 1, adjustable_quantity: lineItem.adjustable_quantity }],
|
|
302
307
|
...pick(price, [
|
|
303
308
|
'allow_promotion_codes',
|
|
304
309
|
'consent_collection',
|
|
@@ -311,9 +316,10 @@ router.post('/:id/checkout/:priceId', async (req, res) => {
|
|
|
311
316
|
'cross_sell_behavior',
|
|
312
317
|
'nft_mint_settings',
|
|
313
318
|
]),
|
|
314
|
-
subscription_data: merge(
|
|
315
|
-
billing_threshold_amount: getBillingThreshold(
|
|
316
|
-
min_stake_amount: getMinStakeAmount(
|
|
319
|
+
subscription_data: merge(lineItem.subscription_data || {}, getDataObjectFromQuery(req.query, 'subscription_data'), {
|
|
320
|
+
billing_threshold_amount: getBillingThreshold(lineItem.subscription_data),
|
|
321
|
+
min_stake_amount: getMinStakeAmount(lineItem.subscription_data),
|
|
322
|
+
no_stake: isCreditMetered(price),
|
|
317
323
|
}),
|
|
318
324
|
metadata: {
|
|
319
325
|
...doc.metadata,
|
|
@@ -21,7 +21,7 @@ const auth = authenticate<Product>({ component: true, roles: ['owner', 'admin']
|
|
|
21
21
|
|
|
22
22
|
const ProductAndPriceSchema = Joi.object({
|
|
23
23
|
name: Joi.string().max(64).empty('').optional(),
|
|
24
|
-
type: Joi.string().valid('service', 'good').empty('').optional(),
|
|
24
|
+
type: Joi.string().valid('service', 'good', 'credit').empty('').optional(),
|
|
25
25
|
description: Joi.string().max(250).empty('').optional(),
|
|
26
26
|
images: Joi.any().optional(),
|
|
27
27
|
metadata: MetadataSchema,
|
|
@@ -57,6 +57,15 @@ const ProductAndPriceSchema = Joi.object({
|
|
|
57
57
|
.optional(),
|
|
58
58
|
}).unknown(true);
|
|
59
59
|
|
|
60
|
+
const CreditConfigSchema = Joi.object({
|
|
61
|
+
valid_duration_value: Joi.number().default(0).optional(),
|
|
62
|
+
valid_duration_unit: Joi.string().valid('hours', 'days', 'weeks', 'months', 'years').default('days').optional(),
|
|
63
|
+
priority: Joi.number().min(0).max(100).default(50).optional(),
|
|
64
|
+
applicable_prices: Joi.array().items(Joi.string()).default([]).optional(),
|
|
65
|
+
credit_amount: Joi.number().greater(0).required(),
|
|
66
|
+
currency_id: Joi.string().required(),
|
|
67
|
+
});
|
|
68
|
+
|
|
60
69
|
export async function createProductAndPrices(payload: any) {
|
|
61
70
|
// 1. 准备 product 数据
|
|
62
71
|
const raw: Partial<Product> = pick(payload, [
|
|
@@ -96,6 +105,18 @@ export async function createProductAndPrices(payload: any) {
|
|
|
96
105
|
throw new Error(`currency ${newPrice.currency_id} used in price not found or inactive`);
|
|
97
106
|
}
|
|
98
107
|
|
|
108
|
+
if (raw.type === 'credit') {
|
|
109
|
+
const creditConfig = newPrice.metadata.credit_config;
|
|
110
|
+
if (!creditConfig) {
|
|
111
|
+
throw new Error('credit_config is required');
|
|
112
|
+
}
|
|
113
|
+
const { error, value: creditConfigValue } = CreditConfigSchema.validate(creditConfig);
|
|
114
|
+
if (error) {
|
|
115
|
+
throw new Error(`credit_config is invalid: ${error.message}`);
|
|
116
|
+
}
|
|
117
|
+
newPrice.metadata.credit_config = creditConfigValue;
|
|
118
|
+
}
|
|
119
|
+
|
|
99
120
|
if (newPrice.custom_unit_amount) {
|
|
100
121
|
// @ts-ignore
|
|
101
122
|
['preset', 'maximum', 'minimum'].forEach((key: keyof CustomUnitAmount) => {
|
|
@@ -213,12 +234,16 @@ const paginationSchema = createListParamSchema<{
|
|
|
213
234
|
name?: string;
|
|
214
235
|
description?: string;
|
|
215
236
|
donation?: string;
|
|
237
|
+
meter_id?: string;
|
|
238
|
+
type?: string;
|
|
216
239
|
}>({
|
|
217
240
|
active: Joi.boolean().empty(''),
|
|
218
241
|
status: Joi.string().empty(''),
|
|
219
242
|
name: Joi.string().empty(''),
|
|
220
243
|
description: Joi.string().empty(''),
|
|
221
244
|
donation: Joi.string().empty(''),
|
|
245
|
+
meter_id: Joi.string().empty(''),
|
|
246
|
+
type: Joi.string().empty(''),
|
|
222
247
|
});
|
|
223
248
|
router.get('/', auth, async (req, res) => {
|
|
224
249
|
const { page, pageSize, active, livemode, name, description, ...query } = await paginationSchema.validateAsync(
|
|
@@ -247,7 +272,6 @@ router.get('/', auth, async (req, res) => {
|
|
|
247
272
|
if (query.donation === 'hide') {
|
|
248
273
|
where.created_via = { [Op.not]: 'donation' };
|
|
249
274
|
}
|
|
250
|
-
|
|
251
275
|
Object.keys(query)
|
|
252
276
|
.filter((x) => x.startsWith('metadata.'))
|
|
253
277
|
.forEach((key: string) => {
|
|
@@ -255,14 +279,49 @@ router.get('/', auth, async (req, res) => {
|
|
|
255
279
|
where[key] = query[key];
|
|
256
280
|
});
|
|
257
281
|
|
|
258
|
-
|
|
282
|
+
if (query.type === 'credit') {
|
|
283
|
+
where.type = 'credit';
|
|
284
|
+
}
|
|
285
|
+
const findOptions: any = {
|
|
259
286
|
where,
|
|
260
287
|
order: getOrder(req.query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
|
|
261
288
|
offset: (page - 1) * pageSize,
|
|
262
289
|
limit: pageSize,
|
|
263
290
|
include: [{ model: Price, as: 'prices' }],
|
|
264
291
|
distinct: true,
|
|
265
|
-
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
if (query.type !== 'credit' && query.meter_id) {
|
|
295
|
+
findOptions.include = [
|
|
296
|
+
{
|
|
297
|
+
model: Price,
|
|
298
|
+
as: 'prices',
|
|
299
|
+
where: {
|
|
300
|
+
recurring: {
|
|
301
|
+
meter_id: query.meter_id,
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
required: true,
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (query.type === 'credit' && query.meter_id) {
|
|
310
|
+
findOptions.include = [
|
|
311
|
+
{
|
|
312
|
+
model: Price,
|
|
313
|
+
as: 'prices',
|
|
314
|
+
where: {
|
|
315
|
+
metadata: {
|
|
316
|
+
meter_id: query.meter_id,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
required: true,
|
|
320
|
+
},
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { rows: list, count } = await Product.findAndCountAll(findOptions);
|
|
266
325
|
|
|
267
326
|
res.json({ count, list, paging: { page, pageSize } });
|
|
268
327
|
});
|
|
@@ -14,7 +14,7 @@ import { createListParamSchema, getOrder, getWhereFromKvQuery } from '../libs/ap
|
|
|
14
14
|
const router = Router();
|
|
15
15
|
const authAdmin = authenticate<Setting>({ component: true, roles: ['owner', 'admin'] });
|
|
16
16
|
router.get('/', async (req, res) => {
|
|
17
|
-
const attributes = ['id', 'name', 'symbol', 'decimal', 'logo', 'payment_method_id', 'maximum_precision'];
|
|
17
|
+
const attributes = ['id', 'name', 'symbol', 'decimal', 'logo', 'payment_method_id', 'maximum_precision', 'type'];
|
|
18
18
|
const where: WhereOptions<PaymentMethod> = { livemode: req.livemode, active: true };
|
|
19
19
|
|
|
20
20
|
const methods = await PaymentMethod.findAll({
|
|
@@ -249,10 +249,14 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
249
249
|
|
|
250
250
|
if (doc) {
|
|
251
251
|
const json = doc.toJSON();
|
|
252
|
+
const isConsumesCredit = await doc.isConsumesCredit();
|
|
253
|
+
const serviceType = isConsumesCredit ? 'credit' : 'standard';
|
|
252
254
|
const products = (await Product.findAll()).map((x) => x.toJSON());
|
|
253
255
|
const prices = (await Price.findAll()).map((x) => x.toJSON());
|
|
254
256
|
// @ts-ignore
|
|
255
257
|
expandLineItems(json.items, products, prices);
|
|
258
|
+
// @ts-ignore
|
|
259
|
+
json.serviceType = serviceType;
|
|
256
260
|
res.json(json);
|
|
257
261
|
} else {
|
|
258
262
|
res.status(404).json(null);
|
|
@@ -43,9 +43,6 @@ router.get('/', auth, async (req, res) => {
|
|
|
43
43
|
const { page, pageSize, status, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
44
44
|
const where: WhereOptions<WebhookEndpoint> = {};
|
|
45
45
|
|
|
46
|
-
if (typeof query.livemode === 'boolean') {
|
|
47
|
-
where.livemode = query.livemode;
|
|
48
|
-
}
|
|
49
46
|
if (status) {
|
|
50
47
|
where.status = status
|
|
51
48
|
.split(',')
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DataTypes } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
import { safeApplyColumnChanges, type Migration } from '../migrate';
|
|
4
|
+
import models from '../models';
|
|
5
|
+
|
|
6
|
+
export const up: Migration = async ({ context }) => {
|
|
7
|
+
await context.createTable('meters', models.Meter.GENESIS_ATTRIBUTES);
|
|
8
|
+
await context.createTable('meter_events', models.MeterEvent.GENESIS_ATTRIBUTES);
|
|
9
|
+
await context.createTable('credit_grants', models.CreditGrant.GENESIS_ATTRIBUTES);
|
|
10
|
+
await context.createTable('credit_transactions', models.CreditTransaction.GENESIS_ATTRIBUTES);
|
|
11
|
+
|
|
12
|
+
await safeApplyColumnChanges(context, {
|
|
13
|
+
payment_currencies: [
|
|
14
|
+
{
|
|
15
|
+
name: 'type',
|
|
16
|
+
field: {
|
|
17
|
+
type: DataTypes.ENUM('standard', 'credit'),
|
|
18
|
+
defaultValue: 'standard',
|
|
19
|
+
allowNull: false,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
await context.sequelize.query(`
|
|
25
|
+
UPDATE payment_currencies SET maximum_precision = 2 WHERE type = 'standard';
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
await context.sequelize.query(`
|
|
29
|
+
UPDATE payment_currencies
|
|
30
|
+
SET maximum_precision = decimal
|
|
31
|
+
WHERE payment_method_id IN (
|
|
32
|
+
SELECT id FROM payment_methods WHERE type = 'stripe'
|
|
33
|
+
);
|
|
34
|
+
`);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const down: Migration = async ({ context }) => {
|
|
38
|
+
await context.removeColumn('payment_currencies', 'type');
|
|
39
|
+
await context.dropTable('credit_transactions');
|
|
40
|
+
await context.dropTable('credit_grants');
|
|
41
|
+
await context.dropTable('meter_events');
|
|
42
|
+
await context.dropTable('meters');
|
|
43
|
+
};
|