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,301 @@
1
+ import { BN } from '@ocap/util';
2
+ import type { LiteralUnion } from 'type-fest';
3
+
4
+ import dayjs from '../libs/dayjs';
5
+ import logger from '../libs/logger';
6
+ import createQueue from '../libs/queue';
7
+ import { getStatementDescriptor, getSubscriptionCycleAmount, getSubscriptionCycleSetup } from '../libs/session';
8
+ import { UsageRecord } from '../store/models';
9
+ import { Customer } from '../store/models/customer';
10
+ import { Invoice } from '../store/models/invoice';
11
+ import { InvoiceItem } from '../store/models/invoice-item';
12
+ import { Price } from '../store/models/price';
13
+ import { Subscription } from '../store/models/subscription';
14
+ import { SubscriptionItem } from '../store/models/subscription-item';
15
+ import { invoiceQueue } from './invoice';
16
+
17
+ type SubscriptionJob = {
18
+ subscriptionId: string;
19
+ action?: LiteralUnion<'cycle' | 'cancel' | 'pause' | 'resume', string>;
20
+ };
21
+
22
+ // generate invoice for subscription periodically
23
+ export const handleSubscription = async (job: SubscriptionJob) => {
24
+ logger.info('handleSubscription', job);
25
+
26
+ const subscription = await Subscription.findByPk(job.subscriptionId);
27
+ if (!subscription) {
28
+ logger.warn(`Subscription not found: ${job.subscriptionId}`);
29
+ return;
30
+ }
31
+ if (['trialing', 'active', 'paused'].includes(subscription.status) === false) {
32
+ logger.warn(`Subscription status not expected: ${job.subscriptionId}`);
33
+ return;
34
+ }
35
+
36
+ const now = dayjs().unix();
37
+
38
+ // Do we need to cancel the subscription
39
+ if (subscription.status === 'active') {
40
+ if (subscription.cancel_at_period_end) {
41
+ await subscription.update({ status: 'canceled', canceled_at: now });
42
+ logger.warn(`Subscription canceled on period end: ${job.subscriptionId}`);
43
+ return;
44
+ }
45
+ if (subscription.cancel_at && subscription.cancel_at <= now) {
46
+ await subscription.update({ status: 'canceled', canceled_at: now });
47
+ logger.warn(`Subscription canceled on schedule: ${job.subscriptionId}`);
48
+ return;
49
+ }
50
+ }
51
+
52
+ // Do we need to resume the subscription
53
+ if (
54
+ subscription.status === 'paused' &&
55
+ subscription.pause_collection?.resumes_at &&
56
+ subscription.pause_collection?.resumes_at <= now
57
+ ) {
58
+ await subscription.update({ status: 'active', pause_collection: undefined });
59
+ logger.warn(`Subscription resumed as scheduled: ${job.subscriptionId}`);
60
+ }
61
+
62
+ // can we create new invoice for this subscription?
63
+ if (subscription.status === 'trialing' && subscription.trail_end && subscription.trail_end > now) {
64
+ logger.warn(`Subscription trialing period not ended: ${job.subscriptionId}`);
65
+ subscriptionQueue.push({
66
+ id: subscription.id,
67
+ job: { subscriptionId: subscription.id, action: 'cycle' },
68
+ runAt: subscription.trail_end,
69
+ });
70
+ return;
71
+ }
72
+ if (subscription.status === 'active' && subscription.current_period_end > now) {
73
+ logger.warn(`Subscription current period not ended: ${job.subscriptionId}`);
74
+ subscriptionQueue.push({
75
+ id: subscription.id,
76
+ job: { subscriptionId: subscription.id, action: 'cycle' },
77
+ runAt: subscription.current_period_end,
78
+ });
79
+ return;
80
+ }
81
+
82
+ // Do we still have the customer
83
+ const customer = await Customer.findByPk(subscription.customer_id);
84
+ if (!customer) {
85
+ logger.warn(`Customer ${subscription.customer_id} not found for subscription: ${subscription.id}`);
86
+ return;
87
+ }
88
+
89
+ // get setup for next subscription period
90
+ const previousPeriodEnd =
91
+ subscription.status === 'trialing' ? subscription.trail_end : subscription.current_period_end;
92
+ const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
93
+
94
+ // check if invoice already created
95
+ const exist = await Invoice.findOne({
96
+ where: {
97
+ subscription_id: subscription.id,
98
+ period_start: setup.period.start,
99
+ period_end: setup.period.end,
100
+ },
101
+ });
102
+ if (exist) {
103
+ logger.warn(`Invoice already created for subscription ${subscription.id} for next billing cycle: ${exist.id}`);
104
+ return;
105
+ }
106
+
107
+ // set invoice status if subscription paused
108
+ let status = 'open';
109
+ if (subscription.status === 'paused') {
110
+ if (subscription.pause_collection?.behavior === 'mark_uncollectible') {
111
+ status = 'uncollectible';
112
+ }
113
+ if (subscription.pause_collection?.behavior === 'void') {
114
+ status = 'void';
115
+ }
116
+ if (subscription.pause_collection?.behavior === 'keep_as_draft') {
117
+ status = 'draft';
118
+ }
119
+ }
120
+
121
+ // expand subscription items
122
+ const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
123
+ let expandedItems = await Price.expand(
124
+ subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
125
+ true
126
+ );
127
+
128
+ // get usage summaries for this billing cycle
129
+ expandedItems = await Promise.all(
130
+ expandedItems.map(async (x: any) => {
131
+ // For metered billing, we need to get usage summary for this billing cycle
132
+ // @link https://stripe.com/docs/products-prices/pricing-models#usage-types
133
+ if (x.price.recurring?.usage_type === 'metered') {
134
+ const duration = setup.cycle / 1000;
135
+ x.quantity = await UsageRecord.getSummary(
136
+ x.id,
137
+ // FIXME: this causes inconsistency when subscription is paused or billing_cycle_anchor reset
138
+ setup.period.start - duration,
139
+ setup.period.end - duration,
140
+ x.price.recurring?.aggregate_usage
141
+ );
142
+ logger.info('Invoice.usageRecordSummary', {
143
+ subscriptionId: subscription.id,
144
+ subscriptionItemId: x.id,
145
+ priceId: x.price_id,
146
+ quantity: x.quantity,
147
+ start: setup.period.start - duration,
148
+ end: setup.period.end - duration,
149
+ usage: x.price.recurring?.aggregate_usage,
150
+ });
151
+ }
152
+
153
+ return x;
154
+ })
155
+ );
156
+
157
+ const amount = getSubscriptionCycleAmount(expandedItems);
158
+
159
+ // create invoice
160
+ const invoice = await Invoice.create({
161
+ livemode: subscription.livemode,
162
+ number: await customer.getInvoiceNumber(),
163
+ description: 'Subscription cycle',
164
+ statement_descriptor: getStatementDescriptor(expandedItems),
165
+ period_start: subscription.current_period_start,
166
+ period_end: subscription.current_period_end,
167
+
168
+ auto_advance: true,
169
+ paid: false,
170
+ paid_out_of_band: false,
171
+
172
+ status,
173
+ collection_method: 'charge_automatically',
174
+ billing_reason: 'subscription_cycle',
175
+
176
+ currency_id: subscription.currency_id,
177
+ customer_id: customer.id,
178
+ payment_intent_id: '',
179
+ subscription_id: subscription?.id,
180
+ checkout_session_id: '',
181
+
182
+ subtotal: amount.total,
183
+ subtotal_excluding_tax: amount.total,
184
+ tax: '0',
185
+ total: amount.total,
186
+ amount_due: amount.total,
187
+ amount_paid: '0',
188
+ amount_remaining: amount.total,
189
+ amount_shipping: '0',
190
+
191
+ starting_balance: '0',
192
+ ending_balance: '0',
193
+
194
+ attempt_count: 0,
195
+ attempted: false,
196
+ // next_payment_attempt: undefined,
197
+
198
+ custom_fields: [],
199
+ customer_address: customer.address,
200
+ customer_email: customer.email,
201
+ customer_name: customer.name,
202
+ customer_phone: customer.phone,
203
+
204
+ discounts: [],
205
+ total_discount_amounts: [],
206
+
207
+ due_date: undefined, // The date on which payment for this invoice is due
208
+ effective_at: dayjs().unix(), // The date when this invoice is in effect
209
+ status_transitions: {
210
+ finalized_at: dayjs().unix(),
211
+ },
212
+
213
+ payment_settings: subscription.payment_settings,
214
+ default_payment_method_id: subscription.default_payment_method_id as string,
215
+
216
+ account_country: '',
217
+ account_name: '',
218
+ metadata: {},
219
+ });
220
+ logger.info(`Invoice created for subscription ${subscription.id}: ${invoice.id}`);
221
+
222
+ // create invoice items
223
+ await Promise.all(
224
+ expandedItems.map((x: any) =>
225
+ InvoiceItem.create({
226
+ livemode: subscription.livemode,
227
+ amount: new BN(x.price.unit_amount).mul(new BN(x.quantity)).toString(),
228
+ quantity: x.quantity,
229
+ description: x.price.product.name,
230
+ period: { start: setup.period.start, end: setup.period.end },
231
+ currency_id: subscription.currency_id,
232
+ customer_id: customer.id,
233
+ price_id: x.price_id,
234
+ invoice_id: invoice.id,
235
+ subscription_id: subscription.id,
236
+ subscription_item_id: subscriptionItems.find((si) => si.price_id === x.price_id)?.id,
237
+ discountable: false,
238
+ discounts: [],
239
+ discount_amounts: [],
240
+ proration: false,
241
+ proration_details: {},
242
+ metadata: {},
243
+ })
244
+ )
245
+ );
246
+
247
+ // schedule invoice job
248
+ invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
249
+ logger.info(`Invoice job scheduled for new billing cycle: ${invoice.id}`);
250
+
251
+ // persist invoice id
252
+ await subscription.update({
253
+ latest_invoice_id: invoice.id,
254
+ current_period_start: setup.period.start,
255
+ current_period_end: setup.period.end,
256
+ });
257
+ logger.info(`Subscription updated for new billing cycle: ${subscription.id}`);
258
+
259
+ // schedule next billing cycle if we are not cancelling
260
+ if (!subscription.cancel_at_period_end) {
261
+ subscriptionQueue.push({
262
+ id: subscription.id,
263
+ job: { subscriptionId: subscription.id, action: 'cycle' },
264
+ runAt: setup.period.end,
265
+ });
266
+ logger.info(`Subscription job scheduled for next billing cycle: ${subscription.id}`);
267
+ }
268
+ };
269
+
270
+ export const subscriptionQueue = createQueue<SubscriptionJob>({
271
+ name: 'subscription',
272
+ onJob: handleSubscription,
273
+ options: {
274
+ concurrency: 1,
275
+ maxRetries: 5,
276
+ enableScheduledJob: true,
277
+ },
278
+ });
279
+
280
+ export const startSubscriptionQueue = async () => {
281
+ const subscriptions = await Subscription.findAll({
282
+ where: {
283
+ status: ['trialing', 'active', 'paused'],
284
+ },
285
+ });
286
+
287
+ subscriptions.forEach(async (x) => {
288
+ const exist = await subscriptionQueue.get(x.id);
289
+ if (!exist) {
290
+ subscriptionQueue.push({
291
+ id: x.id,
292
+ job: { subscriptionId: x.id, action: 'cycle' },
293
+ runAt: x.current_period_end,
294
+ });
295
+ }
296
+ });
297
+ };
298
+
299
+ subscriptionQueue.on('failed', ({ id, job, error }) => {
300
+ logger.error('Subscription job failed', { id, job, error });
301
+ });
@@ -0,0 +1,113 @@
1
+ import { sign } from '@blocklet/sdk/lib/util/verify-sign';
2
+ import axios, { AxiosError } from 'axios';
3
+
4
+ import { wallet } from '../libs/auth';
5
+ import logger from '../libs/logger';
6
+ import createQueue from '../libs/queue';
7
+ import { MAX_RETRY_COUNT, getNextRetry, md5 } from '../libs/util';
8
+ import { Event } from '../store/models/event';
9
+ import { WebhookAttempt } from '../store/models/webhook-attempt';
10
+ import { WebhookEndpoint } from '../store/models/webhook-endpoint';
11
+
12
+ type WebhookJob = {
13
+ eventId: string;
14
+ webhookId: string;
15
+ };
16
+
17
+ export const getJobId = (eventId: string, webhookId: string) => {
18
+ return md5([eventId, webhookId].join('-'));
19
+ };
20
+
21
+ // https://stripe.com/docs/webhooks
22
+ export const handleWebhook = async (job: WebhookJob) => {
23
+ logger.info('handleWebhook', job);
24
+
25
+ const event = await Event.findByPk(job.eventId);
26
+ if (!event) {
27
+ logger.warn(`Event not found when attempt webhook: ${job.eventId}`);
28
+ return;
29
+ }
30
+
31
+ const webhook = await WebhookEndpoint.findByPk(job.webhookId);
32
+ if (!webhook) {
33
+ logger.warn(`Webhook not found on attempt: ${job.webhookId}`);
34
+ return;
35
+ }
36
+ if (webhook.status !== 'enabled') {
37
+ logger.warn(`Webhook disabled on attempt: ${job.webhookId}`);
38
+ return;
39
+ }
40
+
41
+ const lastAttempt = await WebhookAttempt.findOne({
42
+ where: { event_id: event.id, webhook_endpoint_id: webhook.id },
43
+ order: [['retry_count', 'DESC']],
44
+ attributes: ['retry_count'],
45
+ });
46
+
47
+ const retryCount = lastAttempt ? lastAttempt.retry_count + 1 : 0;
48
+
49
+ try {
50
+ // verify similar to component call, but supports external urls
51
+ const result = await axios({
52
+ url: webhook.url,
53
+ method: 'POST',
54
+ timeout: 60 * 1000,
55
+ data: event.toJSON(),
56
+ headers: {
57
+ 'x-app-id': wallet.address,
58
+ 'x-app-pk': wallet.publicKey,
59
+ 'x-component-sig': sign(event.toJSON()),
60
+ 'x-component-did': process.env.BLOCKLET_COMPONENT_DID as string,
61
+ },
62
+ });
63
+
64
+ await WebhookAttempt.create({
65
+ livemode: event.livemode,
66
+ event_id: event.id,
67
+ webhook_endpoint_id: webhook.id,
68
+ status: 'succeeded',
69
+ response_status: result.status,
70
+ response_body: result.data || {},
71
+ retry_count: retryCount,
72
+ });
73
+
74
+ await event.decrement('pending_webhooks');
75
+ logger.info(`Webhook attempt success: ${job.webhookId}`);
76
+ } catch (err: any) {
77
+ logger.error(`Webhook attempt error: ${job.webhookId}`, { message: err.message });
78
+ await WebhookAttempt.create({
79
+ livemode: event.livemode,
80
+ event_id: event.id,
81
+ webhook_endpoint_id: webhook.id,
82
+ status: 'failed',
83
+ response_status: (err as AxiosError).response?.status || 500,
84
+ response_body: (err as AxiosError).response?.data || {},
85
+ retry_count: retryCount,
86
+ });
87
+
88
+ // reschedule next attempt
89
+ if (retryCount < MAX_RETRY_COUNT) {
90
+ process.nextTick(() => {
91
+ webhookQueue.push({
92
+ id: getJobId(event.id, webhook.id),
93
+ job: { eventId: event.id, webhookId: webhook.id },
94
+ runAt: getNextRetry(retryCount + 1),
95
+ });
96
+ });
97
+ }
98
+ }
99
+ };
100
+
101
+ export const webhookQueue = createQueue<WebhookJob>({
102
+ name: 'webhook',
103
+ onJob: handleWebhook,
104
+ options: {
105
+ concurrency: 2,
106
+ maxRetries: 3,
107
+ enableScheduledJob: true,
108
+ },
109
+ });
110
+
111
+ webhookQueue.on('failed', ({ id, job, error }) => {
112
+ logger.error('webhook job failed', { id, job, error });
113
+ });
@@ -0,0 +1,73 @@
1
+ import pick from 'lodash/pick';
2
+
3
+ import { eventQueue } from '../jobs/event';
4
+ import { Event } from '../store/models/event';
5
+
6
+ export async function createEvent(scope: string, type: string, model: any, options: any) {
7
+ // console.log('createEvent', scope, type, model, options);
8
+ const data: any = {
9
+ object: model.dataValues,
10
+ };
11
+ if (type.endsWith('updated')) {
12
+ data.previous_attributes = pick(model._previousDataValues, options.fields);
13
+ }
14
+
15
+ const event = await Event.create({
16
+ type,
17
+ api_version: 'v1',
18
+ livemode: !!model.livemode,
19
+ object_id: model.id,
20
+ object_type: scope,
21
+ data,
22
+ request: {
23
+ // FIXME:
24
+ id: '',
25
+ idempotency_key: '',
26
+ },
27
+ metadata: {},
28
+ pending_webhooks: 99, // force all events goto the event queue
29
+ });
30
+
31
+ eventQueue.push({ id: event.id, job: { eventId: event.id } });
32
+ }
33
+
34
+ export async function createStatusEvent(
35
+ scope: string,
36
+ prefix: string,
37
+ config: Record<string, string>,
38
+ model: any,
39
+ options: any
40
+ ) {
41
+ // console.log('createStatusEvent', scope, prefix, config, model, options);
42
+ if (options.fields.includes('status') === false) {
43
+ return;
44
+ }
45
+
46
+ const data: any = {
47
+ object: model.dataValues,
48
+ previous_attributes: pick(model._previousDataValues, options.fields),
49
+ };
50
+
51
+ if (!config[data.object.status]) {
52
+ return;
53
+ }
54
+
55
+ const suffix = config[data.object.status];
56
+ const event = await Event.create({
57
+ type: [prefix, suffix].join('.'),
58
+ api_version: 'v1',
59
+ livemode: !!model.livemode,
60
+ object_id: model.id,
61
+ object_type: scope,
62
+ data,
63
+ request: {
64
+ // FIXME:
65
+ id: '',
66
+ idempotency_key: '',
67
+ },
68
+ metadata: {},
69
+ pending_webhooks: 99, // force all events goto the event queue
70
+ });
71
+
72
+ eventQueue.push({ id: event.id, job: { eventId: event.id } });
73
+ }
@@ -0,0 +1,40 @@
1
+ import path from 'path';
2
+
3
+ import AuthStorage from '@arcblock/did-auth-storage-nedb';
4
+ import AuthService from '@blocklet/sdk/lib/service/auth';
5
+ import getWallet from '@blocklet/sdk/lib/wallet';
6
+ import WalletAuthenticator from '@blocklet/sdk/lib/wallet-authenticator';
7
+ import WalletHandler from '@blocklet/sdk/lib/wallet-handler';
8
+ import type { Request } from 'express';
9
+ import type { LiteralUnion } from 'type-fest';
10
+
11
+ import env from './env';
12
+
13
+ export const wallet = getWallet();
14
+ export const authenticator = new WalletAuthenticator();
15
+ export const handlers = new WalletHandler({
16
+ authenticator,
17
+ tokenStorage: new AuthStorage({
18
+ dbPath: path.join(env.dataDir, 'auth.db'),
19
+ }),
20
+ });
21
+
22
+ export const blocklet = new AuthService();
23
+
24
+ export type CallbackArgs = {
25
+ request: Request;
26
+ userDid: string;
27
+ userPk: string;
28
+ didwallet: {
29
+ os: LiteralUnion<'ios' | 'android' | 'web', string>;
30
+ version: string;
31
+ jwt: string;
32
+ };
33
+ extraParams: Record<string, any>;
34
+ updateSession: Function;
35
+ claims: any[];
36
+ step?: number;
37
+ challenge?: string;
38
+ pathname?: string;
39
+ baseUrl?: string;
40
+ };
@@ -0,0 +1,13 @@
1
+ import Client from '@ocap/client';
2
+
3
+ const cache = new Map<string, Client>();
4
+ export function getClient(host: string) {
5
+ const cached = cache.has(host);
6
+ if (!cached) {
7
+ const created = new Client(host);
8
+ cache.set(host, created);
9
+ return created;
10
+ }
11
+
12
+ return cache.get(host) as Client;
13
+ }
@@ -0,0 +1,17 @@
1
+ import dayjs from 'dayjs';
2
+ import duration from 'dayjs/plugin/duration';
3
+ import localizedFormat from 'dayjs/plugin/localizedFormat';
4
+ import relativeTime from 'dayjs/plugin/relativeTime';
5
+ import timezone from 'dayjs/plugin/timezone'; // dependent on utc plugin
6
+ import utc from 'dayjs/plugin/utc';
7
+
8
+ import('dayjs/locale/en');
9
+ import('dayjs/locale/zh');
10
+
11
+ dayjs.extend(relativeTime);
12
+ dayjs.extend(localizedFormat);
13
+ dayjs.extend(duration);
14
+ dayjs.extend(utc);
15
+ dayjs.extend(timezone);
16
+
17
+ export default dayjs;
@@ -0,0 +1,5 @@
1
+ import env from '@blocklet/sdk/lib/env';
2
+
3
+ export default {
4
+ ...env,
5
+ };
@@ -0,0 +1,42 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { chmodSync, existsSync, mkdirSync, symlinkSync } from 'fs';
3
+ import { dirname, join } from 'path';
4
+
5
+ import logger from './logger';
6
+
7
+ const { name } = require('../../../package.json');
8
+
9
+ // eslint-disable-next-line import/prefer-default-export
10
+ export async function ensureSqliteBinaryFile() {
11
+ logger.info(`${name} ensure sqlite3 installed`);
12
+
13
+ try {
14
+ await import('sqlite3');
15
+ logger.info(`${name} sqlite3 already installed`);
16
+ return;
17
+ } catch {
18
+ /* empty */
19
+ }
20
+ logger.info(`${name} try install sqlite3`);
21
+
22
+ const appDir = process.env.BLOCKLET_APP_DIR!;
23
+
24
+ // link `node-pre-gyp` to .bin for download or build sqlite3
25
+ try {
26
+ const srcPath = join(appDir, 'node_modules/@mapbox/node-pre-gyp/bin/node-pre-gyp');
27
+ const binPath = join(appDir, 'node_modules/.bin/node-pre-gyp');
28
+ if (!existsSync(binPath) && existsSync(srcPath)) {
29
+ mkdirSync(dirname(binPath), { recursive: true });
30
+ symlinkSync(srcPath, binPath);
31
+ chmodSync(binPath, '755');
32
+ }
33
+ } catch (error) {
34
+ logger.warn(error.message);
35
+ }
36
+
37
+ spawnSync('npm', ['run', 'install'], {
38
+ cwd: join(appDir, 'node_modules/sqlite3'),
39
+ stdio: 'inherit',
40
+ shell: true,
41
+ });
42
+ }
@@ -0,0 +1,27 @@
1
+ /* eslint-disable no-console */
2
+ const createLogger = require('@blocklet/logger');
3
+
4
+ interface Logger {
5
+ debug: (...args: any[]) => void;
6
+ info: (...args: any[]) => void;
7
+ error: (...args: any[]) => void;
8
+ warn: (...args: any[]) => void;
9
+ }
10
+
11
+ const consoleLogger: Logger = {
12
+ debug: console.log,
13
+ info: console.log,
14
+ error: console.log,
15
+ warn: console.warn,
16
+ };
17
+
18
+ const init = (label: string): Logger => {
19
+ const instance = createLogger(label || '');
20
+ return instance;
21
+ };
22
+
23
+ const logger = process.env.NODE_ENV === 'production' ? consoleLogger : init('app');
24
+
25
+ export default logger;
26
+
27
+ export const accessLogStream = createLogger.getAccessLogStream();
@@ -0,0 +1,12 @@
1
+ /* eslint-disable import/prefer-default-export */
2
+ import type { NextFunction, Request, Response } from 'express';
3
+
4
+ import { translate } from '../locales';
5
+
6
+ export function ensureI18n() {
7
+ return (req: Request, _: Response, next: NextFunction) => {
8
+ req.locale = String(req.query.locale || 'en');
9
+ req.t = translate;
10
+ next();
11
+ };
12
+ }