payment-kit 1.13.81 → 1.13.83

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/index.ts CHANGED
@@ -55,9 +55,8 @@ handlers.attach(Object.assign({ app: router }, setupHandlers));
55
55
  handlers.attach(Object.assign({ app: router }, subscribeHandlers));
56
56
 
57
57
  router.use('/api', routes);
58
- app.use(router);
59
58
 
60
- const isProduction = process.env.NODE_ENV === 'production' || process.env.ABT_NODE_SERVICE_ENV === 'production';
59
+ const isProduction = process.env.BLOCKLET_MODE === 'production';
61
60
 
62
61
  const accessFormat =
63
62
  isProduction
@@ -76,6 +75,8 @@ if (isProduction) {
76
75
  });
77
76
  }
78
77
 
78
+ app.use(router);
79
+
79
80
  if (isProduction) {
80
81
  const staticDir = path.resolve(process.env.BLOCKLET_APP_DIR!, 'dist');
81
82
  app.use(express.static(staticDir, { maxAge: '30d', index: false }));
@@ -0,0 +1,72 @@
1
+ import { Op } from 'sequelize';
2
+ import SqlWhereParser from 'sql-where-parser';
3
+
4
+ const parser = new SqlWhereParser();
5
+
6
+ export function handleOperator(operator: string, operands: any[]): any {
7
+ switch (operator) {
8
+ case 'AND':
9
+ case 'OR':
10
+ return handleCondition({ [operator]: operands });
11
+ case 'IN':
12
+ if (operands[0].NOT) {
13
+ return { [operands[0].NOT]: { [Op.notIn]: operands[1] } };
14
+ }
15
+ return { [operands[0]]: { [Op.in]: operands[1] } };
16
+ case 'LIKE':
17
+ if (operands[0].NOT) {
18
+ return { [operands[0].NOT]: { [Op.notLike]: operands[1] } };
19
+ }
20
+ return { [operands[0]]: { [Op.like]: operands[1] } };
21
+ case 'BETWEEN':
22
+ if (operands[0].NOT) {
23
+ return { [operands[0].NOT]: { [Op.notBetween]: [operands[1], operands[2]] } };
24
+ }
25
+ return { [operands[0]]: { [Op.between]: [operands[1], operands[2]] } };
26
+ case 'IS':
27
+ if (operands[1] && operands[1].NOT && Array.isArray(operands[1].NOT) && operands[1].NOT.length) {
28
+ return { [operands[0]]: { [Op.ne]: operands[1].NOT[0] } };
29
+ }
30
+ return { [operands[0]]: operands[1] };
31
+ case '=':
32
+ return { [operands[0]]: operands[1] };
33
+ case '!=':
34
+ return { [operands[0]]: { [Op.ne]: operands[1] } };
35
+ case '>':
36
+ return { [operands[0]]: { [Op.gt]: operands[1] } };
37
+ case '>=':
38
+ return { [operands[0]]: { [Op.gte]: operands[1] } };
39
+ case '<':
40
+ return { [operands[0]]: { [Op.lt]: operands[1] } };
41
+ case '<=':
42
+ return { [operands[0]]: { [Op.lte]: operands[1] } };
43
+ default:
44
+ console.error('Invalid operator', { operator, operands });
45
+ throw new Error('Operator not supported when parse query');
46
+ }
47
+ }
48
+
49
+ export function handleItem(item: Record<string, any>) {
50
+ const operator = Object.keys(item)[0] as string;
51
+ const operands = item[operator];
52
+ return handleOperator(operator, operands);
53
+ }
54
+
55
+ export function handleCondition(condition: any) {
56
+ if (condition.AND) {
57
+ return { [Op.and]: condition.AND.map(handleItem) };
58
+ }
59
+ if (condition.OR) {
60
+ return { [Op.or]: condition.OR.map(handleItem) };
61
+ }
62
+ return handleItem(condition);
63
+ }
64
+
65
+ export function getWhereFromQuery(query: string) {
66
+ if (!query) {
67
+ throw new Error('Can not parse empty query');
68
+ }
69
+
70
+ const parsed = parser.parse(query);
71
+ return handleCondition(parsed);
72
+ }
@@ -1092,8 +1092,11 @@ router.get('/', auth, async (req, res) => {
1092
1092
  const prices = (await Price.findAll(condition)).map((x) => x.toJSON());
1093
1093
  const docs = list.map((x) => x.toJSON());
1094
1094
 
1095
- // @ts-ignore
1096
- docs.forEach((x) => expandLineItems(x.line_items, products, prices));
1095
+ docs.forEach((x) => {
1096
+ // @ts-ignore
1097
+ expandLineItems(x.line_items, products, prices);
1098
+ x.url = getUrl(`/checkout/${x.submit_type}/${x.id}`);
1099
+ });
1097
1100
 
1098
1101
  res.json({ count, list: docs });
1099
1102
  } catch (err) {
@@ -4,6 +4,7 @@ import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
5
  import type { WhereOptions } from 'sequelize';
6
6
 
7
+ import { getWhereFromQuery } from '../libs/api';
7
8
  import { authenticate } from '../libs/security';
8
9
  import { formatMetadata } from '../libs/util';
9
10
  import { Customer } from '../store/models/customer';
@@ -53,6 +54,39 @@ router.get('/', auth, async (req, res) => {
53
54
  }
54
55
  });
55
56
 
57
+ // search customers
58
+ const searchSchema = Joi.object<{
59
+ page: number;
60
+ pageSize: number;
61
+ query: string;
62
+ livemode?: boolean;
63
+ }>({
64
+ page: Joi.number().integer().min(1).default(1),
65
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
66
+ query: Joi.string(),
67
+ livemode: Joi.boolean().empty(''),
68
+ });
69
+ router.get('/search', auth, async (req, res) => {
70
+ const { page, pageSize, query, livemode } = await searchSchema.validateAsync(req.query, {
71
+ stripUnknown: false,
72
+ allowUnknown: true,
73
+ });
74
+
75
+ const where = getWhereFromQuery(query);
76
+ if (typeof livemode === 'boolean') {
77
+ where.livemode = livemode;
78
+ }
79
+ const { rows: list, count } = await Customer.findAndCountAll({
80
+ where,
81
+ order: [['created_at', 'DESC']],
82
+ offset: (page - 1) * pageSize,
83
+ limit: pageSize,
84
+ include: [],
85
+ });
86
+
87
+ res.json({ count, list });
88
+ });
89
+
56
90
  // eslint-disable-next-line consistent-return
57
91
  router.get('/me', user(), async (req, res) => {
58
92
  if (!req.user) {
@@ -4,6 +4,7 @@ import Joi from 'joi';
4
4
  import type { WhereOptions } from 'sequelize';
5
5
 
6
6
  import { syncStripPayment } from '../integrations/stripe/handlers/payment-intent';
7
+ import { getWhereFromQuery } from '../libs/api';
7
8
  import { authenticate } from '../libs/security';
8
9
  import { CheckoutSession } from '../store/models/checkout-session';
9
10
  import { Customer } from '../store/models/customer';
@@ -25,7 +26,7 @@ const authPortal = authenticate<PaymentIntent>({
25
26
  },
26
27
  });
27
28
 
28
- // list payment links
29
+ // list payment intents
29
30
  const paginationSchema = Joi.object<{
30
31
  page: number;
31
32
  pageSize: number;
@@ -102,6 +103,43 @@ router.get('/', authMine, async (req, res) => {
102
103
  }
103
104
  });
104
105
 
106
+ // search payment intents
107
+ const searchSchema = Joi.object<{
108
+ page: number;
109
+ pageSize: number;
110
+ query: string;
111
+ livemode?: boolean;
112
+ }>({
113
+ page: Joi.number().integer().min(1).default(1),
114
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
115
+ query: Joi.string(),
116
+ livemode: Joi.boolean().empty(''),
117
+ });
118
+ router.get('/search', authMine, async (req, res) => {
119
+ const { page, pageSize, query, livemode } = await searchSchema.validateAsync(req.query, {
120
+ stripUnknown: false,
121
+ allowUnknown: true,
122
+ });
123
+
124
+ const where = getWhereFromQuery(query);
125
+ if (typeof livemode === 'boolean') {
126
+ where.livemode = livemode;
127
+ }
128
+ const { rows: list, count } = await Subscription.findAndCountAll({
129
+ where,
130
+ order: [['created_at', 'DESC']],
131
+ offset: (page - 1) * pageSize,
132
+ limit: pageSize,
133
+ include: [
134
+ { model: PaymentCurrency, as: 'paymentCurrency' },
135
+ { model: PaymentMethod, as: 'paymentMethod' },
136
+ { model: Customer, as: 'customer' },
137
+ ],
138
+ });
139
+
140
+ res.json({ count, list });
141
+ });
142
+
105
143
  router.get('/:id', authPortal, async (req, res) => {
106
144
  try {
107
145
  const doc = await PaymentIntent.findOne({
@@ -4,6 +4,7 @@ import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
5
  import type { WhereOptions } from 'sequelize';
6
6
 
7
+ import { getWhereFromQuery } from '../libs/api';
7
8
  import { authenticate } from '../libs/security';
8
9
  import { canUpsell } from '../libs/session';
9
10
  import { PaymentCurrency } from '../store/models/payment-currency';
@@ -40,6 +41,98 @@ export async function getExpandedPrice(id: string) {
40
41
  return null;
41
42
  }
42
43
 
44
+ // list prices
45
+ const paginationSchema = Joi.object<{
46
+ page: number;
47
+ pageSize: number;
48
+ livemode?: boolean;
49
+ active?: boolean;
50
+ type?: string;
51
+ currency_id?: string;
52
+ product_id?: string;
53
+ lookup_key?: string;
54
+ }>({
55
+ page: Joi.number().integer().min(1).default(1),
56
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
57
+ livemode: Joi.boolean().empty(''),
58
+ active: Joi.boolean().empty(''),
59
+ type: Joi.string().empty(''),
60
+ currency_id: Joi.string().empty(''),
61
+ product_id: Joi.string().empty(''),
62
+ lookup_key: Joi.string().empty(''),
63
+ });
64
+ router.get('/', auth, async (req, res) => {
65
+ const { page, pageSize, active, livemode, ...query } = await paginationSchema.validateAsync(req.query, {
66
+ stripUnknown: false,
67
+ allowUnknown: true,
68
+ });
69
+ const where: WhereOptions<Price> = {};
70
+
71
+ if (typeof active === 'boolean') {
72
+ where.active = active;
73
+ }
74
+ if (typeof livemode === 'boolean') {
75
+ where.livemode = livemode;
76
+ }
77
+ ['type', 'currency_id', 'product_id', 'lookup_key'].forEach((key: string) => {
78
+ // @ts-ignore
79
+ if (query[key]) {
80
+ // @ts-ignore
81
+ where[key] = query[key].split(',').map((x: string) => x.trim()).filter(Boolean); // prettier-ignore
82
+ }
83
+ });
84
+
85
+ Object.keys(query)
86
+ .filter((x) => x.startsWith('recurring.'))
87
+ .forEach((key: string) => {
88
+ // @ts-ignore
89
+ where[key] = query[key];
90
+ });
91
+
92
+ const { rows, count } = await Price.findAndCountAll({
93
+ where,
94
+ attributes: ['id'],
95
+ order: [['created_at', 'DESC']],
96
+ offset: (page - 1) * pageSize,
97
+ limit: pageSize,
98
+ });
99
+
100
+ res.json({ count, list: await Promise.all(rows.map((x) => getExpandedPrice(x.id))) });
101
+ });
102
+
103
+ // search prices
104
+ const searchSchema = Joi.object<{
105
+ page: number;
106
+ pageSize: number;
107
+ query: string;
108
+ livemode?: boolean;
109
+ }>({
110
+ page: Joi.number().integer().min(1).default(1),
111
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
112
+ query: Joi.string(),
113
+ livemode: Joi.boolean().empty(''),
114
+ });
115
+ router.get('/search', auth, async (req, res) => {
116
+ const { page, pageSize, query, livemode } = await searchSchema.validateAsync(req.query, {
117
+ stripUnknown: false,
118
+ allowUnknown: true,
119
+ });
120
+
121
+ const where = getWhereFromQuery(query);
122
+ if (typeof livemode === 'boolean') {
123
+ where.livemode = livemode;
124
+ }
125
+ const { rows, count } = await Price.findAndCountAll({
126
+ where,
127
+ attributes: ['id'],
128
+ order: [['created_at', 'DESC']],
129
+ offset: (page - 1) * pageSize,
130
+ limit: pageSize,
131
+ });
132
+
133
+ res.json({ count, list: await Promise.all(rows.map((x) => getExpandedPrice(x.id))) });
134
+ });
135
+
43
136
  // FIXME: @wangshijun use schema validation
44
137
  // create price
45
138
  // eslint-disable-next-line consistent-return
@@ -218,63 +311,4 @@ router.delete('/:id', auth, async (req, res) => {
218
311
  return res.json(price);
219
312
  });
220
313
 
221
- // list products and prices
222
- const paginationSchema = Joi.object<{
223
- page: number;
224
- pageSize: number;
225
- livemode?: boolean;
226
- active?: boolean;
227
- type?: string;
228
- currency_id?: string;
229
- product_id?: string;
230
- lookup_key?: string;
231
- }>({
232
- page: Joi.number().integer().min(1).default(1),
233
- pageSize: Joi.number().integer().min(1).max(100).default(20),
234
- livemode: Joi.boolean().empty(''),
235
- active: Joi.boolean().empty(''),
236
- type: Joi.string().empty(''),
237
- currency_id: Joi.string().empty(''),
238
- product_id: Joi.string().empty(''),
239
- lookup_key: Joi.string().empty(''),
240
- });
241
- router.get('/', auth, async (req, res) => {
242
- const { page, pageSize, active, livemode, ...query } = await paginationSchema.validateAsync(req.query, {
243
- stripUnknown: false,
244
- allowUnknown: true,
245
- });
246
- const where: WhereOptions<Price> = {};
247
-
248
- if (typeof active === 'boolean') {
249
- where.active = active;
250
- }
251
- if (typeof livemode === 'boolean') {
252
- where.livemode = livemode;
253
- }
254
- ['type', 'currency_id', 'product_id', 'lookup_key'].forEach((key: string) => {
255
- // @ts-ignore
256
- if (query[key]) {
257
- // @ts-ignore
258
- where[key] = query[key].split(',').map((x: string) => x.trim()).filter(Boolean); // prettier-ignore
259
- }
260
- });
261
-
262
- Object.keys(query)
263
- .filter((x) => x.startsWith('recurring.'))
264
- .forEach((key: string) => {
265
- // @ts-ignore
266
- where[key] = query[key];
267
- });
268
-
269
- const { rows, count } = await Price.findAndCountAll({
270
- where,
271
- attributes: ['id'],
272
- order: [['created_at', 'DESC']],
273
- offset: (page - 1) * pageSize,
274
- limit: pageSize,
275
- });
276
-
277
- res.json({ count, list: await Promise.all(rows.map((x) => getExpandedPrice(x.id))) });
278
- });
279
-
280
314
  export default router;
@@ -4,6 +4,7 @@ import Joi from 'joi';
4
4
  import pick from 'lodash/pick';
5
5
  import type { WhereOptions } from 'sequelize';
6
6
 
7
+ import { getWhereFromQuery } from '../libs/api';
7
8
  import { authenticate } from '../libs/security';
8
9
  import { formatMetadata } from '../libs/util';
9
10
  import { PaymentCurrency } from '../store/models/payment-currency';
@@ -140,6 +141,39 @@ router.get('/', auth, async (req, res) => {
140
141
  res.json({ count, list });
141
142
  });
142
143
 
144
+ // search products
145
+ const searchSchema = Joi.object<{
146
+ page: number;
147
+ pageSize: number;
148
+ query: string;
149
+ livemode?: boolean;
150
+ }>({
151
+ page: Joi.number().integer().min(1).default(1),
152
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
153
+ query: Joi.string(),
154
+ livemode: Joi.boolean().empty(''),
155
+ });
156
+ router.get('/search', auth, async (req, res) => {
157
+ const { page, pageSize, query, livemode } = await searchSchema.validateAsync(req.query, {
158
+ stripUnknown: false,
159
+ allowUnknown: true,
160
+ });
161
+
162
+ const where = getWhereFromQuery(query);
163
+ if (typeof livemode === 'boolean') {
164
+ where.livemode = livemode;
165
+ }
166
+ const { rows: list, count } = await Product.findAndCountAll({
167
+ where,
168
+ order: [['created_at', 'DESC']],
169
+ offset: (page - 1) * pageSize,
170
+ limit: pageSize,
171
+ include: [{ model: Price, as: 'prices' }],
172
+ });
173
+
174
+ res.json({ count, list });
175
+ });
176
+
143
177
  // get product detail
144
178
  router.get('/:id', auth, async (req, res) => {
145
179
  res.json(await Product.expand(req.params.id as string));
@@ -7,6 +7,7 @@ import pick from 'lodash/pick';
7
7
  import uniq from 'lodash/uniq';
8
8
  import { Transaction, WhereOptions } from 'sequelize';
9
9
 
10
+ import { getWhereFromQuery } from '../libs/api';
10
11
  import dayjs from '../libs/dayjs';
11
12
  import logger from '../libs/logger';
12
13
  import { authenticate } from '../libs/security';
@@ -134,6 +135,44 @@ router.get('/', authMine, async (req, res) => {
134
135
  }
135
136
  });
136
137
 
138
+ // search subscriptions
139
+ const searchSchema = Joi.object<{
140
+ page: number;
141
+ pageSize: number;
142
+ query: string;
143
+ livemode?: boolean;
144
+ }>({
145
+ page: Joi.number().integer().min(1).default(1),
146
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
147
+ query: Joi.string(),
148
+ livemode: Joi.boolean().empty(''),
149
+ });
150
+ router.get('/search', auth, async (req, res) => {
151
+ const { page, pageSize, query, livemode } = await searchSchema.validateAsync(req.query, {
152
+ stripUnknown: false,
153
+ allowUnknown: true,
154
+ });
155
+
156
+ const where = getWhereFromQuery(query);
157
+ if (typeof livemode === 'boolean') {
158
+ where.livemode = livemode;
159
+ }
160
+ const { rows: list, count } = await Subscription.findAndCountAll({
161
+ where,
162
+ order: [['created_at', 'DESC']],
163
+ offset: (page - 1) * pageSize,
164
+ limit: pageSize,
165
+ include: [
166
+ { model: PaymentCurrency, as: 'paymentCurrency' },
167
+ { model: PaymentMethod, as: 'paymentMethod' },
168
+ { model: SubscriptionItem, as: 'items' },
169
+ { model: Customer, as: 'customer' },
170
+ ],
171
+ });
172
+
173
+ res.json({ count, list });
174
+ });
175
+
137
176
  // FIXME: exclude some sensitive fields from PaymentMethod
138
177
  router.get('/:id', authPortal, async (req, res) => {
139
178
  try {
@@ -392,22 +431,26 @@ router.put('/:id', auth, async (req, res) => {
392
431
 
393
432
  // handle updates
394
433
  const updates: Partial<TSubscription> = {};
434
+
435
+ // only metadata can be updated when immutable
395
436
  if (req.body.metadata) {
396
437
  updates.metadata = formatMetadata(req.body.metadata);
397
438
  }
398
439
  if (subscription.isImmutable()) {
399
- if (updates.metadata) {
400
- await subscription.update(updates);
401
- return res.json({ subscription });
402
- }
403
- return res.status(400).json({ error: 'Only metadata can be updated when subscription is immutable' });
404
- }
405
- if (subscription.isActive() === false) {
406
- return res.status(400).json({ error: 'Subscription can only be updated when active' });
440
+ await subscription.update(updates);
441
+ return res.json(subscription);
407
442
  }
443
+
444
+ // only metadata + description can be updated when not active
408
445
  if (req.body.description) {
409
446
  updates.description = req.body.description;
410
447
  }
448
+ if (subscription.isActive() === false) {
449
+ await subscription.update(updates);
450
+ return res.json(subscription);
451
+ }
452
+
453
+ // other updates are allowed
411
454
  if (req.body.payment_behavior) {
412
455
  updates.payment_behavior = req.body.payment_behavior;
413
456
  }
@@ -671,8 +714,7 @@ router.put('/:id', auth, async (req, res) => {
671
714
  }
672
715
 
673
716
  await subscription.update(updates);
674
-
675
- return res.json({ subscription, invoice });
717
+ return res.json(subscription);
676
718
  });
677
719
  } catch (err) {
678
720
  console.error(err);
@@ -5,8 +5,8 @@ export type Pagination<T = any> = T & {
5
5
  // offset based
6
6
  page?: number;
7
7
  pageSize?: number;
8
- // TODO: cursor based
9
8
  limit?: number;
9
+ // TODO: cursor based
10
10
  starting_after?: string;
11
11
  ending_before?: string;
12
12
 
@@ -14,6 +14,10 @@ export type Pagination<T = any> = T & {
14
14
  [key: string]: any;
15
15
  };
16
16
 
17
+ export type Searchable<T = any> = Pagination<T> & {
18
+ query: string;
19
+ };
20
+
17
21
  export type Paginated<T = any> = {
18
22
  count: number;
19
23
  list: T[];
@@ -0,0 +1,157 @@
1
+ import { Op } from 'sequelize';
2
+
3
+ import { getWhereFromQuery } from '../../src/libs/api';
4
+
5
+ describe('getWhereFromQuery', () => {
6
+ it('should correctly parse > operator', () => {
7
+ const result = getWhereFromQuery('response_status > 200');
8
+ expect(result).toEqual({ response_status: { [Op.gt]: 200 } });
9
+ });
10
+
11
+ it('should correctly parse < operator', () => {
12
+ const result = getWhereFromQuery('retry_count < 5');
13
+ expect(result).toEqual({ retry_count: { [Op.lt]: 5 } });
14
+ });
15
+
16
+ it('should correctly parse >= operator', () => {
17
+ const result = getWhereFromQuery('response_status >= 200');
18
+ expect(result).toEqual({ response_status: { [Op.gte]: 200 } });
19
+ });
20
+
21
+ it('should correctly parse <= operator', () => {
22
+ const result = getWhereFromQuery('retry_count <= 5');
23
+ expect(result).toEqual({ retry_count: { [Op.lte]: 5 } });
24
+ });
25
+
26
+ it('should correctly parse = operator', () => {
27
+ const result = getWhereFromQuery("status = 'succeeded'");
28
+ expect(result).toEqual({ status: 'succeeded' });
29
+ });
30
+
31
+ it('should correctly parse != operator', () => {
32
+ const result = getWhereFromQuery("status != 'failed'");
33
+ expect(result).toEqual({ status: { [Op.ne]: 'failed' } });
34
+ });
35
+
36
+ it('should correctly parse IS operator', () => {
37
+ const result = getWhereFromQuery("status IS 'failed'");
38
+ expect(result).toEqual({ status: 'failed' });
39
+ });
40
+
41
+ it('should correctly parse IS NOT operator', () => {
42
+ const result = getWhereFromQuery("status IS NOT 'failed'");
43
+ expect(result).toEqual({ status: { [Op.ne]: 'failed' } });
44
+ });
45
+
46
+ it('should correctly parse IS NOT operator: NULL', () => {
47
+ const result = getWhereFromQuery('status IS NOT null');
48
+ expect(result).toEqual({ status: { [Op.ne]: null } });
49
+ });
50
+
51
+ it('should correctly parse LIKE operator', () => {
52
+ const result = getWhereFromQuery("status LIKE 'fail'");
53
+ expect(result).toEqual({ status: { [Op.like]: 'fail' } });
54
+ });
55
+
56
+ it('should correctly parse NOT LIKE operator', () => {
57
+ const result = getWhereFromQuery("status NOT LIKE 'fail'");
58
+ expect(result).toEqual({ status: { [Op.notLike]: 'fail' } });
59
+ });
60
+
61
+ it('should correctly parse IN operator', () => {
62
+ const result = getWhereFromQuery('status in (1,2,3)');
63
+ expect(result).toEqual({ status: { [Op.in]: [1, 2, 3] } });
64
+ });
65
+
66
+ it('should correctly parse NOT IN operator', () => {
67
+ const result = getWhereFromQuery('status NOT IN (1,2,3)');
68
+ expect(result).toEqual({ status: { [Op.notIn]: [1, 2, 3] } });
69
+ });
70
+
71
+ it('should correctly parse BETWEEN operator', () => {
72
+ const result = getWhereFromQuery('age BETWEEN 1 AND 3');
73
+ expect(result).toEqual({ age: { [Op.between]: [1, 3] } });
74
+ });
75
+
76
+ it('should correctly parse NOT BETWEEN operator', () => {
77
+ const result = getWhereFromQuery('age NOT BETWEEN 1 AND 3');
78
+ expect(result).toEqual({ age: { [Op.notBetween]: [1, 3] } });
79
+ });
80
+
81
+ it('should correctly parse AND operator', () => {
82
+ const result = getWhereFromQuery('response_status >= 200 AND response_status < 300');
83
+ expect(result).toEqual({
84
+ [Op.and]: [{ response_status: { [Op.gte]: 200 } }, { response_status: { [Op.lt]: 300 } }],
85
+ });
86
+ });
87
+
88
+ it('should correctly parse AND operator with different field types', () => {
89
+ const result = getWhereFromQuery("status = 'succeeded' AND response_status = 200");
90
+ expect(result).toEqual({ [Op.and]: [{ status: 'succeeded' }, { response_status: 200 }] });
91
+ });
92
+
93
+ it('should correctly parse AND operator for BETWEEN condition', () => {
94
+ const result = getWhereFromQuery('retry_count >= 1 AND retry_count <= 3');
95
+ expect(result).toEqual({ [Op.and]: [{ retry_count: { [Op.gte]: 1 } }, { retry_count: { [Op.lte]: 3 } }] });
96
+ });
97
+
98
+ it('should correctly parse OR operator with different field types', () => {
99
+ const result = getWhereFromQuery("status = 'succeeded' OR response_status = 200");
100
+ expect(result).toEqual({ [Op.or]: [{ status: 'succeeded' }, { response_status: 200 }] });
101
+ });
102
+
103
+ it('should correctly parse OR operator with Boolean and Number fields', () => {
104
+ const result = getWhereFromQuery('livemode = true OR retry_count > 2');
105
+ expect(result).toEqual({ [Op.or]: [{ livemode: true }, { retry_count: { [Op.gt]: 2 } }] });
106
+ });
107
+
108
+ it('should correctly parse OR operator with boolean,number,string fields', () => {
109
+ const result = getWhereFromQuery('livemode = true OR retry_count > 2 OR status = "succeeded"');
110
+ expect(result).toEqual({
111
+ [Op.or]: [{ [Op.or]: [{ livemode: true }, { retry_count: { [Op.gt]: 2 } }] }, { status: 'succeeded' }],
112
+ });
113
+ });
114
+
115
+ it('should correctly parse OR operator with two String fields', () => {
116
+ const result = getWhereFromQuery("event_id LIKE '%event%' OR status != 'failed'");
117
+ expect(result).toEqual({ [Op.or]: [{ event_id: { [Op.like]: '%event%' } }, { status: { [Op.ne]: 'failed' } }] });
118
+ });
119
+
120
+ it('should throw an error when the query is empty', () => {
121
+ expect(() => getWhereFromQuery('')).toThrow(/empty query/);
122
+ });
123
+
124
+ it('should correctly parse AND operator x3 for metadata', () => {
125
+ const result = getWhereFromQuery(
126
+ // 'metadata.key IS NULL AND metadata.nftId="3" AND status IN ("succeeded","failed")'
127
+ 'metadata.key IS NULL AND metadata.nftId="3" AND metadata.appId="4"'
128
+ );
129
+ expect(result).toEqual({
130
+ [Op.and]: [
131
+ {
132
+ [Op.and]: [{ 'metadata.key': null }, { 'metadata.nftId': '3' }],
133
+ },
134
+ { 'metadata.appId': '4' },
135
+ ],
136
+ });
137
+ });
138
+
139
+ it('should correctly parse AND + IN operator for metadata', () => {
140
+ const result = getWhereFromQuery(
141
+ 'metadata.key IS NULL AND metadata.nftId="3" AND status IN ("succeeded","failed")'
142
+ );
143
+ expect(result).toEqual({
144
+ [Op.and]: [
145
+ {
146
+ [Op.and]: [{ 'metadata.key': null }, { 'metadata.nftId': '3' }],
147
+ },
148
+ { status: { [Op.in]: ['succeeded', 'failed'] } },
149
+ ],
150
+ });
151
+ });
152
+
153
+ // eslint-disable-next-line jest/no-disabled-tests
154
+ it.skip('should throw an error when an unsupported operator is used', () => {
155
+ expect(() => getWhereFromQuery('status === "failed"')).toThrow(/Operator not supported/);
156
+ });
157
+ });
@@ -0,0 +1,135 @@
1
+ import {
2
+ getCheckoutMode,
3
+ getPriceCurrencyOptions,
4
+ getPriceUintAmountByCurrency,
5
+ getRecurringPeriod,
6
+ } from '../../src/libs/session';
7
+ import type { TLineItemExpanded } from '../../src/store/models';
8
+
9
+ describe('getCheckoutMode', () => {
10
+ it('should return "setup" when items array is empty', () => {
11
+ const result = getCheckoutMode([]);
12
+ expect(result).toBe('setup');
13
+ });
14
+
15
+ it('should return "setup" when all items are metered', () => {
16
+ const items = [
17
+ { price: { type: 'recurring', recurring: { usage_type: 'metered' } } },
18
+ { price: { type: 'recurring', recurring: { usage_type: 'metered' } } },
19
+ ];
20
+ const result = getCheckoutMode(items as TLineItemExpanded[]);
21
+ expect(result).toBe('setup');
22
+ });
23
+
24
+ it('should return "subscription" when some items are recurring', () => {
25
+ const items = [
26
+ { price: { type: 'recurring', recurring: { usage_type: 'metered' } } },
27
+ { price: { type: 'recurring' } },
28
+ ];
29
+ const result = getCheckoutMode(items as TLineItemExpanded[]);
30
+ expect(result).toBe('subscription');
31
+ });
32
+
33
+ it('should return "payment" when none of the above conditions are met', () => {
34
+ const items = [{ price: { type: 'one_time' } }, { price: { type: 'one_time' } }];
35
+ const result = getCheckoutMode(items as TLineItemExpanded[]);
36
+ expect(result).toBe('payment');
37
+ });
38
+ });
39
+
40
+ describe('getPriceCurrencyOptions', () => {
41
+ it('should return currency_options when it is an array', () => {
42
+ const price = {
43
+ currency_options: [
44
+ { currency_id: 'usd', unit_amount: 1000, tiers: null, custom_unit_amount: null },
45
+ { currency_id: 'eur', unit_amount: 900, tiers: null, custom_unit_amount: null },
46
+ ],
47
+ currency_id: 'usd',
48
+ unit_amount: 1000,
49
+ };
50
+ const result = getPriceCurrencyOptions(price as any);
51
+ expect(result).toEqual(price.currency_options);
52
+ });
53
+
54
+ it('should return an array with a single object when currency_options is not an array', () => {
55
+ const price = {
56
+ currency_id: 'usd',
57
+ unit_amount: 1000,
58
+ };
59
+ const result = getPriceCurrencyOptions(price as any);
60
+ expect(result).toEqual([{ currency_id: 'usd', unit_amount: 1000, tiers: null, custom_unit_amount: null }]);
61
+ });
62
+ });
63
+ describe('getPriceUintAmountByCurrency', () => {
64
+ it('should return the unit_amount of the matching currency_id in currency_options', () => {
65
+ const price = {
66
+ currency_options: [
67
+ { currency_id: 'usd', unit_amount: 1000 },
68
+ { currency_id: 'eur', unit_amount: 900 },
69
+ ],
70
+ currency_id: 'usd',
71
+ unit_amount: 1000,
72
+ };
73
+ const result = getPriceUintAmountByCurrency(price as any, 'eur');
74
+ expect(result).toBe(900);
75
+ });
76
+
77
+ it('should return the unit_amount of the price object when the currency_id is not found in currency_options', () => {
78
+ const price = {
79
+ currency_options: [
80
+ { currency_id: 'usd', unit_amount: 1000 },
81
+ { currency_id: 'eur', unit_amount: 900 },
82
+ ],
83
+ currency_id: 'usd',
84
+ unit_amount: 1000,
85
+ };
86
+ const result = getPriceUintAmountByCurrency(price as any, 'gbp');
87
+ expect(result).toBe(1000);
88
+ });
89
+
90
+ it('should return the unit_amount of the price object when currency_options is not an array', () => {
91
+ const price = {
92
+ currency_id: 'usd',
93
+ unit_amount: 1000,
94
+ };
95
+ const result = getPriceUintAmountByCurrency(price as any, 'eur');
96
+ expect(result).toBe(1000);
97
+ });
98
+ });
99
+ describe('getRecurringPeriod', () => {
100
+ it('should return the correct period in milliseconds when interval is "hour"', () => {
101
+ const recurring = { interval: 'hour', interval_count: '1' };
102
+ const result = getRecurringPeriod(recurring as any);
103
+ expect(result).toBe(60 * 60 * 1000);
104
+ });
105
+
106
+ it('should return the correct period in milliseconds when interval is "day"', () => {
107
+ const recurring = { interval: 'day', interval_count: '1' };
108
+ const result = getRecurringPeriod(recurring as any);
109
+ expect(result).toBe(24 * 60 * 60 * 1000);
110
+ });
111
+
112
+ it('should return the correct period in milliseconds when interval is "week"', () => {
113
+ const recurring = { interval: 'week', interval_count: '1' };
114
+ const result = getRecurringPeriod(recurring as any);
115
+ expect(result).toBe(7 * 24 * 60 * 60 * 1000);
116
+ });
117
+
118
+ it('should return the correct period in milliseconds when interval is "month"', () => {
119
+ const recurring = { interval: 'month', interval_count: '1' };
120
+ const result = getRecurringPeriod(recurring as any);
121
+ expect(result).toBe(30 * 24 * 60 * 60 * 1000);
122
+ });
123
+
124
+ it('should return the correct period in milliseconds when interval is "year"', () => {
125
+ const recurring = { interval: 'year', interval_count: '1' };
126
+ const result = getRecurringPeriod(recurring as any);
127
+ expect(result).toBe(365 * 24 * 60 * 60 * 1000);
128
+ });
129
+
130
+ it('should return 0 when interval is an invalid value', () => {
131
+ const recurring = { interval: 'invalid', interval_count: '1' };
132
+ const result = getRecurringPeriod(recurring as any);
133
+ expect(result).toBe(0);
134
+ });
135
+ });
@@ -1,4 +1,4 @@
1
- import { getDaysUntilDue, getDueUnit } from '../../../api/src/libs/subscription';
1
+ import { getDaysUntilDue, getDueUnit } from '../../src/libs/subscription';
2
2
 
3
3
  describe('getDueUnit', () => {
4
4
  it('should return 60 for recurring interval of "hour"', () => {
@@ -0,0 +1,177 @@
1
+ import dayjs from '../../src/libs/dayjs';
2
+ import {
3
+ createCodeGenerator,
4
+ createIdGenerator,
5
+ formatMetadata,
6
+ getMetadataFromQuery,
7
+ getNextRetry,
8
+ tryWithTimeout,
9
+ } from '../../src/libs/util';
10
+
11
+ describe('createIdGenerator', () => {
12
+ it('should return a function that generates an ID with the specified prefix and size', () => {
13
+ const generateId = createIdGenerator('test', 10);
14
+ const id = generateId();
15
+ expect(id.startsWith('test_')).toBe(true);
16
+ expect(id.length).toBe(15); // 10 for ID, 4 for prefix, 1 for underscore
17
+ });
18
+
19
+ it('should return a function that generates an ID with the specified prefix and default size when size is not provided', () => {
20
+ const generateId = createIdGenerator('test');
21
+ const id = generateId();
22
+ expect(id.startsWith('test_')).toBe(true);
23
+ expect(id.length).toBe(29); // 24 for ID, 4 for prefix, 1 for underscore
24
+ });
25
+
26
+ it('should return a function that generates an ID without a prefix when prefix is not provided', () => {
27
+ const generateId = createIdGenerator('', 10);
28
+ const id = generateId();
29
+ expect(id.startsWith('_')).toBe(false);
30
+ expect(id.length).toBe(10);
31
+ });
32
+ });
33
+
34
+ describe('createCodeGenerator', () => {
35
+ it('should return a function that generates a code with the specified prefix and size', () => {
36
+ const generateCode = createCodeGenerator('test', 10);
37
+ const code = generateCode();
38
+ expect(code.startsWith('test_')).toBe(true);
39
+ expect(code.length).toBe(15); // 10 for code, 4 for prefix, 1 for underscore
40
+ });
41
+
42
+ it('should return a function that generates a code with the specified prefix and default size when size is not provided', () => {
43
+ const generateCode = createCodeGenerator('test');
44
+ const code = generateCode();
45
+ expect(code.startsWith('test_')).toBe(true);
46
+ expect(code.length).toBe(29); // 24 for code, 4 for prefix, 1 for underscore
47
+ });
48
+
49
+ it('should return a function that generates a code without a prefix when prefix is not provided', () => {
50
+ const generateCode = createCodeGenerator('', 10);
51
+ const code = generateCode();
52
+ expect(code.startsWith('_')).toBe(false);
53
+ expect(code.length).toBe(10);
54
+ });
55
+ });
56
+
57
+ describe('formatMetadata', () => {
58
+ it('should return an empty object when metadata is undefined', () => {
59
+ const result = formatMetadata(undefined);
60
+ expect(result).toEqual({});
61
+ });
62
+
63
+ it('should return a formatted object when metadata is an array', () => {
64
+ const metadata = [
65
+ { key: 'key1', value: 'value1' },
66
+ { key: 'key2', value: '' },
67
+ { key: 'key3', value: 'value3' },
68
+ ];
69
+ const result = formatMetadata(metadata);
70
+ expect(result).toEqual({ key1: 'value1', key3: 'value3' });
71
+ });
72
+
73
+ it('should return a formatted object when metadata is an object', () => {
74
+ const metadata = {
75
+ key1: 'value1',
76
+ key2: '',
77
+ key3: 'value3',
78
+ };
79
+ const result = formatMetadata(metadata);
80
+ expect(result).toEqual({ key1: 'value1', key3: 'value3' });
81
+ });
82
+ });
83
+
84
+ describe('tryWithTimeout', () => {
85
+ it('should resolve with the result of the async function when it completes before the timeout', async () => {
86
+ const asyncFn = jest.fn().mockResolvedValue('result');
87
+ const result = await tryWithTimeout(asyncFn, 100);
88
+ expect(result).toBe('result');
89
+ expect(asyncFn).toHaveBeenCalled();
90
+ });
91
+
92
+ it('should reject with a timeout error when the async function does not complete before the timeout', async () => {
93
+ // eslint-disable-next-line no-promise-executor-return
94
+ const asyncFn = jest.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 200)));
95
+ await expect(tryWithTimeout(asyncFn, 100)).rejects.toThrow('Operation timed out after 100 ms');
96
+ expect(asyncFn).toHaveBeenCalled();
97
+ });
98
+
99
+ it('should reject with the error of the async function when it rejects', async () => {
100
+ const asyncFn = jest.fn().mockRejectedValue(new Error('error'));
101
+ await expect(tryWithTimeout(asyncFn, 100)).rejects.toThrow('error');
102
+ expect(asyncFn).toHaveBeenCalled();
103
+ });
104
+
105
+ it('should throw an error when the provided asyncFn is not a function', () => {
106
+ // @ts-ignore
107
+ expect(() => tryWithTimeout(null)).toThrow('Must provide a valid asyncFn function');
108
+ });
109
+ });
110
+
111
+ describe('getNextRetry', () => {
112
+ it('should return the current time plus 1 second when retry count is 0', () => {
113
+ const now = dayjs().unix();
114
+ const result = getNextRetry(0);
115
+ expect(result).toBe(now + 1);
116
+ });
117
+
118
+ it('should return the current time plus 2 seconds when retry count is 1', () => {
119
+ const now = dayjs().unix();
120
+ const result = getNextRetry(1);
121
+ expect(result).toBe(now + 2);
122
+ });
123
+
124
+ it('should return the current time plus 4 seconds when retry count is 2', () => {
125
+ const now = dayjs().unix();
126
+ const result = getNextRetry(2);
127
+ expect(result).toBe(now + 4);
128
+ });
129
+
130
+ it('should return the current time plus 8 seconds when retry count is 3', () => {
131
+ const now = dayjs().unix();
132
+ const result = getNextRetry(3);
133
+ expect(result).toBe(now + 8);
134
+ });
135
+ });
136
+
137
+ describe('getMetadataFromQuery', () => {
138
+ it('should return an empty object when the query object is empty', () => {
139
+ const result = getMetadataFromQuery({});
140
+ expect(result).toEqual({});
141
+ });
142
+
143
+ it('should return an object with metadata when the query object contains metadata keys', () => {
144
+ const query = {
145
+ 'metadata.key1': 'value1',
146
+ 'metadata.key2': 'value2',
147
+ };
148
+ const result = getMetadataFromQuery(query);
149
+ expect(result).toEqual({
150
+ key1: 'value1',
151
+ key2: 'value2',
152
+ });
153
+ });
154
+
155
+ it('should return an object without non-metadata keys when the query object contains non-metadata keys', () => {
156
+ const query = {
157
+ 'metadata.key1': 'value1',
158
+ key2: 'value2',
159
+ };
160
+ const result = getMetadataFromQuery(query);
161
+ expect(result).toEqual({
162
+ key1: 'value1',
163
+ });
164
+ });
165
+
166
+ it('should ignore metadata keys with undefined or null values', () => {
167
+ const query = {
168
+ 'metadata.key1': 'value1',
169
+ 'metadata.key2': undefined,
170
+ 'metadata.key3': null,
171
+ };
172
+ const result = getMetadataFromQuery(query);
173
+ expect(result).toEqual({
174
+ key1: 'value1',
175
+ });
176
+ });
177
+ });
package/api/third.d.ts CHANGED
@@ -12,6 +12,8 @@ declare module 'flat';
12
12
 
13
13
  declare module '@abtnode/cron';
14
14
 
15
+ declare module 'sql-where-parser';
16
+
15
17
  declare module 'cls-hooked';
16
18
 
17
19
  namespace Express {
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.13.81
17
+ version: 1.13.83
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/jest.config.js CHANGED
@@ -3,9 +3,11 @@ module.exports = {
3
3
  verbose: true,
4
4
  preset: 'ts-jest',
5
5
  testEnvironment: 'node',
6
- collectCoverage: true,
7
6
  coverageDirectory: 'coverage',
7
+ restoreMocks: true,
8
8
  clearMocks: true,
9
+ globalSetup: '@blocklet/sdk/lib/util/jest-setup.js',
10
+ globalTeardown: '@blocklet/sdk/lib/util/jest-teardown.js',
9
11
  transform: {
10
12
  '^.+\\.ts?$': 'ts-jest',
11
13
  },
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.13.81",
3
+ "version": "1.13.83",
4
4
  "scripts": {
5
5
  "dev": "COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev",
6
6
  "eject": "vite eject",
7
7
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
8
8
  "lint:fix": "npm run lint -- --fix",
9
9
  "format": "prettier -w src",
10
- "test": "jest",
10
+ "test": "node scripts/jest.js",
11
11
  "coverage": "npm run test -- --coverage",
12
12
  "start": "cross-env NODE_ENV=development nodemon api/dev.ts -w api",
13
13
  "clean": "node scripts/build-clean.js",
@@ -42,15 +42,15 @@
42
42
  ]
43
43
  },
44
44
  "dependencies": {
45
- "@abtnode/cron": "^1.16.20",
45
+ "@abtnode/cron": "1.16.21-beta-e828f413",
46
46
  "@arcblock/did": "^1.18.106",
47
47
  "@arcblock/did-auth-storage-nedb": "^1.7.1",
48
48
  "@arcblock/did-connect": "^2.8.23",
49
49
  "@arcblock/did-util": "^1.18.106",
50
50
  "@arcblock/jwt": "^1.18.106",
51
51
  "@arcblock/ux": "^2.8.23",
52
- "@blocklet/logger": "^1.16.20",
53
- "@blocklet/sdk": "^1.16.20",
52
+ "@blocklet/logger": "1.16.21-beta-e828f413",
53
+ "@blocklet/sdk": "1.16.21-beta-e828f413",
54
54
  "@blocklet/ui-react": "^2.8.23",
55
55
  "@blocklet/uploader": "^0.0.55",
56
56
  "@mui/icons-material": "^5.14.19",
@@ -97,6 +97,7 @@
97
97
  "react-router-dom": "^6.20.1",
98
98
  "rimraf": "^3.0.2",
99
99
  "sequelize": "^6.35.1",
100
+ "sql-where-parser": "^2.2.1",
100
101
  "sqlite3": "^5.1.6",
101
102
  "stripe": "^13.11.0",
102
103
  "typewriter-effect": "^2.21.0",
@@ -106,10 +107,10 @@
106
107
  "validator": "^13.11.0"
107
108
  },
108
109
  "devDependencies": {
109
- "@abtnode/types": "^1.16.20",
110
+ "@abtnode/types": "1.16.21-beta-e828f413",
110
111
  "@arcblock/eslint-config": "^0.2.4",
111
112
  "@arcblock/eslint-config-ts": "^0.2.4",
112
- "@did-pay/types": "1.13.81",
113
+ "@did-pay/types": "1.13.83",
113
114
  "@types/cookie-parser": "^1.4.6",
114
115
  "@types/cors": "^2.8.17",
115
116
  "@types/dotenv-flow": "^3.3.3",
@@ -148,5 +149,5 @@
148
149
  "parser": "typescript"
149
150
  }
150
151
  },
151
- "gitHead": "ccdd4c815330b40a748619132db7bb3332850749"
152
+ "gitHead": "e6357508c38296224f7c493b6630a45fd359d6bc"
152
153
  }
@@ -0,0 +1 @@
1
+ require('../../../tools/jest');