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
package/.eslintrc.js ADDED
@@ -0,0 +1,15 @@
1
+ const { join } = require('path');
2
+
3
+ module.exports = {
4
+ root: true,
5
+ extends: '@arcblock/eslint-config-ts',
6
+ parserOptions: {
7
+ project: [join(__dirname, 'tsconfig.eslint.json'), join(__dirname, 'tsconfig.json')],
8
+ },
9
+ rules: {
10
+ '@typescript-eslint/comma-dangle': 'off',
11
+ '@typescript-eslint/no-use-before-define': 'off',
12
+ '@typescript-eslint/lines-between-class-members': 'off',
13
+ 'import/prefer-default-export': 'off',
14
+ },
15
+ };
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Payment Kit
2
+
3
+ The decentralized stripe for blocklet platform.
package/api/dev.ts ADDED
@@ -0,0 +1,6 @@
1
+ // eslint-disable-next-line import/no-extraneous-dependencies
2
+ import { setupClient } from 'vite-plugin-blocklet';
3
+
4
+ import { app } from './src';
5
+
6
+ setupClient(app);
@@ -0,0 +1,12 @@
1
+ /* eslint-disable global-require */
2
+
3
+ const isDevelopment = process.env.BLOCKLET_MODE === 'development';
4
+
5
+ if (isDevelopment) {
6
+ // rename `require` to skip deps resolve when bundling
7
+ const r = require;
8
+ r('ts-node').register();
9
+ r('../src/hooks/pre-start');
10
+ } else {
11
+ require('../dist/hooks/pre-start');
12
+ }
@@ -0,0 +1,21 @@
1
+ import '@blocklet/sdk/lib/error-handler';
2
+
3
+ import dotenv from 'dotenv-flow';
4
+
5
+ import { ensureSqliteBinaryFile } from '../libs/hooks';
6
+ import logger from '../libs/logger';
7
+
8
+ dotenv.config();
9
+
10
+ const { name } = require('../../../package.json');
11
+
12
+ (async () => {
13
+ try {
14
+ await ensureSqliteBinaryFile();
15
+ await import('../store/migrate').then((m) => m.default());
16
+ process.exit(0);
17
+ } catch (err) {
18
+ logger.error(`${name} pre-start error`, err.message);
19
+ process.exit(1);
20
+ }
21
+ })();
@@ -0,0 +1,92 @@
1
+ import 'express-async-errors';
2
+
3
+ import path from 'path';
4
+
5
+ import fallback from '@blocklet/sdk/lib/middlewares/fallback';
6
+ import cookieParser from 'cookie-parser';
7
+ import cors from 'cors';
8
+ import dotenv from 'dotenv-flow';
9
+ import express, { ErrorRequestHandler, Request, Response } from 'express';
10
+ import morgan from 'morgan';
11
+
12
+ import { startEventQueue } from './jobs/event';
13
+ import { startInvoiceQueue } from './jobs/invoice';
14
+ import { startPaymentQueue } from './jobs/payment';
15
+ import { startSubscriptionQueue } from './jobs/subscription';
16
+ import { handlers } from './libs/auth';
17
+ import logger, { accessLogStream } from './libs/logger';
18
+ import { ensureI18n } from './libs/middleware';
19
+ import routes from './routes';
20
+ import collectHandlers from './routes/connect/collect';
21
+ import payHandlers from './routes/connect/pay';
22
+ import setupHandlers from './routes/connect/setup';
23
+ import subscribeHandlers from './routes/connect/subscribe';
24
+ import { initialize } from './store/models';
25
+ import { sequelize } from './store/sequelize';
26
+
27
+ dotenv.config();
28
+
29
+ const { name, version } = require('../../package.json');
30
+
31
+ initialize(sequelize);
32
+
33
+ export const app = express();
34
+
35
+ app.set('trust proxy', true);
36
+ app.use(cookieParser());
37
+ app.use(express.json({ limit: '1 mb' }));
38
+ app.use(express.urlencoded({ extended: true, limit: '1 mb' }));
39
+ app.use(cors());
40
+ app.use(ensureI18n());
41
+
42
+ const router = express.Router();
43
+ handlers.attach(Object.assign({ app: router }, collectHandlers));
44
+ handlers.attach(Object.assign({ app: router }, payHandlers));
45
+ handlers.attach(Object.assign({ app: router }, setupHandlers));
46
+ handlers.attach(Object.assign({ app: router }, subscribeHandlers));
47
+
48
+ router.use('/api', routes);
49
+ app.use(router);
50
+
51
+ const isProduction = process.env.NODE_ENV === 'production' || process.env.ABT_NODE_SERVICE_ENV === 'production';
52
+
53
+ const accessFormat =
54
+ isProduction
55
+ ? 'combined'
56
+ : (tokens: any, req: Request, res: Response) => [tokens.method(req, res), tokens.url(req, res), tokens.status(req, res), tokens.res(req, res, 'content-length'), '-', tokens['response-time'](req, res), 'ms'].join(' '); // prettier-ignore
57
+
58
+ if (isProduction) {
59
+ app.use(morgan(accessFormat, { stream: accessLogStream }));
60
+ } else {
61
+ app.use((req, res, next) => {
62
+ if (['/node_modules/.vite', '/src', '/@'].some((p) => req.originalUrl.startsWith(p))) {
63
+ return next();
64
+ }
65
+
66
+ return morgan(accessFormat, { stream: accessLogStream })(req, res, next);
67
+ });
68
+ }
69
+
70
+ if (isProduction) {
71
+ const staticDir = path.resolve(process.env.BLOCKLET_APP_DIR!, 'dist');
72
+ app.use(express.static(staticDir, { maxAge: '30d', index: false }));
73
+ app.use(fallback('index.html', { root: staticDir }));
74
+
75
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
76
+ app.use(<ErrorRequestHandler>((err, _req, res, _next) => {
77
+ logger.error(err.stack);
78
+ res.status(500).send('Something broke!');
79
+ }));
80
+ }
81
+
82
+ const port = parseInt(process.env.BLOCKLET_PORT!, 10);
83
+
84
+ export const server = app.listen(port, (err?: any) => {
85
+ if (err) throw err;
86
+ logger.info(`> ${name} v${version} ready on ${port}`);
87
+
88
+ startPaymentQueue().then(() => logger.info('payment queue started'));
89
+ startInvoiceQueue().then(() => logger.info('invoice queue started'));
90
+ startSubscriptionQueue().then(() => logger.info('subscription queue started'));
91
+ startEventQueue().then(() => logger.info('event queue started'));
92
+ });
@@ -0,0 +1,72 @@
1
+ import { Op } from 'sequelize';
2
+
3
+ import logger from '../libs/logger';
4
+ import createQueue from '../libs/queue';
5
+ import { Event } from '../store/models/event';
6
+ import { WebhookEndpoint } from '../store/models/webhook-endpoint';
7
+ import { getJobId, webhookQueue } from './webhook';
8
+
9
+ type EventJob = {
10
+ eventId: string;
11
+ };
12
+
13
+ export const handleEvent = async (job: EventJob) => {
14
+ logger.info('handleEvent', job);
15
+
16
+ const event = await Event.findByPk(job.eventId);
17
+ if (!event) {
18
+ logger.warn(`Event not found: ${job.eventId}`);
19
+ return;
20
+ }
21
+
22
+ if (!event.pending_webhooks) {
23
+ logger.warn(`Event already processed: ${job.eventId}`);
24
+ return;
25
+ }
26
+
27
+ const webhooks = await WebhookEndpoint.findAll({ where: { status: 'enabled', livemode: event.livemode } });
28
+ const eventWebhooks = webhooks.filter((webhook) => webhook.enabled_events.includes(event.type));
29
+ if (eventWebhooks.length === 0) {
30
+ logger.info(`No webhook endpoint for event: ${job.eventId}`);
31
+ await event.update({ pending_webhooks: 0 });
32
+ return;
33
+ }
34
+
35
+ await event.update({ pending_webhooks: eventWebhooks.length });
36
+ eventWebhooks.forEach((webhook) => {
37
+ logger.info(`Schedule webhook ${webhook.id} attempt for event: ${job.eventId}`);
38
+ webhookQueue.push({
39
+ id: getJobId(event.id, webhook.id),
40
+ job: { eventId: event.id, webhookId: webhook.id },
41
+ });
42
+ });
43
+ };
44
+
45
+ export const eventQueue = createQueue<EventJob>({
46
+ name: 'event',
47
+ onJob: handleEvent,
48
+ options: {
49
+ concurrency: 1,
50
+ maxRetries: 3,
51
+ },
52
+ });
53
+
54
+ export const startEventQueue = async () => {
55
+ const events = await Event.findAll({
56
+ where: {
57
+ pending_webhooks: { [Op.gt]: 0 },
58
+ },
59
+ attributes: ['id'],
60
+ });
61
+
62
+ events.forEach(async (x) => {
63
+ const exist = await eventQueue.get(x.id);
64
+ if (!exist) {
65
+ eventQueue.push({ id: x.id, job: { eventId: x.id } });
66
+ }
67
+ });
68
+ };
69
+
70
+ eventQueue.on('failed', ({ id, job, error }) => {
71
+ logger.error('Event job failed', { id, job, error });
72
+ });
@@ -0,0 +1,148 @@
1
+ import { Op } from 'sequelize';
2
+
3
+ import dayjs from '../libs/dayjs';
4
+ import logger from '../libs/logger';
5
+ import createQueue from '../libs/queue';
6
+ import { CheckoutSession } from '../store/models/checkout-session';
7
+ import { Invoice } from '../store/models/invoice';
8
+ import { PaymentIntent } from '../store/models/payment-intent';
9
+ import { Subscription } from '../store/models/subscription';
10
+ import { paymentQueue } from './payment';
11
+
12
+ type InvoiceJob = {
13
+ invoiceId: string;
14
+ retryOnError?: boolean;
15
+ };
16
+
17
+ // handle invoice payment
18
+ // TODO: send invoice to user with email
19
+ export const handleInvoice = async (job: InvoiceJob) => {
20
+ logger.info('handleInvoice', job);
21
+
22
+ const invoice = await Invoice.findByPk(job.invoiceId);
23
+ if (!invoice) {
24
+ logger.warn(`Invoice not found: ${job.invoiceId}`);
25
+ return;
26
+ }
27
+ if (invoice.status !== 'open') {
28
+ logger.warn(`Invoice not open: ${job.invoiceId}`);
29
+ return;
30
+ }
31
+ if (invoice.auto_advance === false) {
32
+ logger.warn(`Invoice not configured to auto advance: ${job.invoiceId}`);
33
+ return;
34
+ }
35
+
36
+ // no payment required
37
+ if (invoice.total === '0') {
38
+ logger.warn(`Invoice does not require payment: ${job.invoiceId}`);
39
+
40
+ await invoice.update({
41
+ paid: true,
42
+ status: 'paid',
43
+ amount_due: '0',
44
+ amount_paid: '0',
45
+ amount_remaining: '0',
46
+ attempt_count: invoice.attempt_count + 1,
47
+ attempted: true,
48
+ status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
49
+ });
50
+
51
+ if (invoice.subscription_id) {
52
+ const subscription = await Subscription.findByPk(invoice.subscription_id);
53
+ if (subscription && subscription.status === 'incomplete') {
54
+ await subscription.update({ status: subscription.trail_end ? 'trialing' : 'active' });
55
+ logger.info('Invoice subscription updated', subscription.id);
56
+ }
57
+ }
58
+
59
+ if (invoice.checkout_session_id) {
60
+ const checkoutSession = await CheckoutSession.findByPk(invoice.checkout_session_id);
61
+ if (checkoutSession && checkoutSession.status === 'open') {
62
+ await checkoutSession.update({ status: 'complete', payment_status: 'no_payment_required' });
63
+ logger.info('Invoice checkout session updated', checkoutSession.id);
64
+ }
65
+ }
66
+
67
+ return;
68
+ }
69
+
70
+ // charge automatically
71
+ let paymentIntent: PaymentIntent | null = null;
72
+ if (invoice.payment_intent_id) {
73
+ logger.warn(`PaymentIntent exist: ${invoice.payment_intent_id} for invoice ${job.invoiceId}`);
74
+ paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
75
+ } else {
76
+ const descriptionMap: any = {
77
+ subscription_create: 'Subscription creation',
78
+ subscription_cycle: 'Subscription cycle',
79
+ };
80
+ paymentIntent = await PaymentIntent.create({
81
+ livemode: !!invoice.livemode,
82
+ amount: invoice.total,
83
+ amount_received: '0',
84
+ amount_capturable: invoice.total,
85
+ customer_id: invoice.customer_id,
86
+ description: descriptionMap[invoice.billing_reason] || '',
87
+ currency_id: invoice.currency_id,
88
+ payment_method_id: invoice.default_payment_method_id,
89
+ invoice_id: invoice.id,
90
+ status: 'requires_capture',
91
+ capture_method: 'automatic',
92
+ confirmation_method: 'automatic',
93
+ payment_method_types: [],
94
+ receipt_email: invoice.customer_email,
95
+ statement_descriptor: invoice.statement_descriptor,
96
+ statement_descriptor_suffix: '',
97
+ setup_future_usage: 'on_session',
98
+ metadata: {},
99
+ });
100
+ await invoice.update({ payment_intent_id: paymentIntent.id });
101
+ logger.info(`PaymentIntent created: ${paymentIntent.id} for invoice ${job.invoiceId}`);
102
+
103
+ if (invoice.checkout_session_id) {
104
+ const checkoutSession = await CheckoutSession.findByPk(invoice.checkout_session_id);
105
+ if (checkoutSession && checkoutSession.status === 'open') {
106
+ await checkoutSession.update({ payment_intent_id: paymentIntent.id });
107
+ logger.info('PaymentIntent attached to invoice checkout session', checkoutSession.id);
108
+ }
109
+ }
110
+ }
111
+ if (paymentIntent) {
112
+ logger.info(`Payment job created: ${paymentIntent.id}`);
113
+ paymentQueue.push({
114
+ id: paymentIntent.id,
115
+ job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
116
+ });
117
+ }
118
+ };
119
+
120
+ export const invoiceQueue = createQueue<InvoiceJob>({
121
+ name: 'invoice',
122
+ onJob: handleInvoice,
123
+ options: {
124
+ concurrency: 1,
125
+ maxRetries: 3,
126
+ },
127
+ });
128
+
129
+ export const startInvoiceQueue = async () => {
130
+ const invoices = await Invoice.findAll({
131
+ where: {
132
+ status: 'open',
133
+ collection_method: 'charge_automatically',
134
+ amount_due: { [Op.gt]: '0' },
135
+ },
136
+ });
137
+
138
+ invoices.forEach(async (x) => {
139
+ const exist = await invoiceQueue.get(x.id);
140
+ if (!exist) {
141
+ invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
142
+ }
143
+ });
144
+ };
145
+
146
+ invoiceQueue.on('failed', ({ id, job, error }) => {
147
+ logger.error('Invoice job failed', { id, job, error });
148
+ });
@@ -0,0 +1,208 @@
1
+ import { wallet } from '../libs/auth';
2
+ import { getClient } from '../libs/chain/arcblock';
3
+ import dayjs from '../libs/dayjs';
4
+ import logger from '../libs/logger';
5
+ import createQueue from '../libs/queue';
6
+ import { MAX_RETRY_COUNT, getNextRetry } from '../libs/util';
7
+ import { CheckoutSession } from '../store/models/checkout-session';
8
+ import { Invoice } from '../store/models/invoice';
9
+ import { PaymentCurrency } from '../store/models/payment-currency';
10
+ import { PaymentIntent } from '../store/models/payment-intent';
11
+ import { PaymentMethod } from '../store/models/payment-method';
12
+ import { Subscription } from '../store/models/subscription';
13
+ import type { PaymentError, PaymentSettings } from '../store/models/types';
14
+
15
+ type PaymentJob = {
16
+ paymentIntentId: string;
17
+ paymentSettings?: PaymentSettings;
18
+ retryOnError?: boolean;
19
+ };
20
+
21
+ export const handlePayment = async (job: PaymentJob) => {
22
+ logger.info('handlePayment', job);
23
+
24
+ const paymentIntent = await PaymentIntent.findByPk(job.paymentIntentId);
25
+ if (!paymentIntent) {
26
+ logger.warn(`PaymentIntent not found: ${job.paymentIntentId}`);
27
+ return;
28
+ }
29
+
30
+ if (['requires_capture', 'processing'].includes(paymentIntent.status) === false) {
31
+ logger.warn(`PaymentIntent status not expected: ${paymentIntent.status}`);
32
+ return;
33
+ }
34
+
35
+ const paymentMethod = await PaymentMethod.findByPk(paymentIntent.payment_method_id);
36
+ if (!paymentMethod) {
37
+ logger.warn(`PaymentMethod not found: ${paymentIntent.payment_method_id}`);
38
+ return;
39
+ }
40
+
41
+ // FIXME: more methods supported here
42
+ if (paymentMethod.type !== 'arcblock') {
43
+ logger.warn(`Unexpected payment method type: ${paymentMethod.type}`);
44
+ return;
45
+ }
46
+
47
+ const paymentCurrency = await PaymentCurrency.findByPk(paymentIntent.currency_id);
48
+ if (!paymentCurrency) {
49
+ logger.warn(`PaymentCurrency not found: ${paymentIntent.currency_id}`);
50
+ return;
51
+ }
52
+
53
+ const invoice = await Invoice.findByPk(paymentIntent.invoice_id);
54
+ const paymentSettings = invoice?.payment_settings || job.paymentSettings;
55
+ if (!paymentSettings) {
56
+ logger.warn('Payment settings not found:', job);
57
+ return;
58
+ }
59
+
60
+ // try payment capture and reschedule on error
61
+ logger.info(`PaymentIntent capture attempt: ${paymentIntent.id}`);
62
+ try {
63
+ await paymentIntent.update({ status: 'processing' });
64
+ const client = getClient(paymentMethod.settings.arcblock?.api_host as string);
65
+ const txHash = await client.sendTransferV2Tx({
66
+ tx: {
67
+ itx: {
68
+ to: wallet.address,
69
+ value: '0',
70
+ assets: [],
71
+ tokens: [{ address: paymentCurrency.contract, value: paymentIntent.amount }],
72
+ data: {
73
+ typeUrl: 'json',
74
+ // @ts-ignore
75
+ value: {
76
+ appId: wallet.address,
77
+ paymentIntentId: paymentIntent.id,
78
+ },
79
+ },
80
+ },
81
+ },
82
+ delegator: paymentSettings?.payment_method_options.arcblock?.payer,
83
+ wallet,
84
+ });
85
+ logger.info(`PaymentIntent capture done: ${paymentIntent.id} with tx ${txHash}`);
86
+
87
+ await paymentIntent.update({
88
+ status: 'succeeded',
89
+ amount_received: paymentIntent.amount,
90
+ payment_details: {
91
+ tx_hash: txHash,
92
+ payer: paymentSettings?.payment_method_options.arcblock?.payer,
93
+ },
94
+ });
95
+
96
+ if (invoice) {
97
+ await invoice.update({
98
+ paid: true,
99
+ status: 'paid',
100
+ amount_due: '0',
101
+ amount_paid: paymentIntent.amount,
102
+ amount_remaining: '0',
103
+ attempt_count: invoice.attempt_count + 1,
104
+ attempted: true,
105
+ status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
106
+ });
107
+ logger.info(`Invoice ${invoice.id} updated on payment done: ${job.paymentIntentId}`);
108
+
109
+ if (invoice.subscription_id) {
110
+ const subscription = await Subscription.findByPk(invoice.subscription_id);
111
+ if (subscription) {
112
+ if (subscription.status === 'incomplete') {
113
+ await subscription.update({ status: subscription.trail_end ? 'trialing' : 'active' });
114
+ logger.info(`Subscription ${subscription.id} updated on payment done ${invoice.id}`);
115
+ } else {
116
+ await subscription.update({ status: 'active' });
117
+ logger.info(`Subscription ${subscription.id} moved to active after payment done ${invoice.id}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ if (invoice.checkout_session_id) {
123
+ const checkoutSession = await CheckoutSession.findByPk(invoice.checkout_session_id);
124
+ if (checkoutSession && checkoutSession.status === 'open') {
125
+ await checkoutSession.update({
126
+ status: 'complete',
127
+ payment_status: 'paid',
128
+ payment_details: {
129
+ tx_hash: txHash,
130
+ payer: paymentSettings?.payment_method_options.arcblock?.payer,
131
+ },
132
+ });
133
+ logger.info(`CheckoutSession ${checkoutSession.id} updated on payment done ${invoice.id}`);
134
+ }
135
+ }
136
+ }
137
+ } catch (err) {
138
+ logger.error('PaymentIntent capture failed', { error: err, id: paymentIntent.id });
139
+
140
+ const error: PaymentError = {
141
+ type: 'card_error',
142
+ code: err.code,
143
+ message: err.message,
144
+ payment_settings: paymentSettings,
145
+ payment_method_id: paymentMethod.id,
146
+ payment_method_type: paymentMethod.type,
147
+ };
148
+
149
+ if (invoice && job.retryOnError) {
150
+ if (invoice.attempt_count > MAX_RETRY_COUNT) {
151
+ await paymentIntent.update({ status: 'requires_action', last_payment_error: error });
152
+ await invoice.update({ status: 'uncollectible' });
153
+ // FIXME: send email to customer, pause subscription
154
+ logger.error('PaymentIntent capture failed after max retry', { id: paymentIntent.id });
155
+ } else {
156
+ const retryAt = getNextRetry(invoice.attempt_count + 1);
157
+
158
+ await paymentIntent.update({ status: 'requires_capture', last_payment_error: error });
159
+ await invoice.update({
160
+ attempt_count: invoice.attempt_count + 1,
161
+ attempted: true,
162
+ next_payment_attempt: retryAt,
163
+ });
164
+ logger.error('PaymentIntent capture retry scheduled', { id: paymentIntent.id, retryAt });
165
+
166
+ // reschedule next attempt
167
+ paymentQueue.push({
168
+ id: paymentIntent.id,
169
+ job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
170
+ runAt: retryAt,
171
+ });
172
+ }
173
+ } else {
174
+ logger.error('PaymentIntent status reverted on capture error', { id: paymentIntent.id });
175
+ await paymentIntent.update({ status: 'requires_capture' });
176
+ }
177
+ }
178
+ };
179
+
180
+ export const paymentQueue = createQueue<PaymentJob>({
181
+ name: 'payment',
182
+ onJob: handlePayment,
183
+ options: {
184
+ concurrency: 1,
185
+ maxRetries: 3,
186
+ enableScheduledJob: true,
187
+ },
188
+ });
189
+
190
+ export const startPaymentQueue = async () => {
191
+ const payments = await PaymentIntent.findAll({
192
+ where: {
193
+ status: ['requires_capture', 'processing'],
194
+ capture_method: 'automatic',
195
+ },
196
+ });
197
+
198
+ payments.forEach(async (x) => {
199
+ const exist = await paymentQueue.get(x.id);
200
+ if (!exist) {
201
+ paymentQueue.push({ id: x.id, job: { paymentIntentId: x.id } });
202
+ }
203
+ });
204
+ };
205
+
206
+ paymentQueue.on('failed', ({ id, job, error }) => {
207
+ logger.error('Payment job failed', { id, job, error });
208
+ });