payment-kit 1.14.30 → 1.14.31

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 (58) hide show
  1. package/api/src/index.ts +4 -0
  2. package/api/src/libs/api.ts +23 -0
  3. package/api/src/libs/subscription.ts +32 -0
  4. package/api/src/queues/refund.ts +38 -1
  5. package/api/src/queues/subscription.ts +218 -21
  6. package/api/src/routes/checkout-sessions.ts +5 -0
  7. package/api/src/routes/customers.ts +27 -1
  8. package/api/src/routes/invoices.ts +5 -1
  9. package/api/src/routes/payment-intents.ts +17 -2
  10. package/api/src/routes/payment-links.ts +105 -3
  11. package/api/src/routes/payouts.ts +5 -1
  12. package/api/src/routes/prices.ts +19 -3
  13. package/api/src/routes/pricing-table.ts +79 -2
  14. package/api/src/routes/products.ts +24 -8
  15. package/api/src/routes/refunds.ts +7 -4
  16. package/api/src/routes/subscription-items.ts +5 -1
  17. package/api/src/routes/subscriptions.ts +25 -5
  18. package/api/src/routes/webhook-endpoints.ts +5 -1
  19. package/api/src/store/models/subscription.ts +1 -0
  20. package/api/tests/libs/api.spec.ts +72 -1
  21. package/api/third.d.ts +2 -0
  22. package/blocklet.yml +1 -1
  23. package/package.json +19 -18
  24. package/src/components/customer/form.tsx +53 -0
  25. package/src/components/filter-toolbar.tsx +1 -1
  26. package/src/components/invoice/list.tsx +8 -8
  27. package/src/components/invoice/table.tsx +42 -36
  28. package/src/components/metadata/form.tsx +24 -3
  29. package/src/components/payment-intent/actions.tsx +17 -5
  30. package/src/components/payment-link/after-pay.tsx +46 -4
  31. package/src/components/payouts/list.tsx +1 -1
  32. package/src/components/price/form.tsx +14 -2
  33. package/src/components/pricing-table/payment-settings.tsx +45 -4
  34. package/src/components/product/features.tsx +16 -2
  35. package/src/components/product/form.tsx +28 -4
  36. package/src/components/subscription/actions/cancel.tsx +10 -0
  37. package/src/components/subscription/description.tsx +2 -2
  38. package/src/components/subscription/items/index.tsx +3 -2
  39. package/src/components/subscription/portal/cancel.tsx +12 -1
  40. package/src/components/subscription/portal/list.tsx +6 -5
  41. package/src/locales/en.tsx +6 -1
  42. package/src/locales/zh.tsx +6 -1
  43. package/src/pages/admin/billing/invoices/detail.tsx +17 -2
  44. package/src/pages/admin/billing/subscriptions/detail.tsx +4 -0
  45. package/src/pages/admin/customers/customers/detail.tsx +4 -0
  46. package/src/pages/admin/customers/customers/index.tsx +1 -1
  47. package/src/pages/admin/payments/intents/detail.tsx +4 -0
  48. package/src/pages/admin/payments/payouts/detail.tsx +4 -0
  49. package/src/pages/admin/payments/refunds/detail.tsx +4 -0
  50. package/src/pages/admin/products/links/detail.tsx +4 -0
  51. package/src/pages/admin/products/prices/detail.tsx +4 -0
  52. package/src/pages/admin/products/pricing-tables/detail.tsx +4 -0
  53. package/src/pages/admin/products/products/detail.tsx +4 -0
  54. package/src/pages/checkout/pricing-table.tsx +9 -3
  55. package/src/pages/customer/index.tsx +28 -17
  56. package/src/pages/customer/invoice/detail.tsx +27 -16
  57. package/src/pages/customer/invoice/past-due.tsx +3 -2
  58. package/src/pages/customer/subscription/detail.tsx +4 -0
@@ -4,7 +4,7 @@ import pick from 'lodash/pick';
4
4
  import { Op } from 'sequelize';
5
5
  import type { WhereOptions } from 'sequelize';
6
6
 
7
- import { createListParamSchema } from '../libs/api';
7
+ import { createListParamSchema, MetadataSchema } from '../libs/api';
8
8
  import logger from '../libs/logger';
9
9
  import { authenticate } from '../libs/security';
10
10
  import { isLineItemAligned } from '../libs/session';
@@ -141,9 +141,62 @@ export async function createPaymentLink(payload: any) {
141
141
  return PaymentLink.create(raw as PaymentLink);
142
142
  }
143
143
 
144
- // FIXME: @wangshijun use schema validation
144
+ const PaymentLinkCreateSchema = Joi.object({
145
+ name: Joi.string().required(),
146
+ line_items: Joi.array()
147
+ .items(
148
+ Joi.object({
149
+ price_id: Joi.string().required(),
150
+ quantity: Joi.number().min(1).required(),
151
+ adjustable_quantity: Joi.object({
152
+ enabled: Joi.boolean().required(),
153
+ minimum: Joi.number().min(0),
154
+ maximum: Joi.number().min(0),
155
+ })
156
+ .min(0)
157
+ .optional(),
158
+ })
159
+ )
160
+ .min(1)
161
+ .required(),
162
+ currency_id: Joi.string().optional(),
163
+ metadata: MetadataSchema,
164
+ after_completion: Joi.object({
165
+ type: Joi.string().valid('hosted_confirmation', 'redirect').required(),
166
+ hosted_confirmation: Joi.object({
167
+ custom_message: Joi.string().max(200).empty('').optional(),
168
+ })
169
+ .min(0)
170
+ .optional(),
171
+ redirect: Joi.object({
172
+ url: Joi.string().uri().max(2048).empty('').optional(),
173
+ })
174
+ .min(0)
175
+ .optional(),
176
+ })
177
+ .min(0)
178
+ .optional(),
179
+ allow_promotion_codes: Joi.boolean().optional(),
180
+ nft_mint_settings: Joi.object({
181
+ enabled: Joi.boolean().required(),
182
+ factory: Joi.string().max(40).empty('').optional(),
183
+ })
184
+ .min(0)
185
+ .optional(),
186
+ consent_collection: Joi.object({
187
+ promotions: Joi.string().valid('none', 'opt_in', 'opt_out').required(),
188
+ terms_of_service: Joi.string().valid('none', 'opt_in', 'opt_out').required(),
189
+ })
190
+ .min(0)
191
+ .optional(),
192
+ }).unknown(true);
145
193
  router.post('/', auth, async (req, res) => {
146
194
  try {
195
+ const { error } = PaymentLinkCreateSchema.validate(req.body);
196
+ if (error) {
197
+ res.status(400).json({ error: `Payment link create request invalid: ${error.message}` });
198
+ return;
199
+ }
147
200
  const result = await createPaymentLink({
148
201
  ...req.body,
149
202
  livemode: !!req.livemode,
@@ -218,9 +271,58 @@ router.get('/:id', auth, async (req, res) => {
218
271
  }
219
272
  });
220
273
 
221
- // update
274
+ const PaymentLinkUpdateSchema = Joi.object({
275
+ name: Joi.string().optional(),
276
+ line_items: Joi.array()
277
+ .items(
278
+ Joi.object({
279
+ price_id: Joi.string().required(),
280
+ quantity: Joi.number().min(1).required(),
281
+ adjustable_quantity: Joi.object({
282
+ enabled: Joi.boolean().required(),
283
+ minimum: Joi.number().min(0),
284
+ maximum: Joi.number().min(0),
285
+ })
286
+ .min(0)
287
+ .optional(),
288
+ })
289
+ )
290
+ .min(1)
291
+ .optional(),
292
+ currency_id: Joi.string().optional(),
293
+ metadata: MetadataSchema,
294
+ active: Joi.boolean().optional(),
295
+ after_completion: Joi.object({
296
+ type: Joi.string().valid('hosted_confirmation', 'redirect').optional(),
297
+ hosted_confirmation: Joi.object({
298
+ custom_message: Joi.string().max(200).empty('').optional(),
299
+ }),
300
+ redirect: Joi.object({
301
+ url: Joi.string().uri().max(2048).empty('').optional(),
302
+ }),
303
+ })
304
+ .min(0)
305
+ .optional(),
306
+ allow_promotion_codes: Joi.boolean().optional(),
307
+ nft_mint_settings: Joi.object({
308
+ enabled: Joi.boolean().required(),
309
+ factory: Joi.string().max(40).empty('').optional(),
310
+ })
311
+ .min(0)
312
+ .optional(),
313
+ consent_collection: Joi.object({
314
+ promotions: Joi.string().valid('none', 'opt_in', 'opt_out').optional(),
315
+ terms_of_service: Joi.string().valid('none', 'opt_in', 'opt_out').optional(),
316
+ })
317
+ .min(0)
318
+ .optional(),
319
+ }).unknown(true);
222
320
  // eslint-disable-next-line consistent-return
223
321
  router.put('/:id', auth, async (req, res) => {
322
+ const { error } = PaymentLinkUpdateSchema.validate(req.body);
323
+ if (error) {
324
+ return res.status(400).json({ error: `Payment link update request invalid: ${error.message}` });
325
+ }
224
326
  const doc = await PaymentLink.findByPk(req.params.id);
225
327
 
226
328
  if (!doc) {
@@ -3,7 +3,7 @@ import { Router } from 'express';
3
3
  import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
5
 
6
- import { createListParamSchema, getWhereFromKvQuery } from '../libs/api';
6
+ import { createListParamSchema, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
7
7
  import { authenticate } from '../libs/security';
8
8
  import { formatMetadata } from '../libs/util';
9
9
  import { Customer } from '../store/models/customer';
@@ -137,6 +137,10 @@ router.put('/:id', authAdmin, async (req, res) => {
137
137
 
138
138
  const raw = pick(req.body, ['metadata']);
139
139
  if (raw.metadata) {
140
+ const { error: metadataError } = MetadataSchema.validate(raw.metadata);
141
+ if (metadataError) {
142
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
143
+ }
140
144
  raw.metadata = formatMetadata(raw.metadata);
141
145
  }
142
146
 
@@ -4,7 +4,7 @@ import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
5
  import type { WhereOptions } from 'sequelize';
6
6
 
7
- import { createListParamSchema, getWhereFromQuery } from '../libs/api';
7
+ import { createListParamSchema, getWhereFromQuery, MetadataSchema } from '../libs/api';
8
8
  import logger from '../libs/logger';
9
9
  import { authenticate } from '../libs/security';
10
10
  import { canUpsell } from '../libs/session';
@@ -182,6 +182,8 @@ const priceAmountSchema = Joi.object({
182
182
  )
183
183
  .optional(),
184
184
  unit_amount: Joi.number().greater(0).optional(),
185
+ nickname: Joi.string().max(64).empty('').optional(),
186
+ lookup_key: Joi.string().max(64).empty('').optional(),
185
187
  });
186
188
 
187
189
  // FIXME: @wangshijun use schema validation
@@ -195,10 +197,18 @@ router.post('/', auth, async (req, res) => {
195
197
  if (error) {
196
198
  return res.status(400).json({ error: `Price create request invalid: ${error.message}` });
197
199
  }
198
- const { error: priceAmountError } = priceAmountSchema.validate(pick(req.body, ['currency_options', 'unit_amount']));
200
+ const { error: priceAmountError } = priceAmountSchema.validate(
201
+ pick(req.body, ['currency_options', 'unit_amount', 'nickname', 'lookup_key'])
202
+ );
199
203
  if (priceAmountError) {
200
204
  return res.status(400).json({ error: `Price create request invalid: ${priceAmountError.message}` });
201
205
  }
206
+ if (req.body.metadata) {
207
+ const { error: metadataError } = MetadataSchema.validate(req.body.metadata);
208
+ if (metadataError) {
209
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
210
+ }
211
+ }
202
212
  const result = await createPrice({
203
213
  ...req.body,
204
214
  livemode: !!req.livemode,
@@ -265,7 +275,9 @@ router.put('/:id', auth, async (req, res) => {
265
275
  return res.status(400).json({ error: `Price update request invalid: ${error.message}` });
266
276
  }
267
277
 
268
- const { error: priceAmountError } = priceAmountSchema.validate(pick(req.body, ['currency_options', 'unit_amount']));
278
+ const { error: priceAmountError } = priceAmountSchema.validate(
279
+ pick(req.body, ['currency_options', 'unit_amount', 'nickname', 'lookup_key'])
280
+ );
269
281
  if (priceAmountError) {
270
282
  return res.status(400).json({ error: `Price update request invalid: ${priceAmountError.message}` });
271
283
  }
@@ -285,6 +297,10 @@ router.put('/:id', auth, async (req, res) => {
285
297
  }
286
298
 
287
299
  const locked = doc.locked && process.env.PAYMENT_CHANGE_LOCKED_PRICE !== '1';
300
+ const { error: metadataError } = MetadataSchema.validate(req.body.metadata);
301
+ if (metadataError) {
302
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
303
+ }
288
304
  const updates: Partial<Price> = Price.formatBeforeSave(
289
305
  pick(
290
306
  req.body,
@@ -8,7 +8,7 @@ import uniq from 'lodash/uniq';
8
8
  import type { WhereOptions } from 'sequelize';
9
9
 
10
10
  import { checkPassportForPricingTable } from '../integrations/blocklet/passport';
11
- import { createListParamSchema } from '../libs/api';
11
+ import { createListParamSchema, MetadataSchema } from '../libs/api';
12
12
  import logger from '../libs/logger';
13
13
  import { authenticate } from '../libs/security';
14
14
  import { getBillingThreshold, getMinStakeAmount, isLineItemCurrencyAligned } from '../libs/session';
@@ -24,9 +24,46 @@ import { formatCheckoutSession } from './checkout-sessions';
24
24
  const router = Router();
25
25
  const auth = authenticate<PricingTable>({ component: true, roles: ['owner', 'admin'] });
26
26
 
27
- // FIXME: @wangshijun use schema validation
27
+ const PricingTableCreateSchema = Joi.object({
28
+ name: Joi.string().required(),
29
+ description: Joi.string().allow(null, ''),
30
+ metadata: MetadataSchema,
31
+ items: Joi.array()
32
+ .items(
33
+ Joi.object({
34
+ price_id: Joi.string().required(),
35
+ product_id: Joi.string().required(),
36
+ after_completion: Joi.object({
37
+ type: Joi.string().valid('hosted_confirmation', 'redirect').required(),
38
+ hosted_confirmation: Joi.object({
39
+ custom_message: Joi.string().max(200).empty('').optional(),
40
+ })
41
+ .min(0)
42
+ .optional(),
43
+ redirect: Joi.object({
44
+ url: Joi.string().uri().max(2048).empty('').optional(),
45
+ })
46
+ .min(0)
47
+ .optional(),
48
+ }).unknown(true),
49
+ nft_mint_settings: Joi.object({
50
+ enabled: Joi.boolean().required(),
51
+ factory: Joi.string().max(40).empty('').optional(),
52
+ })
53
+ .min(0)
54
+ .optional(),
55
+ }).unknown(true)
56
+ )
57
+ .min(1)
58
+ .required(),
59
+ branding_settings: Joi.object().allow(null, {}).optional(),
60
+ }).unknown(true);
28
61
  // eslint-disable-next-line consistent-return
29
62
  router.post('/', auth, async (req, res) => {
63
+ const { error } = PricingTableCreateSchema.validate(req.body);
64
+ if (error) {
65
+ return res.status(400).json({ error: `Pricing table create request invalid: ${error.message}` });
66
+ }
30
67
  const raw: Partial<PricingTable> = PricingTable.format(req.body);
31
68
  raw.active = true;
32
69
  raw.locked = false;
@@ -110,8 +147,48 @@ router.get('/:id', async (req, res) => {
110
147
  });
111
148
 
112
149
  // update
150
+ // 更新schema,局部更新
151
+ const PricingTableUpdateSchema = Joi.object({
152
+ name: Joi.string().optional(),
153
+ description: Joi.string().allow(null, '').optional(),
154
+ metadata: MetadataSchema.optional(),
155
+ items: Joi.array()
156
+ .items(
157
+ Joi.object({
158
+ price_id: Joi.string().required(),
159
+ product_id: Joi.string().required(),
160
+ after_completion: Joi.object({
161
+ type: Joi.string().valid('hosted_confirmation', 'redirect').required(),
162
+ hosted_confirmation: Joi.object({
163
+ custom_message: Joi.string().max(200).empty('').optional(),
164
+ })
165
+ .min(0)
166
+ .optional(),
167
+ redirect: Joi.object({
168
+ url: Joi.string().uri().max(2048).empty('').optional(),
169
+ })
170
+ .min(0)
171
+ .optional(),
172
+ }).unknown(true),
173
+ nft_mint_settings: Joi.object({
174
+ enabled: Joi.boolean().required(),
175
+ factory: Joi.string().max(40).empty('').optional(),
176
+ })
177
+ .min(0)
178
+ .optional(),
179
+ }).unknown(true)
180
+ )
181
+ .min(1)
182
+ .optional(),
183
+ branding_settings: Joi.object().allow(null, {}).optional(),
184
+ }).unknown(true);
185
+
113
186
  // eslint-disable-next-line consistent-return
114
187
  router.put('/:id', auth, async (req, res) => {
188
+ const { error } = PricingTableUpdateSchema.validate(req.body);
189
+ if (error) {
190
+ return res.status(400).json({ error: `Pricing table create request invalid: ${error.message}` });
191
+ }
115
192
  const doc = await PricingTable.findByPk(req.params.id);
116
193
 
117
194
  if (!doc) {
@@ -5,7 +5,7 @@ import cloneDeep from 'lodash/cloneDeep';
5
5
  import pick from 'lodash/pick';
6
6
  import { Op } from 'sequelize';
7
7
 
8
- import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery } from '../libs/api';
8
+ import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
9
9
  import logger from '../libs/logger';
10
10
  import { authenticate } from '../libs/security';
11
11
  import { formatMetadata } from '../libs/util';
@@ -18,7 +18,18 @@ const router = Router();
18
18
 
19
19
  const auth = authenticate<Product>({ component: true, roles: ['owner', 'admin'] });
20
20
 
21
- const priceAmountSchema = Joi.object({
21
+ const ProductAndPriceSchema = Joi.object({
22
+ name: Joi.string().max(64).empty('').optional(),
23
+ type: Joi.string().valid('service', 'good').empty('').optional(),
24
+ description: Joi.string().max(256).empty('').optional(),
25
+ images: Joi.any().optional(),
26
+ metadata: MetadataSchema,
27
+ statement_descriptor: Joi.string().max(32).empty('').optional(),
28
+ unit_label: Joi.string().max(32).empty('').optional(),
29
+ nft_factory: Joi.string().max(40).allow(null).empty('').optional(),
30
+ features: Joi.array()
31
+ .items(Joi.object({ name: Joi.string().max(64).empty('').optional() }).unknown(true))
32
+ .optional(),
22
33
  prices: Joi.array()
23
34
  .items(
24
35
  Joi.object({
@@ -26,15 +37,16 @@ const priceAmountSchema = Joi.object({
26
37
  .items(
27
38
  Joi.object({
28
39
  unit_amount: Joi.number().greater(0).required(),
29
- // 其他属性
30
40
  }).unknown(true)
31
41
  )
32
42
  .optional(),
33
43
  unit_amount: Joi.number().greater(0).required(),
44
+ nickname: Joi.string().max(64).empty('').optional(),
45
+ lookup_key: Joi.string().max(64).empty('').optional(),
34
46
  }).unknown(true)
35
47
  )
36
- .required(),
37
- });
48
+ .optional(),
49
+ }).unknown(true);
38
50
 
39
51
  export async function createProductAndPrices(payload: any) {
40
52
  const raw: Partial<Product> = pick(payload, [
@@ -128,9 +140,9 @@ export async function createProductAndPrices(payload: any) {
128
140
  // eslint-disable-next-line consistent-return
129
141
  router.post('/', auth, async (req, res) => {
130
142
  try {
131
- const { error: priceAmountError } = priceAmountSchema.validate(pick(req.body, ['prices']));
132
- if (priceAmountError) {
133
- return res.status(400).json({ error: `Product create request invalid: ${priceAmountError.message}` });
143
+ const { error } = ProductAndPriceSchema.validate(req.body);
144
+ if (error) {
145
+ return res.status(400).json({ error: `Product create request invalid: ${error.message}` });
134
146
  }
135
147
  const result = await createProductAndPrices({
136
148
  ...req.body,
@@ -272,6 +284,10 @@ router.put('/:id', auth, async (req, res) => {
272
284
  'metadata',
273
285
  'cross_sell',
274
286
  ]);
287
+ const { error } = ProductAndPriceSchema.validate(updates);
288
+ if (error) {
289
+ return res.status(400).json({ error: `Product update request invalid: ${error.message}` });
290
+ }
275
291
  if (updates.metadata) {
276
292
  updates.metadata = formatMetadata(updates.metadata);
277
293
  }
@@ -5,7 +5,7 @@ import Joi from 'joi';
5
5
  import pick from 'lodash/pick';
6
6
 
7
7
  import { BN, fromTokenToUnit } from '@ocap/util';
8
- import { BNPositiveValidator, createListParamSchema, getWhereFromKvQuery } from '../libs/api';
8
+ import { BNPositiveValidator, createListParamSchema, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
9
9
  import { authenticate } from '../libs/security';
10
10
  import { formatMetadata } from '../libs/util';
11
11
  import {
@@ -121,8 +121,8 @@ const refundRequestSchema = Joi.object({
121
121
  reason: Joi.string()
122
122
  .valid('duplicate', 'requested_by_customer', 'requested_by_admin', 'fraudulent', 'expired_uncaptured_charge')
123
123
  .required(),
124
- description: Joi.string().required(),
125
- metadata: Joi.object().optional(),
124
+ description: Joi.string().max(200).required(),
125
+ metadata: MetadataSchema,
126
126
  invoice_id: Joi.string().optional(),
127
127
  subscription_id: Joi.string().optional(),
128
128
  });
@@ -149,7 +149,6 @@ router.post('/', authAdmin, async (req, res) => {
149
149
  if (!paymentMethod) {
150
150
  throw new Error(`payment method not found: ${req.body.payment_method_id}`);
151
151
  }
152
-
153
152
  const item = await Refund.create({
154
153
  ...req.body,
155
154
  type: 'refund',
@@ -234,6 +233,10 @@ router.put('/:id', authAdmin, async (req, res) => {
234
233
 
235
234
  const raw = pick(req.body, ['metadata']);
236
235
  if (raw.metadata) {
236
+ const { error: metadataError } = MetadataSchema.validate(raw.metadata);
237
+ if (metadataError) {
238
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
239
+ }
237
240
  raw.metadata = formatMetadata(raw.metadata);
238
241
  }
239
242
 
@@ -4,7 +4,7 @@ import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
5
  import type { WhereOptions } from 'sequelize';
6
6
 
7
- import { createListParamSchema } from '../libs/api';
7
+ import { createListParamSchema, MetadataSchema } from '../libs/api';
8
8
  import { authenticate } from '../libs/security';
9
9
  import { expandLineItems } from '../libs/session';
10
10
  import { formatMetadata } from '../libs/util';
@@ -32,6 +32,10 @@ router.post('/', auth, async (req, res) => {
32
32
 
33
33
  raw.livemode = req.livemode;
34
34
  if (raw.metadata) {
35
+ const { error: metadataError } = MetadataSchema.validate(raw.metadata);
36
+ if (metadataError) {
37
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
38
+ }
35
39
  raw.metadata = formatMetadata(raw.metadata);
36
40
  }
37
41
 
@@ -9,7 +9,7 @@ import uniq from 'lodash/uniq';
9
9
  import { literal } from 'sequelize';
10
10
  import type { Literal } from 'sequelize/types/utils';
11
11
  import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../integrations/stripe/resource';
12
- import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery } from '../libs/api';
12
+ import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
13
13
  import dayjs from '../libs/dayjs';
14
14
  import logger from '../libs/logger';
15
15
  import { isDelegationSufficientForPayment } from '../libs/payment';
@@ -121,7 +121,7 @@ router.get('/', authMine, async (req, res) => {
121
121
 
122
122
  if (query.activeFirst) {
123
123
  order.unshift([
124
- literal("CASE status WHEN 'active' THEN 1 WHEN 'trialing' THEN 2 WHEN 'past_due' THEN 3 ELSE 4 END"),
124
+ literal("CASE status WHEN 'active' THEN 1 WHEN 'trialing' THEN 1 WHEN 'past_due' THEN 2 ELSE 3 END"),
125
125
  'ASC',
126
126
  ]);
127
127
  }
@@ -221,7 +221,14 @@ router.get('/:id', authPortal, async (req, res) => {
221
221
  }
222
222
  });
223
223
 
224
+ const CommentSchema = Joi.string().max(200).empty('').optional();
225
+
224
226
  router.put('/:id/cancel', authPortal, async (req, res) => {
227
+ const { error: commentError } = CommentSchema.validate(req.body?.comment);
228
+ if (commentError) {
229
+ return res.status(400).json({ error: `comment invalid: ${commentError.message}` });
230
+ }
231
+
225
232
  const subscription = await Subscription.findByPk(req.params.id);
226
233
  logger.info('subscription cancel request', { ...req.params, ...req.body });
227
234
 
@@ -250,6 +257,7 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
250
257
  if ((requestByAdmin && staking === 'proration') || req.body?.cancel_from === 'customer') {
251
258
  canReturnStake = true;
252
259
  }
260
+ const slashStake = requestByAdmin && staking === 'slash';
253
261
  // update cancel at
254
262
  const updates: Partial<Subscription> = {
255
263
  cancelation_details: {
@@ -257,14 +265,26 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
257
265
  reason: reason || 'payment_disputed',
258
266
  feedback: feedback || 'other',
259
267
  return_stake: canReturnStake,
268
+ slash_stake: slashStake,
260
269
  },
261
270
  };
262
271
  const now = dayjs().unix() + 3;
263
- if (req.user?.via === 'portal') {
272
+ if (req.user?.via === 'portal' || req.body?.cancel_from === 'customer') {
273
+ const inTrialing = subscription.status === 'trialing';
264
274
  updates.cancel_at_period_end = true;
265
275
  updates.cancel_at = subscription.current_period_end;
266
- updates.cancelation_details = { reason: 'cancellation_requested', feedback, comment, return_stake: canReturnStake };
276
+ updates.cancelation_details = {
277
+ reason: 'cancellation_requested',
278
+ feedback,
279
+ comment,
280
+ return_stake: canReturnStake,
281
+ slash_stake: slashStake,
282
+ };
267
283
  updates.canceled_at = now;
284
+ if (inTrialing) {
285
+ updates.cancel_at_period_end = false;
286
+ updates.cancel_at = now;
287
+ }
268
288
  await addSubscriptionJob(subscription, 'cancel', true, updates.cancel_at);
269
289
  } else {
270
290
  if (['owner', 'admin'].includes(req.user?.role as string) === false) {
@@ -614,7 +634,7 @@ const updateSchema = Joi.object<{
614
634
  service_actions?: ServiceAction[];
615
635
  }>({
616
636
  description: Joi.string().min(1).optional(),
617
- metadata: Joi.any().optional(),
637
+ metadata: MetadataSchema,
618
638
  payment_behavior: Joi.string().allow('allow_incomplete', 'error_if_incomplete', 'pending_if_incomplete').optional(),
619
639
  proration_behavior: Joi.string().allow('always_invoice', 'create_prorations', 'none').optional(),
620
640
  billing_cycle_anchor: Joi.string().allow('now', 'unchanged').optional(),
@@ -3,7 +3,7 @@ import Joi from 'joi';
3
3
  import pick from 'lodash/pick';
4
4
  import type { WhereOptions } from 'sequelize';
5
5
 
6
- import { createListParamSchema } from '../libs/api';
6
+ import { createListParamSchema, MetadataSchema } from '../libs/api';
7
7
  import { authenticate } from '../libs/security';
8
8
  import { formatMetadata } from '../libs/util';
9
9
  import { WebhookEndpoint } from '../store/models';
@@ -22,6 +22,10 @@ router.post('/', auth, async (req, res) => {
22
22
  raw.api_version = '2023-09-05';
23
23
  raw.status = raw.status || 'enabled';
24
24
  if (raw.metadata) {
25
+ const { error } = MetadataSchema.validate(raw.metadata);
26
+ if (error) {
27
+ return res.status(400).json({ error: `metadata invalid: ${error.message}` });
28
+ }
25
29
  raw.metadata = formatMetadata(raw.metadata);
26
30
  }
27
31
 
@@ -62,6 +62,7 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
62
62
  >;
63
63
  reason: LiteralUnion<'cancellation_requested' | 'payment_disputed' | 'payment_failed' | 'stake_revoked', string>;
64
64
  return_stake?: boolean;
65
+ slash_stake?: boolean;
65
66
  };
66
67
 
67
68
  declare billing_cycle_anchor: number;
@@ -1,6 +1,6 @@
1
1
  import { Op } from 'sequelize';
2
2
 
3
- import { getWhereFromQuery } from '../../src/libs/api';
3
+ import { getWhereFromQuery, MetadataSchema } from '../../src/libs/api';
4
4
 
5
5
  describe('getWhereFromQuery', () => {
6
6
  it('should correctly parse > operator', () => {
@@ -155,3 +155,74 @@ describe('getWhereFromQuery', () => {
155
155
  expect(() => getWhereFromQuery('status === "failed"')).toThrow(/Operator not supported/);
156
156
  });
157
157
  });
158
+
159
+ describe('MetadataSchema', () => {
160
+ it('should validate an object with string keys and custom validated values', () => {
161
+ const data = {
162
+ key1: 'value1',
163
+ key2: 123,
164
+ };
165
+ const { error } = MetadataSchema.validate(data);
166
+ expect(error).toBeUndefined();
167
+ });
168
+
169
+ it('should validate an array of objects with key and custom validated value', () => {
170
+ const data = [
171
+ { key: 'key1', value: 'value1' },
172
+ { key: 'key2', value: 123 },
173
+ ];
174
+ const { error } = MetadataSchema.validate(data);
175
+ expect(error).toBeUndefined();
176
+ });
177
+
178
+ it('should invalidate an object with a key longer than 64 characters', () => {
179
+ const data = {
180
+ ['a'.repeat(65)]: 'value1',
181
+ };
182
+ const { error } = MetadataSchema.validate(data);
183
+ expect(error).toBeDefined();
184
+ expect(error?.details?.[0]?.message).toMatch(
185
+ /"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" is not allowed/
186
+ );
187
+ });
188
+
189
+ it('should invalidate an array with an object missing the key field', () => {
190
+ const data = [{ value: 'value1' }];
191
+ const { error } = MetadataSchema.validate(data);
192
+ expect(error).toBeDefined();
193
+ expect(error?.details?.[0]?.message).toMatch(/"\[0\]\.key" is required/);
194
+ });
195
+
196
+ it('should invalidate an array with an object missing the value field', () => {
197
+ const data = [{ key: 'key1' }];
198
+ const { error } = MetadataSchema.validate(data);
199
+ expect(error).toBeDefined();
200
+ expect(error?.details?.[0]?.message).toMatch(/"\[0\]\.value" is required/);
201
+ });
202
+
203
+ it('should invalidate an object with a value longer than 256 characters', () => {
204
+ const data = {
205
+ key1: 'a'.repeat(257),
206
+ };
207
+ const { error } = MetadataSchema.validate(data);
208
+ expect(error).toBeDefined();
209
+ expect(error?.details?.[0]?.message).toMatch(/Metadata value should be less than 256 characters/);
210
+ });
211
+ it('should validate when data is null', () => {
212
+ const data = null;
213
+ const { error } = MetadataSchema.validate(data);
214
+ expect(error).toBeUndefined();
215
+ });
216
+
217
+ it('should validate when data is empty object', () => {
218
+ const data = {};
219
+ const { error } = MetadataSchema.validate(data);
220
+ expect(error).toBeUndefined();
221
+ });
222
+
223
+ it('should validate when data is empty array', () => {
224
+ const data: any[] = [];
225
+ const { error } = MetadataSchema.validate(data);
226
+ expect(error).toBeUndefined();
227
+ });
228
+ });
package/api/third.d.ts CHANGED
@@ -16,6 +16,8 @@ declare module 'sql-where-parser';
16
16
 
17
17
  declare module 'cls-hooked';
18
18
 
19
+ declare module 'express-xss-sanitizer';
20
+
19
21
  namespace Express {
20
22
  interface Request {
21
23
  user?: {
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.14.30
17
+ version: 1.14.31
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist