payment-kit 1.14.29 → 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 (59) 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 +38 -6
  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 +169 -145
  41. package/src/hooks/loading.ts +28 -0
  42. package/src/locales/en.tsx +6 -1
  43. package/src/locales/zh.tsx +6 -1
  44. package/src/pages/admin/billing/invoices/detail.tsx +17 -2
  45. package/src/pages/admin/billing/subscriptions/detail.tsx +4 -0
  46. package/src/pages/admin/customers/customers/detail.tsx +4 -0
  47. package/src/pages/admin/customers/customers/index.tsx +1 -1
  48. package/src/pages/admin/payments/intents/detail.tsx +4 -0
  49. package/src/pages/admin/payments/payouts/detail.tsx +4 -0
  50. package/src/pages/admin/payments/refunds/detail.tsx +4 -0
  51. package/src/pages/admin/products/links/detail.tsx +4 -0
  52. package/src/pages/admin/products/prices/detail.tsx +4 -0
  53. package/src/pages/admin/products/pricing-tables/detail.tsx +4 -0
  54. package/src/pages/admin/products/products/detail.tsx +4 -0
  55. package/src/pages/checkout/pricing-table.tsx +9 -3
  56. package/src/pages/customer/index.tsx +28 -17
  57. package/src/pages/customer/invoice/detail.tsx +27 -16
  58. package/src/pages/customer/invoice/past-due.tsx +3 -2
  59. 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
 
@@ -6,8 +6,10 @@ import isObject from 'lodash/isObject';
6
6
  import pick from 'lodash/pick';
7
7
  import uniq from 'lodash/uniq';
8
8
 
9
+ import { literal } from 'sequelize';
10
+ import type { Literal } from 'sequelize/types/utils';
9
11
  import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../integrations/stripe/resource';
10
- import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery } from '../libs/api';
12
+ import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
11
13
  import dayjs from '../libs/dayjs';
12
14
  import logger from '../libs/logger';
13
15
  import { isDelegationSufficientForPayment } from '../libs/payment';
@@ -72,10 +74,12 @@ const schema = createListParamSchema<{
72
74
  status?: string;
73
75
  customer_id?: string;
74
76
  customer_did?: string;
77
+ activeFirst?: boolean;
75
78
  }>({
76
79
  status: Joi.string().empty(''),
77
80
  customer_id: Joi.string().empty(''),
78
81
  customer_did: Joi.string().empty(''),
82
+ activeFirst: Joi.boolean().optional(),
79
83
  });
80
84
  router.get('/', authMine, async (req, res) => {
81
85
  const { page, pageSize, status, livemode, ...query } = await schema.validateAsync(req.query, {
@@ -113,10 +117,19 @@ router.get('/', authMine, async (req, res) => {
113
117
  where[key] = query[key];
114
118
  });
115
119
 
120
+ const order: [Literal | string, 'ASC' | 'DESC'][] = [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']];
121
+
122
+ if (query.activeFirst) {
123
+ order.unshift([
124
+ literal("CASE status WHEN 'active' THEN 1 WHEN 'trialing' THEN 1 WHEN 'past_due' THEN 2 ELSE 3 END"),
125
+ 'ASC',
126
+ ]);
127
+ }
128
+
116
129
  try {
117
130
  const { rows: list, count } = await Subscription.findAndCountAll({
118
131
  where,
119
- order: [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']],
132
+ order,
120
133
  offset: (page - 1) * pageSize,
121
134
  limit: pageSize,
122
135
  include: [
@@ -128,7 +141,6 @@ router.get('/', authMine, async (req, res) => {
128
141
  // https://github.com/sequelize/sequelize/issues/9481
129
142
  distinct: true,
130
143
  });
131
-
132
144
  const products = (await Product.findAll()).map((x) => x.toJSON());
133
145
  const prices = (await Price.findAll()).map((x) => x.toJSON());
134
146
  const docs = list.map((x) => x.toJSON());
@@ -209,7 +221,14 @@ router.get('/:id', authPortal, async (req, res) => {
209
221
  }
210
222
  });
211
223
 
224
+ const CommentSchema = Joi.string().max(200).empty('').optional();
225
+
212
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
+
213
232
  const subscription = await Subscription.findByPk(req.params.id);
214
233
  logger.info('subscription cancel request', { ...req.params, ...req.body });
215
234
 
@@ -238,6 +257,7 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
238
257
  if ((requestByAdmin && staking === 'proration') || req.body?.cancel_from === 'customer') {
239
258
  canReturnStake = true;
240
259
  }
260
+ const slashStake = requestByAdmin && staking === 'slash';
241
261
  // update cancel at
242
262
  const updates: Partial<Subscription> = {
243
263
  cancelation_details: {
@@ -245,14 +265,26 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
245
265
  reason: reason || 'payment_disputed',
246
266
  feedback: feedback || 'other',
247
267
  return_stake: canReturnStake,
268
+ slash_stake: slashStake,
248
269
  },
249
270
  };
250
271
  const now = dayjs().unix() + 3;
251
- if (req.user?.via === 'portal') {
272
+ if (req.user?.via === 'portal' || req.body?.cancel_from === 'customer') {
273
+ const inTrialing = subscription.status === 'trialing';
252
274
  updates.cancel_at_period_end = true;
253
275
  updates.cancel_at = subscription.current_period_end;
254
- 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
+ };
255
283
  updates.canceled_at = now;
284
+ if (inTrialing) {
285
+ updates.cancel_at_period_end = false;
286
+ updates.cancel_at = now;
287
+ }
256
288
  await addSubscriptionJob(subscription, 'cancel', true, updates.cancel_at);
257
289
  } else {
258
290
  if (['owner', 'admin'].includes(req.user?.role as string) === false) {
@@ -602,7 +634,7 @@ const updateSchema = Joi.object<{
602
634
  service_actions?: ServiceAction[];
603
635
  }>({
604
636
  description: Joi.string().min(1).optional(),
605
- metadata: Joi.any().optional(),
637
+ metadata: MetadataSchema,
606
638
  payment_behavior: Joi.string().allow('allow_incomplete', 'error_if_incomplete', 'pending_if_incomplete').optional(),
607
639
  proration_behavior: Joi.string().allow('always_invoice', 'create_prorations', 'none').optional(),
608
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
+ });