payment-kit 1.13.17 → 1.13.19

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