payment-kit 1.13.15

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 (222) hide show
  1. package/.eslintrc.js +15 -0
  2. package/README.md +3 -0
  3. package/api/dev.ts +6 -0
  4. package/api/hooks/pre-start.js +12 -0
  5. package/api/src/hooks/pre-start.ts +21 -0
  6. package/api/src/index.ts +92 -0
  7. package/api/src/jobs/event.ts +72 -0
  8. package/api/src/jobs/invoice.ts +148 -0
  9. package/api/src/jobs/payment.ts +208 -0
  10. package/api/src/jobs/subscription.ts +301 -0
  11. package/api/src/jobs/webhook.ts +113 -0
  12. package/api/src/libs/audit.ts +73 -0
  13. package/api/src/libs/auth.ts +40 -0
  14. package/api/src/libs/chain/arcblock.ts +13 -0
  15. package/api/src/libs/dayjs.ts +17 -0
  16. package/api/src/libs/env.ts +5 -0
  17. package/api/src/libs/hooks.ts +42 -0
  18. package/api/src/libs/logger.ts +27 -0
  19. package/api/src/libs/middleware.ts +12 -0
  20. package/api/src/libs/payment.ts +53 -0
  21. package/api/src/libs/queue/index.ts +263 -0
  22. package/api/src/libs/queue/store.ts +47 -0
  23. package/api/src/libs/security.ts +95 -0
  24. package/api/src/libs/session.ts +164 -0
  25. package/api/src/libs/util.ts +93 -0
  26. package/api/src/locales/en.ts +3 -0
  27. package/api/src/locales/index.ts +37 -0
  28. package/api/src/locales/zh.ts +3 -0
  29. package/api/src/routes/checkout-sessions.ts +536 -0
  30. package/api/src/routes/connect/collect.ts +109 -0
  31. package/api/src/routes/connect/pay.ts +116 -0
  32. package/api/src/routes/connect/setup.ts +121 -0
  33. package/api/src/routes/connect/shared.ts +410 -0
  34. package/api/src/routes/connect/subscribe.ts +128 -0
  35. package/api/src/routes/customers.ts +70 -0
  36. package/api/src/routes/events.ts +76 -0
  37. package/api/src/routes/index.ts +59 -0
  38. package/api/src/routes/invoices.ts +126 -0
  39. package/api/src/routes/payment-currencies.ts +38 -0
  40. package/api/src/routes/payment-intents.ts +122 -0
  41. package/api/src/routes/payment-links.ts +221 -0
  42. package/api/src/routes/payment-methods.ts +39 -0
  43. package/api/src/routes/prices.ts +134 -0
  44. package/api/src/routes/products.ts +191 -0
  45. package/api/src/routes/settings.ts +33 -0
  46. package/api/src/routes/subscription-items.ts +148 -0
  47. package/api/src/routes/subscriptions.ts +254 -0
  48. package/api/src/routes/usage-records.ts +120 -0
  49. package/api/src/routes/webhook-attempts.ts +57 -0
  50. package/api/src/routes/webhook-endpoints.ts +105 -0
  51. package/api/src/store/migrate.ts +16 -0
  52. package/api/src/store/migrations/20230905-genesis.ts +52 -0
  53. package/api/src/store/migrations/20230911-seeding.ts +145 -0
  54. package/api/src/store/models/checkout-session.ts +395 -0
  55. package/api/src/store/models/coupon.ts +137 -0
  56. package/api/src/store/models/customer.ts +199 -0
  57. package/api/src/store/models/discount.ts +116 -0
  58. package/api/src/store/models/event.ts +111 -0
  59. package/api/src/store/models/index.ts +165 -0
  60. package/api/src/store/models/invoice-item.ts +185 -0
  61. package/api/src/store/models/invoice.ts +492 -0
  62. package/api/src/store/models/job.ts +75 -0
  63. package/api/src/store/models/payment-currency.ts +139 -0
  64. package/api/src/store/models/payment-intent.ts +282 -0
  65. package/api/src/store/models/payment-link.ts +219 -0
  66. package/api/src/store/models/payment-method.ts +169 -0
  67. package/api/src/store/models/price.ts +266 -0
  68. package/api/src/store/models/product.ts +162 -0
  69. package/api/src/store/models/promotion-code.ts +112 -0
  70. package/api/src/store/models/setup-intent.ts +206 -0
  71. package/api/src/store/models/subscription-item.ts +103 -0
  72. package/api/src/store/models/subscription-schedule.ts +157 -0
  73. package/api/src/store/models/subscription.ts +307 -0
  74. package/api/src/store/models/types.ts +406 -0
  75. package/api/src/store/models/usage-record.ts +132 -0
  76. package/api/src/store/models/webhook-attempt.ts +96 -0
  77. package/api/src/store/models/webhook-endpoint.ts +96 -0
  78. package/api/src/store/sequelize.ts +15 -0
  79. package/api/third.d.ts +28 -0
  80. package/blocklet.md +3 -0
  81. package/blocklet.yml +89 -0
  82. package/index.html +14 -0
  83. package/logo.png +0 -0
  84. package/package.json +133 -0
  85. package/public/.gitkeep +0 -0
  86. package/screenshots/.gitkeep +0 -0
  87. package/screenshots/1-subscription.png +0 -0
  88. package/screenshots/2-customer-1.png +0 -0
  89. package/screenshots/3-customer-2.png +0 -0
  90. package/screenshots/4-admin-3.png +0 -0
  91. package/screenshots/5-admin-4.png +0 -0
  92. package/scripts/build-clean.js +6 -0
  93. package/scripts/bump-version.mjs +35 -0
  94. package/src/app.tsx +68 -0
  95. package/src/components/actions.tsx +85 -0
  96. package/src/components/blockchain/tx.tsx +29 -0
  97. package/src/components/checkout/amount.tsx +24 -0
  98. package/src/components/checkout/error.tsx +30 -0
  99. package/src/components/checkout/footer.tsx +12 -0
  100. package/src/components/checkout/form/address.tsx +38 -0
  101. package/src/components/checkout/form/index.tsx +295 -0
  102. package/src/components/checkout/header.tsx +23 -0
  103. package/src/components/checkout/pay.tsx +222 -0
  104. package/src/components/checkout/product-card.tsx +56 -0
  105. package/src/components/checkout/product-item.tsx +37 -0
  106. package/src/components/checkout/skeleton/overview.tsx +21 -0
  107. package/src/components/checkout/skeleton/payment.tsx +35 -0
  108. package/src/components/checkout/success.tsx +183 -0
  109. package/src/components/checkout/summary.tsx +34 -0
  110. package/src/components/collapse.tsx +50 -0
  111. package/src/components/confirm.tsx +55 -0
  112. package/src/components/copyable.tsx +38 -0
  113. package/src/components/currency.tsx +15 -0
  114. package/src/components/customer/actions.tsx +73 -0
  115. package/src/components/data.tsx +20 -0
  116. package/src/components/drawer-form.tsx +77 -0
  117. package/src/components/error-fallback.tsx +7 -0
  118. package/src/components/error.tsx +39 -0
  119. package/src/components/event/list.tsx +217 -0
  120. package/src/components/info-card.tsx +40 -0
  121. package/src/components/info-metric.tsx +35 -0
  122. package/src/components/info-row.tsx +28 -0
  123. package/src/components/input.tsx +40 -0
  124. package/src/components/invoice/action.tsx +94 -0
  125. package/src/components/invoice/list.tsx +225 -0
  126. package/src/components/invoice/table.tsx +110 -0
  127. package/src/components/layout.tsx +70 -0
  128. package/src/components/livemode.tsx +23 -0
  129. package/src/components/metadata/editor.tsx +57 -0
  130. package/src/components/metadata/form.tsx +45 -0
  131. package/src/components/payment-intent/actions.tsx +81 -0
  132. package/src/components/payment-intent/list.tsx +204 -0
  133. package/src/components/payment-link/actions.tsx +114 -0
  134. package/src/components/payment-link/after-pay.tsx +87 -0
  135. package/src/components/payment-link/before-pay.tsx +175 -0
  136. package/src/components/payment-link/item.tsx +135 -0
  137. package/src/components/payment-link/product-select.tsx +66 -0
  138. package/src/components/payment-link/rename.tsx +64 -0
  139. package/src/components/portal/invoice/list.tsx +110 -0
  140. package/src/components/portal/subscription/cancel.tsx +83 -0
  141. package/src/components/portal/subscription/list.tsx +232 -0
  142. package/src/components/price/actions.tsx +21 -0
  143. package/src/components/price/form.tsx +292 -0
  144. package/src/components/product/actions.tsx +125 -0
  145. package/src/components/product/add-price.tsx +59 -0
  146. package/src/components/product/create.tsx +97 -0
  147. package/src/components/product/edit-price.tsx +75 -0
  148. package/src/components/product/edit.tsx +67 -0
  149. package/src/components/product/features.tsx +32 -0
  150. package/src/components/product/form.tsx +76 -0
  151. package/src/components/relative-time.tsx +41 -0
  152. package/src/components/section/header.tsx +29 -0
  153. package/src/components/status.tsx +12 -0
  154. package/src/components/subscription/actions/cancel.tsx +66 -0
  155. package/src/components/subscription/actions/index.tsx +172 -0
  156. package/src/components/subscription/actions/pause.tsx +83 -0
  157. package/src/components/subscription/items/actions.tsx +31 -0
  158. package/src/components/subscription/items/index.tsx +107 -0
  159. package/src/components/subscription/list.tsx +200 -0
  160. package/src/components/switch.tsx +48 -0
  161. package/src/components/table.tsx +66 -0
  162. package/src/components/uploader.tsx +81 -0
  163. package/src/components/webhook/attempts.tsx +149 -0
  164. package/src/contexts/products.tsx +42 -0
  165. package/src/contexts/session.ts +10 -0
  166. package/src/contexts/settings.tsx +54 -0
  167. package/src/env.d.ts +17 -0
  168. package/src/global.css +97 -0
  169. package/src/hooks/mobile.ts +15 -0
  170. package/src/index.tsx +6 -0
  171. package/src/libs/api.ts +19 -0
  172. package/src/libs/dayjs.ts +17 -0
  173. package/src/libs/util.ts +474 -0
  174. package/src/locales/en.tsx +395 -0
  175. package/src/locales/index.tsx +8 -0
  176. package/src/locales/zh.tsx +389 -0
  177. package/src/pages/admin/billing/index.tsx +56 -0
  178. package/src/pages/admin/billing/invoices/detail.tsx +215 -0
  179. package/src/pages/admin/billing/invoices/index.tsx +5 -0
  180. package/src/pages/admin/billing/subscriptions/detail.tsx +237 -0
  181. package/src/pages/admin/billing/subscriptions/index.tsx +5 -0
  182. package/src/pages/admin/customers/customers/detail.tsx +209 -0
  183. package/src/pages/admin/customers/customers/index.tsx +109 -0
  184. package/src/pages/admin/customers/index.tsx +47 -0
  185. package/src/pages/admin/developers/events/detail.tsx +77 -0
  186. package/src/pages/admin/developers/events/index.tsx +5 -0
  187. package/src/pages/admin/developers/index.tsx +60 -0
  188. package/src/pages/admin/developers/logs.tsx +3 -0
  189. package/src/pages/admin/developers/overview.tsx +3 -0
  190. package/src/pages/admin/developers/webhooks/detail.tsx +109 -0
  191. package/src/pages/admin/developers/webhooks/index.tsx +102 -0
  192. package/src/pages/admin/index.tsx +120 -0
  193. package/src/pages/admin/overview.tsx +3 -0
  194. package/src/pages/admin/payments/index.tsx +65 -0
  195. package/src/pages/admin/payments/intents/detail.tsx +205 -0
  196. package/src/pages/admin/payments/intents/index.tsx +5 -0
  197. package/src/pages/admin/payments/links/create.tsx +141 -0
  198. package/src/pages/admin/payments/links/detail.tsx +318 -0
  199. package/src/pages/admin/payments/links/index.tsx +167 -0
  200. package/src/pages/admin/products/coupons/index.tsx +3 -0
  201. package/src/pages/admin/products/index.tsx +81 -0
  202. package/src/pages/admin/products/prices/actions.tsx +151 -0
  203. package/src/pages/admin/products/prices/detail.tsx +203 -0
  204. package/src/pages/admin/products/prices/list.tsx +95 -0
  205. package/src/pages/admin/products/pricing-tables.tsx +3 -0
  206. package/src/pages/admin/products/products/create.tsx +105 -0
  207. package/src/pages/admin/products/products/detail.tsx +246 -0
  208. package/src/pages/admin/products/products/index.tsx +154 -0
  209. package/src/pages/admin/settings/branding.tsx +3 -0
  210. package/src/pages/admin/settings/business.tsx +3 -0
  211. package/src/pages/admin/settings/index.tsx +47 -0
  212. package/src/pages/admin/settings/payment-methods.tsx +80 -0
  213. package/src/pages/checkout/index.tsx +38 -0
  214. package/src/pages/checkout/pay.tsx +89 -0
  215. package/src/pages/customer/index.tsx +93 -0
  216. package/src/pages/customer/invoice.tsx +147 -0
  217. package/src/pages/home.tsx +9 -0
  218. package/tsconfig.api.json +9 -0
  219. package/tsconfig.eslint.json +7 -0
  220. package/tsconfig.json +99 -0
  221. package/tsconfig.types.json +11 -0
  222. package/vite.config.ts +19 -0
@@ -0,0 +1,254 @@
1
+ import { isValid } from '@arcblock/did';
2
+ import { Router } from 'express';
3
+ import Joi from 'joi';
4
+ import type { WhereOptions } from 'sequelize';
5
+
6
+ import { subscriptionQueue } from '../jobs/subscription';
7
+ import dayjs from '../libs/dayjs';
8
+ import logger from '../libs/logger';
9
+ import { authenticate } from '../libs/security';
10
+ import { expandLineItems } from '../libs/session';
11
+ import { Customer } from '../store/models/customer';
12
+ import { PaymentCurrency } from '../store/models/payment-currency';
13
+ import { PaymentMethod } from '../store/models/payment-method';
14
+ import { Price } from '../store/models/price';
15
+ import { Product } from '../store/models/product';
16
+ import { Subscription } from '../store/models/subscription';
17
+ import { SubscriptionItem } from '../store/models/subscription-item';
18
+
19
+ const router = Router();
20
+ const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
21
+ const authMine = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'], mine: true });
22
+ const authPortal = authenticate<Subscription>({
23
+ component: true,
24
+ roles: ['owner', 'admin'],
25
+ record: {
26
+ // @ts-ignore
27
+ model: Subscription,
28
+ field: 'customer_id',
29
+ },
30
+ });
31
+
32
+ const schema = Joi.object<{
33
+ page: number;
34
+ size: number;
35
+ status?: string;
36
+ customer_id?: string;
37
+ customer_did?: string;
38
+ livemode?: boolean;
39
+ }>({
40
+ page: Joi.number().integer().min(1).default(1),
41
+ size: Joi.number().integer().min(1).max(100).default(20),
42
+ status: Joi.string().empty(''),
43
+ customer_id: Joi.string().empty(''),
44
+ customer_did: Joi.string().empty(''),
45
+ livemode: Joi.boolean().empty(''),
46
+ });
47
+ router.get('/', authMine, async (req, res) => {
48
+ const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
49
+ const where: WhereOptions<Subscription> = {};
50
+
51
+ if (query.status) {
52
+ where.status = query.status
53
+ .split(',')
54
+ .map((x) => x.trim())
55
+ .filter(Boolean);
56
+ }
57
+ if (query.customer_id) {
58
+ where.customer_id = query.customer_id;
59
+ }
60
+ if (query.customer_did && isValid(query.customer_did)) {
61
+ const customer = await Customer.findOne({ where: { did: query.customer_did } });
62
+ if (customer) {
63
+ where.customer_id = customer.id;
64
+ }
65
+ }
66
+ if (typeof query.livemode === 'boolean') {
67
+ where.livemode = query.livemode;
68
+ }
69
+
70
+ try {
71
+ const { rows: list, count } = await Subscription.findAndCountAll({
72
+ where,
73
+ order: [['created_at', 'DESC']],
74
+ offset: (page - 1) * size,
75
+ limit: size,
76
+ include: [
77
+ { model: PaymentCurrency, as: 'paymentCurrency' },
78
+ { model: PaymentMethod, as: 'paymentMethod' },
79
+ { model: SubscriptionItem, as: 'items' },
80
+ { model: Customer, as: 'customer' },
81
+ ],
82
+ });
83
+
84
+ const products = (await Product.findAll()).map((x) => x.toJSON());
85
+ const prices = (await Price.findAll()).map((x) => x.toJSON());
86
+ const docs = list.map((x) => x.toJSON());
87
+ // @ts-ignore
88
+ docs.forEach((x) => expandLineItems(x.items, products, prices));
89
+
90
+ res.json({ count, list: docs });
91
+ } catch (err) {
92
+ console.error(err);
93
+ res.json({ count: 0, list: [] });
94
+ }
95
+ });
96
+
97
+ // FIXME: exclude some sensitive fields from PaymentMethod
98
+ router.get('/:id', authPortal, async (req, res) => {
99
+ try {
100
+ const doc = await Subscription.findOne({
101
+ where: { id: req.params.id },
102
+ include: [
103
+ { model: PaymentCurrency, as: 'paymentCurrency' },
104
+ { model: PaymentMethod, as: 'paymentMethod' },
105
+ { model: SubscriptionItem, as: 'items' },
106
+ { model: Customer, as: 'customer' },
107
+ ],
108
+ });
109
+
110
+ if (doc) {
111
+ const json = doc.toJSON();
112
+ const products = (await Product.findAll()).map((x) => x.toJSON());
113
+ const prices = (await Price.findAll()).map((x) => x.toJSON());
114
+ // @ts-ignore
115
+ expandLineItems(json.items, products, prices);
116
+ res.json(json);
117
+ } else {
118
+ res.json(null);
119
+ }
120
+ } catch (err) {
121
+ console.error(err);
122
+ res.json(null);
123
+ }
124
+ });
125
+
126
+ router.put('/:id/cancel', authPortal, async (req, res) => {
127
+ const doc = await Subscription.findByPk(req.params.id);
128
+
129
+ if (!doc) {
130
+ return res.status(404).json({ error: 'subscription not found' });
131
+ }
132
+ if (doc.status === 'canceled') {
133
+ return res.status(400).json({ error: 'Subscription already canceled' });
134
+ }
135
+ if (doc.cancel_at) {
136
+ return res.status(400).json({ error: 'subscription scheduled to canceled' });
137
+ }
138
+
139
+ const { at = 'current_period_end', time, feedback = 'other', comment = '' } = req.body;
140
+ if (at === 'custom' && dayjs(time).unix() < dayjs().unix()) {
141
+ return res.status(400).json({ error: 'cancel at must be a future timestamp' });
142
+ }
143
+
144
+ // update cancel at
145
+ const updates: Partial<Subscription> = {};
146
+ if (req.user?.via === 'portal') {
147
+ updates.cancel_at_period_end = true;
148
+ updates.cancel_at = doc.current_period_end;
149
+ updates.cancelation_details = { reason: 'cancellation_requested', feedback, comment };
150
+ } else if (at === 'now') {
151
+ updates.status = 'canceled';
152
+ updates.cancel_at = dayjs().unix();
153
+ updates.canceled_at = dayjs().unix();
154
+ } else if (at === 'current_period_end') {
155
+ updates.cancel_at_period_end = true;
156
+ updates.cancel_at = doc.current_period_end;
157
+ } else {
158
+ updates.cancel_at = dayjs(time).unix();
159
+ subscriptionQueue.push({
160
+ id: `cancel-${doc.id}`,
161
+ job: { subscriptionId: doc.id, action: 'cancel' },
162
+ runAt: updates.cancel_at,
163
+ });
164
+ }
165
+
166
+ await doc.update(updates);
167
+
168
+ return res.json(doc);
169
+ });
170
+
171
+ router.put('/:id/recover', authPortal, async (req, res) => {
172
+ const doc = await Subscription.findByPk(req.params.id);
173
+
174
+ if (!doc) {
175
+ return res.status(404).json({ error: 'Subscription not found' });
176
+ }
177
+ if (!doc.cancel_at_period_end) {
178
+ return res.status(400).json({ error: 'Subscription not recoverable from cancellation config' });
179
+ }
180
+ if (doc.status === 'canceled') {
181
+ return res.status(400).json({ error: 'Subscription not recoverable from cancellation' });
182
+ }
183
+
184
+ await doc.update({ cancel_at: 0, cancel_at_period_end: false });
185
+
186
+ // reschedule jobs
187
+ subscriptionQueue
188
+ .cancel(`cancel-${doc.id}`)
189
+ .then(() => logger.info('subscription cancel job is canceled'))
190
+ .catch((err) => logger.error('subscription cancel job failed to cancel', { error: err }));
191
+ subscriptionQueue.push({
192
+ id: doc.id,
193
+ job: { subscriptionId: doc.id, action: 'cycle' },
194
+ runAt: doc.current_period_end,
195
+ });
196
+
197
+ return res.json(doc);
198
+ });
199
+
200
+ router.put('/:id/pause', auth, async (req, res) => {
201
+ const doc = await Subscription.findByPk(req.params.id);
202
+
203
+ if (!doc) {
204
+ return res.status(404).json({ error: 'subscription not found' });
205
+ }
206
+ if (doc.status === 'paused') {
207
+ return res.status(400).json({ error: 'Subscription already paused' });
208
+ }
209
+
210
+ const { type, resumesAt, behavior } = req.body;
211
+ if (type === 'custom' && dayjs(resumesAt).unix() < dayjs().unix()) {
212
+ return res.status(400).json({ error: 'resumesAt must be a future timestamp' });
213
+ }
214
+
215
+ const timestamp = type === 'custom' ? dayjs(resumesAt).unix() : 0;
216
+ await doc.update({
217
+ status: 'paused',
218
+ pause_collection: {
219
+ resumes_at: timestamp,
220
+ behavior: behavior || 'keep_as_draft',
221
+ },
222
+ });
223
+
224
+ if (timestamp) {
225
+ subscriptionQueue.push({
226
+ id: `resume-${doc.id}`,
227
+ job: { subscriptionId: doc.id, action: 'resume' },
228
+ runAt: timestamp,
229
+ });
230
+ }
231
+
232
+ return res.json(doc);
233
+ });
234
+
235
+ router.put('/:id/resume', auth, async (req, res) => {
236
+ const doc = await Subscription.findByPk(req.params.id);
237
+
238
+ if (!doc) {
239
+ return res.status(404).json({ error: 'Subscription not found' });
240
+ }
241
+ if (doc.status !== 'paused') {
242
+ return res.status(400).json({ error: 'Subscription not paused' });
243
+ }
244
+
245
+ await doc.update({ status: 'active', pause_collection: undefined });
246
+ subscriptionQueue
247
+ .cancel(`resume-${doc.id}`)
248
+ .then(() => logger.info('subscription resume job is canceled'))
249
+ .catch((err) => logger.error('subscription resume job failed to cancel', { error: err }));
250
+
251
+ return res.json(doc);
252
+ });
253
+
254
+ export default router;
@@ -0,0 +1,120 @@
1
+ /* eslint-disable consistent-return */
2
+ import { Router } from 'express';
3
+ import Joi from 'joi';
4
+ import pick from 'lodash/pick';
5
+
6
+ import dayjs from '../libs/dayjs';
7
+ import { authenticate } from '../libs/security';
8
+ import { formatMetadata } from '../libs/util';
9
+ import { Invoice, Price, Subscription, SubscriptionItem, UsageRecord } from '../store/models';
10
+
11
+ const router = Router();
12
+ const auth = authenticate<UsageRecord>({ component: true, roles: ['owner', 'admin'] });
13
+
14
+ // @link https://stripe.com/docs/api/usage_records/create
15
+ 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
+
21
+ const item = await SubscriptionItem.findByPk(raw.subscription_item_id);
22
+ if (!item) {
23
+ return res.status(400).json({ error: `SubscriptionItem not found: ${raw.subscription_item_id}` });
24
+ }
25
+
26
+ // @ts-ignore
27
+ if (!raw.timestamp || raw.timestamp === 'now') {
28
+ raw.timestamp = dayjs().unix();
29
+ }
30
+
31
+ const exist = await UsageRecord.findOne({ where: { timestamp: raw.timestamp } });
32
+ if (exist) {
33
+ if (req.body.action === 'increment') {
34
+ await exist.increment('quantity', { by: raw.quantity });
35
+ } else {
36
+ const subscription = await Subscription.findByPk(item.subscription_id);
37
+ if (subscription?.billing_thresholds) {
38
+ return res
39
+ .status(400)
40
+ .json({ error: 'UsageRecord action must be increment for subscriptions with billing_thresholds' });
41
+ }
42
+ await exist.update({ quantity: raw.quantity });
43
+ }
44
+ }
45
+
46
+ raw.livemode = req.livemode;
47
+ const doc = await UsageRecord.create(raw as UsageRecord);
48
+ return res.json(doc);
49
+ });
50
+
51
+ // @link https://stripe.com/docs/api/usage_records/subscription_item_summary_list
52
+ const schema = Joi.object<{
53
+ page: number;
54
+ size: number;
55
+ subscription_item_id: string;
56
+ livemode?: boolean;
57
+ }>({
58
+ page: Joi.number().integer().min(1).default(1),
59
+ size: Joi.number().integer().min(1).max(100).default(20),
60
+ subscription_item_id: Joi.string().required(),
61
+ livemode: Joi.boolean().empty(''),
62
+ });
63
+ router.get('/', auth, async (req, res) => {
64
+ const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
65
+
66
+ try {
67
+ const item = await SubscriptionItem.findByPk(query.subscription_item_id, {
68
+ include: [{ model: Price, as: 'price' }],
69
+ });
70
+ if (!item) {
71
+ return res.status(400).json({ error: `SubscriptionItem not found: ${query.subscription_item_id}` });
72
+ }
73
+
74
+ // const subscription = await Subscription.findByPk(item.subscription_id);
75
+ // const result = await UsageRecord.getSummary(
76
+ // item.id,
77
+ // subscription?.current_period_start as number,
78
+ // subscription?.current_period_end as number,
79
+ // // @ts-ignore
80
+ // item.price.recurring.aggregate_usage
81
+ // );
82
+ // return res.json({ result });
83
+
84
+ const { rows, count } = await Invoice.findAndCountAll({
85
+ where: { subscription_id: item.subscription_id },
86
+ attributes: ['id', 'period_end', 'period_start'],
87
+ order: [['created_at', 'DESC']],
88
+ offset: (page - 1) * size,
89
+ limit: size,
90
+ });
91
+
92
+ const list = await Promise.all(
93
+ rows.map(async (invoice) => {
94
+ return {
95
+ livemode: invoice.livemode,
96
+ invoice_id: invoice.id,
97
+ subscription_item_id: item.id,
98
+ total_usage: await UsageRecord.getSummary(
99
+ item.id,
100
+ invoice.period_start,
101
+ invoice.period_end,
102
+ // @ts-ignore
103
+ item.price.recurring.aggregate_usage
104
+ ),
105
+ period: {
106
+ start: invoice.period_start,
107
+ end: invoice.period_end,
108
+ },
109
+ };
110
+ })
111
+ );
112
+
113
+ res.json({ count, list });
114
+ } catch (err) {
115
+ console.error(err);
116
+ res.json({ count: 0, list: [] });
117
+ }
118
+ });
119
+
120
+ export default router;
@@ -0,0 +1,57 @@
1
+ import { Router } from 'express';
2
+ import Joi from 'joi';
3
+ import type { WhereOptions } from 'sequelize';
4
+
5
+ import { authenticate } from '../libs/security';
6
+ import { Event, WebhookAttempt, WebhookEndpoint } from '../store/models';
7
+
8
+ const router = Router();
9
+ const auth = authenticate<WebhookAttempt>({ component: true, roles: ['owner', 'admin'] });
10
+
11
+ const schema = Joi.object<{
12
+ page: number;
13
+ size: number;
14
+ livemode?: boolean;
15
+ event_id?: string;
16
+ webhook_endpoint_id?: string;
17
+ }>({
18
+ page: Joi.number().integer().min(1).default(1),
19
+ size: Joi.number().integer().min(1).max(100).default(20),
20
+ livemode: Joi.boolean().empty(''),
21
+ event_id: Joi.string().empty(''),
22
+ webhook_endpoint_id: Joi.string().empty(''),
23
+ });
24
+ router.get('/', auth, async (req, res) => {
25
+ const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
26
+ const where: WhereOptions<WebhookAttempt> = {};
27
+
28
+ if (typeof query.livemode === 'boolean') {
29
+ where.livemode = query.livemode;
30
+ }
31
+ if (query.event_id) {
32
+ where.event_id = query.event_id;
33
+ }
34
+ if (query.webhook_endpoint_id) {
35
+ where.webhook_endpoint_id = query.webhook_endpoint_id;
36
+ }
37
+
38
+ try {
39
+ const { rows: list, count } = await WebhookAttempt.findAndCountAll({
40
+ where,
41
+ order: [['created_at', 'DESC']],
42
+ offset: (page - 1) * size,
43
+ limit: size,
44
+ include: [
45
+ { model: Event, as: 'event' },
46
+ { model: WebhookEndpoint, as: 'endpoint' },
47
+ ],
48
+ });
49
+
50
+ res.json({ count, list });
51
+ } catch (err) {
52
+ console.error(err);
53
+ res.json({ count: 0, list: [] });
54
+ }
55
+ });
56
+
57
+ export default router;
@@ -0,0 +1,105 @@
1
+ import { Router } from 'express';
2
+ import Joi from 'joi';
3
+ import pick from 'lodash/pick';
4
+ import type { WhereOptions } from 'sequelize';
5
+
6
+ import { authenticate } from '../libs/security';
7
+ import { formatMetadata } from '../libs/util';
8
+ import { WebhookEndpoint } from '../store/models';
9
+
10
+ const router = Router();
11
+ const auth = authenticate<WebhookEndpoint>({ component: true, roles: ['owner', 'admin'] });
12
+
13
+ router.post('/', auth, async (req, res) => {
14
+ const raw: Partial<WebhookEndpoint> = pick(req.body, ['url', 'description', 'metadata', 'status', 'enabled_events']);
15
+ const exist = await WebhookEndpoint.findOne({ where: { url: raw.url } });
16
+ if (exist) {
17
+ return res.status(400).json({ error: 'webhook endpoint with same url already exist' });
18
+ }
19
+
20
+ raw.livemode = req.livemode;
21
+ raw.api_version = '2023-09-05';
22
+ raw.status = raw.status || 'enabled';
23
+ if (raw.metadata) {
24
+ raw.metadata = formatMetadata(raw.metadata);
25
+ }
26
+
27
+ const doc = await WebhookEndpoint.create(raw as WebhookEndpoint);
28
+ return res.json(doc);
29
+ });
30
+
31
+ const schema = Joi.object<{
32
+ page: number;
33
+ size: number;
34
+ livemode?: boolean;
35
+ }>({
36
+ page: Joi.number().integer().min(1).default(1),
37
+ size: Joi.number().integer().min(1).max(100).default(20),
38
+ livemode: Joi.boolean().empty(''),
39
+ });
40
+ router.get('/', auth, async (req, res) => {
41
+ const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
42
+ const where: WhereOptions<WebhookEndpoint> = {};
43
+
44
+ if (typeof query.livemode === 'boolean') {
45
+ where.livemode = query.livemode;
46
+ }
47
+
48
+ try {
49
+ const { rows: list, count } = await WebhookEndpoint.findAndCountAll({
50
+ where,
51
+ order: [['created_at', 'DESC']],
52
+ offset: (page - 1) * size,
53
+ limit: size,
54
+ include: [],
55
+ });
56
+
57
+ res.json({ count, list });
58
+ } catch (err) {
59
+ console.error(err);
60
+ res.json({ count: 0, list: [] });
61
+ }
62
+ });
63
+
64
+ router.get('/:id', auth, async (req, res) => {
65
+ try {
66
+ const doc = await WebhookEndpoint.findOne({
67
+ where: { id: req.params.id },
68
+ include: [],
69
+ });
70
+ res.json(doc);
71
+ } catch (err) {
72
+ console.error(err);
73
+ res.json(null);
74
+ }
75
+ });
76
+
77
+ router.put('/:id', auth, async (req, res) => {
78
+ const doc = await WebhookEndpoint.findByPk(req.params.id as string);
79
+
80
+ if (!doc) {
81
+ return res.status(404).json({ error: 'webhook endpoint not found' });
82
+ }
83
+
84
+ const updates: Partial<WebhookEndpoint> = pick(req.body, ['description', 'metadata', 'status', 'enabled_events']);
85
+ if (updates.metadata) {
86
+ updates.metadata = formatMetadata(updates.metadata);
87
+ }
88
+
89
+ await doc.update(updates);
90
+
91
+ return res.json(doc);
92
+ });
93
+
94
+ router.delete('/:id', auth, async (req, res) => {
95
+ const doc = await WebhookEndpoint.findByPk(req.params.id);
96
+
97
+ if (!doc) {
98
+ return res.status(404).json({ error: 'webhook endpoint not found' });
99
+ }
100
+
101
+ await doc.destroy();
102
+ return res.json(doc);
103
+ });
104
+
105
+ export default router;
@@ -0,0 +1,16 @@
1
+ import { SequelizeStorage, Umzug } from 'umzug';
2
+
3
+ import { sequelize } from './sequelize';
4
+
5
+ const umzug = new Umzug({
6
+ migrations: { glob: ['migrations/*.{ts,js}', { cwd: __dirname }] },
7
+ context: sequelize.getQueryInterface(),
8
+ storage: new SequelizeStorage({ sequelize }),
9
+ logger: console,
10
+ });
11
+
12
+ export default function migrate() {
13
+ return umzug.up();
14
+ }
15
+
16
+ export type Migration = typeof umzug._types.migration;
@@ -0,0 +1,52 @@
1
+ import type { Migration } from '../migrate';
2
+ import models from '../models';
3
+
4
+ export const up: Migration = async ({ context: queryInterface }) => {
5
+ await queryInterface.createTable('checkout_sessions', models.CheckoutSession.GENESIS_ATTRIBUTES);
6
+ await queryInterface.createTable('coupons', models.Coupon.GENESIS_ATTRIBUTES);
7
+ await queryInterface.createTable('customers', models.Customer.GENESIS_ATTRIBUTES);
8
+ await queryInterface.createTable('discounts', models.Discount.GENESIS_ATTRIBUTES);
9
+ await queryInterface.createTable('events', models.Event.GENESIS_ATTRIBUTES);
10
+ await queryInterface.createTable('jobs', models.Job.GENESIS_ATTRIBUTES);
11
+ await queryInterface.createTable('invoices', models.Invoice.GENESIS_ATTRIBUTES);
12
+ await queryInterface.createTable('invoice_items', models.InvoiceItem.GENESIS_ATTRIBUTES);
13
+ await queryInterface.createTable('payment_currencies', models.PaymentCurrency.GENESIS_ATTRIBUTES);
14
+ await queryInterface.createTable('payment_intents', models.PaymentIntent.GENESIS_ATTRIBUTES);
15
+ await queryInterface.createTable('payment_links', models.PaymentLink.GENESIS_ATTRIBUTES);
16
+ await queryInterface.createTable('payment_methods', models.PaymentMethod.GENESIS_ATTRIBUTES);
17
+ await queryInterface.createTable('prices', models.Price.GENESIS_ATTRIBUTES);
18
+ await queryInterface.createTable('products', models.Product.GENESIS_ATTRIBUTES);
19
+ await queryInterface.createTable('promotion_codes', models.PromotionCode.GENESIS_ATTRIBUTES);
20
+ await queryInterface.createTable('setup_intents', models.SetupIntent.GENESIS_ATTRIBUTES);
21
+ await queryInterface.createTable('subscription_items', models.SubscriptionItem.GENESIS_ATTRIBUTES);
22
+ await queryInterface.createTable('subscription_schedules', models.SubscriptionSchedule.GENESIS_ATTRIBUTES);
23
+ await queryInterface.createTable('subscriptions', models.Subscription.GENESIS_ATTRIBUTES);
24
+ await queryInterface.createTable('usage_records', models.UsageRecord.GENESIS_ATTRIBUTES);
25
+ await queryInterface.createTable('webhook_attempts', models.WebhookAttempt.GENESIS_ATTRIBUTES);
26
+ await queryInterface.createTable('webhook_endpoints', models.WebhookEndpoint.GENESIS_ATTRIBUTES);
27
+ };
28
+
29
+ export const down: Migration = async ({ context: queryInterface }) => {
30
+ await queryInterface.dropTable('checkout_sessions');
31
+ await queryInterface.dropTable('coupons');
32
+ await queryInterface.dropTable('customers');
33
+ await queryInterface.dropTable('discounts');
34
+ await queryInterface.dropTable('events');
35
+ await queryInterface.dropTable('jobs');
36
+ await queryInterface.dropTable('invoices');
37
+ await queryInterface.dropTable('invoice_items');
38
+ await queryInterface.dropTable('payment_currencies');
39
+ await queryInterface.dropTable('payment_intents');
40
+ await queryInterface.dropTable('payment_links');
41
+ await queryInterface.dropTable('payment_methods');
42
+ await queryInterface.dropTable('prices');
43
+ await queryInterface.dropTable('products');
44
+ await queryInterface.dropTable('promotion_codes');
45
+ await queryInterface.dropTable('setup_intents');
46
+ await queryInterface.dropTable('subscription_items');
47
+ await queryInterface.dropTable('subscription_schedules');
48
+ await queryInterface.dropTable('subscriptions');
49
+ await queryInterface.dropTable('usage_records');
50
+ await queryInterface.dropTable('webhook_endpoints');
51
+ await queryInterface.dropTable('webhook_attempts');
52
+ };