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,93 @@
1
+ import crypto from 'crypto';
2
+
3
+ import { customAlphabet } from 'nanoid';
4
+
5
+ import dayjs from './dayjs';
6
+
7
+ export const MAX_RETRY_COUNT = 18; // 2^18 seconds ~~ 3 days, total retry time: 6 days
8
+
9
+ export function md5(input: string) {
10
+ return crypto.createHash('md5').update(input).digest('hex');
11
+ }
12
+
13
+ export function createIdGenerator(prefix: string, size: number = 24) {
14
+ const generator = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', size);
15
+ return prefix ? () => `${prefix}_${generator()}` : generator;
16
+ }
17
+
18
+ export function createCodeGenerator(prefix: string, size: number = 24) {
19
+ const generator = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', size);
20
+ return prefix ? () => `${prefix}_${generator()}` : generator;
21
+ }
22
+
23
+ export function formatMetadata(metadata?: Record<string, any>): Record<string, any> {
24
+ if (!metadata) {
25
+ return {};
26
+ }
27
+
28
+ if (Array.isArray(metadata)) {
29
+ return metadata.reduce((acc: Record<string, any>, x: { key: string; value: any }) => {
30
+ if (x.value) {
31
+ acc[x.key] = x.value;
32
+ }
33
+ return acc;
34
+ }, {});
35
+ }
36
+
37
+ // remove empty values
38
+ return Object.keys(metadata).reduce((acc: Record<string, any>, key: string) => {
39
+ if (metadata[key]) {
40
+ acc[key] = metadata[key];
41
+ }
42
+ return acc;
43
+ }, []);
44
+ }
45
+
46
+ export function sleep(timeout = 0) {
47
+ return new Promise((resolve) => {
48
+ setTimeout(() => resolve(timeout), timeout);
49
+ });
50
+ }
51
+
52
+ export function tryWithTimeout(asyncFn: Function, timeout = 5000) {
53
+ if (typeof asyncFn !== 'function') {
54
+ throw new Error('Must provide a valid asyncFn function');
55
+ }
56
+
57
+ /* eslint-disable no-async-promise-executor */
58
+ return new Promise(async (resolve, reject) => {
59
+ const timer = setTimeout(() => {
60
+ reject(new Error(`Operation timed out after ${timeout} ms`));
61
+ }, timeout);
62
+
63
+ try {
64
+ const result = await asyncFn();
65
+ resolve(result);
66
+ } catch (err) {
67
+ reject(err);
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ });
72
+ }
73
+
74
+ export class CustomError extends Error {
75
+ code: string;
76
+
77
+ constructor(code = 'GENERIC', ...params: any) {
78
+ super(...params);
79
+
80
+ if (Error.captureStackTrace) {
81
+ Error.captureStackTrace(this, CustomError);
82
+ }
83
+
84
+ this.code = code;
85
+ }
86
+ }
87
+
88
+ // simple exponential delay: 2^retryCount
89
+ export const getNextRetry = (retryCount: number) => {
90
+ const delay = 2 ** retryCount;
91
+ const now = dayjs().unix();
92
+ return now + delay;
93
+ };
@@ -0,0 +1,3 @@
1
+ import flat from 'flat';
2
+
3
+ export default flat({});
@@ -0,0 +1,37 @@
1
+ /* eslint-disable no-prototype-builtins */
2
+ import en from './en';
3
+ import zh from './zh';
4
+
5
+ export const replace = (template: string, data: Record<string, any> = {}) =>
6
+ template.replace(/{(\w*)}/g, (_, key) => (data.hasOwnProperty(key) ? data[key] : ''));
7
+
8
+ export const createTranslator = ({
9
+ translations,
10
+ fallbackLocale = 'en',
11
+ }: {
12
+ translations: { [key: string]: Record<string, string> };
13
+ fallbackLocale?: string;
14
+ }) => {
15
+ return (key: string, locale = fallbackLocale, data: Record<string, any> = {}) => {
16
+ // @ts-ignore
17
+ if (!translations[locale] || !translations[locale][key]) {
18
+ if (fallbackLocale && translations[fallbackLocale]?.[key]) {
19
+ // @ts-ignore
20
+ return replace(translations[fallbackLocale]?.[key], data);
21
+ }
22
+
23
+ return key;
24
+ }
25
+
26
+ // @ts-ignore
27
+ return replace(translations[locale][key], data);
28
+ };
29
+ };
30
+
31
+ // eslint-disable-next-line import/prefer-default-export
32
+ export const translations = {
33
+ zh,
34
+ en,
35
+ };
36
+
37
+ export const translate = createTranslator({ translations });
@@ -0,0 +1,3 @@
1
+ import flat from 'flat';
2
+
3
+ export default flat({});
@@ -0,0 +1,536 @@
1
+ /* eslint-disable consistent-return */
2
+ import { getUrl } from '@blocklet/sdk/lib/component';
3
+ import userMiddleware from '@blocklet/sdk/lib/middlewares/user';
4
+ import { Router } from 'express';
5
+ import omit from 'lodash/omit';
6
+ import pick from 'lodash/pick';
7
+
8
+ import { invoiceQueue } from '../jobs/invoice';
9
+ import { paymentQueue } from '../jobs/payment';
10
+ import { subscriptionQueue } from '../jobs/subscription';
11
+ import dayjs from '../libs/dayjs';
12
+ import logger from '../libs/logger';
13
+ import { isDelegationSufficientForPayment } from '../libs/payment';
14
+ import { authenticate } from '../libs/security';
15
+ import {
16
+ getCheckoutAmount,
17
+ getCheckoutMode,
18
+ getStatementDescriptor,
19
+ getSubscriptionCreateSetup,
20
+ } from '../libs/session';
21
+ import { createCodeGenerator, formatMetadata } from '../libs/util';
22
+ import type { LineItem } from '../store/models';
23
+ import { CheckoutSession } from '../store/models/checkout-session';
24
+ import { Customer } from '../store/models/customer';
25
+ import { PaymentCurrency } from '../store/models/payment-currency';
26
+ import { PaymentIntent } from '../store/models/payment-intent';
27
+ import { PaymentLink } from '../store/models/payment-link';
28
+ import { PaymentMethod } from '../store/models/payment-method';
29
+ import { Price } from '../store/models/price';
30
+ import { SetupIntent } from '../store/models/setup-intent';
31
+ import { Subscription } from '../store/models/subscription';
32
+ import { SubscriptionItem } from '../store/models/subscription-item';
33
+ import { ensureInvoiceForCheckout } from './connect/shared';
34
+
35
+ const getInvoicePrefix = createCodeGenerator('', 8);
36
+
37
+ const router = Router();
38
+
39
+ const user = userMiddleware();
40
+ const auth = authenticate<CheckoutSession>({ component: true, roles: ['owner', 'admin'] });
41
+
42
+ const formatBeforeSave = async (payload: any) => {
43
+ const raw: Partial<CheckoutSession> = Object.assign(
44
+ {
45
+ allow_promotion_codes: false,
46
+ customer_creation: 'always',
47
+ consent_collection: {
48
+ promotions: 'none',
49
+ terms_of_service: 'none',
50
+ },
51
+ invoice_creation: {
52
+ enabled: false,
53
+ },
54
+ phone_number_collection: {
55
+ enabled: false,
56
+ },
57
+ billing_address_collection: 'auto',
58
+ subscription_data: {
59
+ description: '',
60
+ trial_period_days: 0,
61
+ },
62
+ submit_type: 'pay',
63
+ },
64
+ pick(payload, [
65
+ 'expires_at',
66
+ 'line_items',
67
+ 'allow_promotion_codes',
68
+ 'consent_collection',
69
+ 'custom_fields',
70
+ 'customer_creation',
71
+ 'invoice_creation',
72
+ 'phone_number_collection',
73
+ 'billing_address_collection',
74
+ 'submit_type',
75
+ 'subscription_data',
76
+ 'metadata',
77
+ 'cancel_url',
78
+ 'success_url',
79
+ 'client_reference_id',
80
+ 'after_expiration',
81
+ ])
82
+ );
83
+ if (payload.include_free_trial && raw.subscription_data) {
84
+ raw.subscription_data.trial_period_days = Number(raw.subscription_data.trial_period_days);
85
+ }
86
+
87
+ if (!raw.expires_at) {
88
+ raw.expires_at = dayjs().unix() + 60 * 60 * 24; // 24 hours after creation
89
+ }
90
+
91
+ raw.line_items?.forEach((x) => {
92
+ if (x.adjustable_quantity?.enabled) {
93
+ x.adjustable_quantity.minimum = Number(x.adjustable_quantity?.minimum);
94
+ x.adjustable_quantity.maximum = Number(x.adjustable_quantity?.maximum);
95
+ }
96
+ });
97
+
98
+ raw.metadata = formatMetadata(raw.metadata);
99
+
100
+ const items = await Price.expand(raw.line_items as LineItem[]);
101
+ if (items.some((x) => !x.price)) {
102
+ throw new Error('Invalid line items for checkout session, some price may have been deleted');
103
+ }
104
+ if (items.some((x) => !x.price.active)) {
105
+ throw new Error('Invalid line items for checkout session, some price may have been archived');
106
+ }
107
+
108
+ const amount = getCheckoutAmount(items, !!raw.subscription_data?.trial_period_days);
109
+ const mode = getCheckoutMode(items);
110
+
111
+ return Object.assign(raw, {
112
+ mode,
113
+ status: 'open',
114
+ payment_status: 'unpaid',
115
+
116
+ amount_subtotal: amount.subtotal,
117
+ amount_total: amount.total,
118
+ total_details: {
119
+ amount_discount: amount.discount,
120
+ amount_shipping: amount.shipping,
121
+ amount_tax: amount.tax,
122
+ },
123
+
124
+ // always create invoice for subscriptions
125
+ invoice_creation: mode === 'subscription' ? { enabled: true } : raw.invoice_creation,
126
+ });
127
+ };
128
+
129
+ // create checkout session
130
+ router.post('/', auth, async (req, res) => {
131
+ const raw: Partial<CheckoutSession> = await formatBeforeSave(req.body);
132
+ raw.livemode = !!req.livemode;
133
+ raw.created_via = req.user?.via as string;
134
+ raw.currency_id = raw.currency_id || req.currency.id;
135
+
136
+ const doc = await CheckoutSession.create(raw as any);
137
+
138
+ // FIXME: lock price and product
139
+
140
+ res.json({ ...doc.toJSON(), url: getUrl(`/checkout/${doc.submit_type}/${doc.id}`) });
141
+ });
142
+
143
+ // start checkout session from payment link
144
+ router.post('/start/:id', user, async (req, res) => {
145
+ const link = await PaymentLink.findByPk(req.params.id);
146
+ if (!link) {
147
+ res.status(400).json({ error: 'Payment link not found, please contact the source of the payment link.' });
148
+ return;
149
+ }
150
+ if (!link.active) {
151
+ res.status(400).json({ error: 'Payment link archived, we can not create new checkout session.' });
152
+ return;
153
+ }
154
+
155
+ const items = await Price.expand(link.line_items);
156
+
157
+ const raw: Partial<CheckoutSession> = await formatBeforeSave(link);
158
+ raw.livemode = link.livemode;
159
+ raw.created_via = 'portal';
160
+ raw.currency_id = link.currency_id || req.currency.id;
161
+ raw.payment_link_id = link.id;
162
+
163
+ try {
164
+ let doc;
165
+ if (req.query.preview === '1') {
166
+ doc = await CheckoutSession.findOne({ where: { payment_link_id: link.id, metadata: { preview: '1' } } });
167
+ if (doc) {
168
+ await doc.update(omit(raw, ['metadata']));
169
+ } else {
170
+ raw.metadata = { preview: '1' };
171
+ }
172
+ }
173
+
174
+ if (!doc) {
175
+ doc = await CheckoutSession.create(raw as CheckoutSession);
176
+ }
177
+
178
+ doc.line_items = items;
179
+ res.json({
180
+ checkoutSession: { ...doc.toJSON(), currency: await PaymentCurrency.findByPk(doc.currency_id) },
181
+ paymentMethods: await PaymentMethod.expand(req.livemode),
182
+ paymentLink: link,
183
+ paymentIntent: null,
184
+ });
185
+ } catch (err) {
186
+ console.error(err);
187
+ res.status(500).json({ error: err.message });
188
+ }
189
+ });
190
+
191
+ router.get('/:id', auth, async (req, res) => {
192
+ const doc = await CheckoutSession.findByPk(req.params.id);
193
+
194
+ if (doc) {
195
+ // @ts-ignore
196
+ doc.line_items = await Price.expand(doc.line_items);
197
+ }
198
+
199
+ res.json(doc?.toJSON());
200
+ });
201
+
202
+ router.get('/retrieve/:id', user, async (req, res) => {
203
+ const doc = await CheckoutSession.findByPk(req.params.id);
204
+
205
+ if (!doc) {
206
+ res.status(404).json({ error: 'Checkout session not found, you may have incorrectly copied the link.' });
207
+ return;
208
+ }
209
+
210
+ // @ts-ignore
211
+ doc.line_items = await Price.expand(doc.line_items);
212
+
213
+ // FIXME: possible sensitive data leak
214
+ res.json({
215
+ checkoutSession: { ...doc.toJSON(), currency: await PaymentCurrency.findByPk(doc.currency_id) },
216
+ paymentMethods: await PaymentMethod.expand(req.livemode),
217
+ paymentLink: doc.payment_link_id ? await PaymentLink.findByPk(doc.payment_link_id) : null,
218
+ paymentIntent: doc.payment_intent_id ? await PaymentIntent.findByPk(doc.payment_intent_id) : null,
219
+ customer: req.user ? await Customer.findOne({ where: { did: req.user.did } }) : null,
220
+ });
221
+ });
222
+
223
+ // submit order
224
+ router.put('/:id/submit', user, async (req, res) => {
225
+ try {
226
+ if (!req.user) {
227
+ return res.status(403).json({ error: 'Please login to continue' });
228
+ }
229
+
230
+ // validate session
231
+ const checkoutSession = await CheckoutSession.findByPk(req.params.id);
232
+ if (!checkoutSession) {
233
+ return res.status(404).json({ error: 'Checkout session not found' });
234
+ }
235
+ if (checkoutSession.status === 'complete') {
236
+ return res.status(403).json({ error: 'Checkout session completed' });
237
+ }
238
+ if (checkoutSession.status === 'expired') {
239
+ return res.status(403).json({ error: 'Checkout session expired' });
240
+ }
241
+
242
+ // validate payment settings
243
+ const paymentMethod = await PaymentMethod.findByPk(req.body.payment_method);
244
+ const paymentCurrency = await PaymentCurrency.findByPk(req.body.payment_currency);
245
+ if (!paymentMethod) {
246
+ return res.status(400).json({ error: 'Payment method not found' });
247
+ }
248
+ if (!paymentCurrency) {
249
+ return res.status(400).json({ error: 'Payment currency not found' });
250
+ }
251
+ if (paymentCurrency.payment_method_id !== paymentMethod.id) {
252
+ return res.status(400).json({ error: 'Payment currency not match with payment method' });
253
+ }
254
+ await checkoutSession.update({ currency_id: paymentCurrency.id });
255
+
256
+ // ensure customer created or updated
257
+ let customer = await Customer.findOne({ where: { did: req.user.did } });
258
+ if (!customer) {
259
+ customer = await Customer.create({
260
+ livemode: !!checkoutSession.livemode,
261
+ did: req.user.did,
262
+ name: req.body.customer_name,
263
+ email: req.body.customer_email,
264
+ phone: req.body.customer_phone,
265
+ address: req.body.billing_address,
266
+ description: '',
267
+ metadata: {},
268
+ balance: '0',
269
+ next_invoice_sequence: 1,
270
+ delinquent: false,
271
+ invoice_prefix: getInvoicePrefix(),
272
+ });
273
+ } else {
274
+ const updates: Record<string, string> = {};
275
+ if (checkoutSession.customer_update?.name) {
276
+ updates.name = req.body.customer_name;
277
+ updates.email = req.body.customer_email;
278
+ updates.phone = req.body.customer_phone;
279
+ }
280
+ if (checkoutSession.customer_update?.address) {
281
+ updates.address = req.body.billing_address;
282
+ }
283
+ if (!customer.invoice_prefix) {
284
+ updates.invoice_prefix = getInvoicePrefix();
285
+ }
286
+
287
+ await customer.update(updates);
288
+ }
289
+
290
+ const lineItems = await Price.expand(checkoutSession.line_items, true);
291
+
292
+ // payment intent is only created when checkout session is in payment mode
293
+ let paymentIntent: PaymentIntent | null = null;
294
+ if (checkoutSession.mode === 'payment') {
295
+ if (checkoutSession.payment_intent_id) {
296
+ paymentIntent = await PaymentIntent.findByPk(checkoutSession.payment_intent_id);
297
+ }
298
+
299
+ // check existing payment intent
300
+ if (paymentIntent) {
301
+ // Check payment intent, if we have a payment intent, we should not create a new one
302
+ if (paymentIntent.status === 'succeeded') {
303
+ return res.status(403).json({ error: 'Checkout session payment completed' });
304
+ }
305
+ if (paymentIntent.status === 'canceled') {
306
+ return res.status(403).json({ error: 'Checkout session payment canceled' });
307
+ }
308
+ if (paymentIntent.status === 'processing') {
309
+ return res.status(403).json({ error: 'Checkout session payment processing' });
310
+ }
311
+ paymentIntent = await paymentIntent.update({
312
+ amount: checkoutSession.amount_total,
313
+ customer_id: customer.id,
314
+ currency_id: paymentCurrency.id,
315
+ payment_method_id: paymentMethod.id,
316
+ receipt_email: customer.email,
317
+ });
318
+ } else {
319
+ // ensure payment intent
320
+ // FIXME: support and validate currency converting here
321
+ paymentIntent = await PaymentIntent.create({
322
+ livemode: !!checkoutSession.livemode,
323
+ amount: checkoutSession.amount_total,
324
+ amount_received: '0',
325
+ amount_capturable: checkoutSession.amount_total,
326
+ customer_id: customer.id,
327
+ description: '',
328
+ currency_id: paymentCurrency.id,
329
+ payment_method_id: paymentMethod.id,
330
+ status: 'requires_payment_method',
331
+ capture_method: 'automatic',
332
+ confirmation_method: 'automatic',
333
+ payment_method_types: [],
334
+ receipt_email: customer.email,
335
+ statement_descriptor: getStatementDescriptor(lineItems),
336
+ statement_descriptor_suffix: '',
337
+ setup_future_usage: 'on_session',
338
+ metadata: {},
339
+ });
340
+
341
+ // persist payment intent id
342
+ await checkoutSession.update({ payment_intent_id: paymentIntent.id });
343
+ }
344
+ }
345
+
346
+ // payment intent is only created when checkout session is in payment mode
347
+ let setupIntent: SetupIntent | null = null;
348
+ if (checkoutSession.mode === 'setup') {
349
+ if (checkoutSession.setup_intent_id) {
350
+ setupIntent = await SetupIntent.findByPk(checkoutSession.setup_intent_id);
351
+ }
352
+
353
+ // check existing payment intent
354
+ if (setupIntent) {
355
+ // Check payment intent, if we have a payment intent, we should not create a new one
356
+ if (setupIntent.status === 'succeeded') {
357
+ return res.status(403).json({ error: 'Checkout session setup completed' });
358
+ }
359
+ if (setupIntent.status === 'canceled') {
360
+ return res.status(403).json({ error: 'Checkout session setup canceled' });
361
+ }
362
+ if (setupIntent.status === 'processing') {
363
+ return res.status(403).json({ error: 'Checkout session setup processing' });
364
+ }
365
+ await setupIntent.update({
366
+ customer_id: customer.id,
367
+ currency_id: paymentCurrency.id,
368
+ payment_method_id: paymentMethod.id,
369
+ });
370
+ } else {
371
+ // ensure payment intent
372
+ setupIntent = await SetupIntent.create({
373
+ livemode: !!checkoutSession.livemode,
374
+ customer_id: customer.id,
375
+ description: '',
376
+ currency_id: paymentCurrency.id,
377
+ payment_method_id: paymentMethod.id,
378
+ status: 'requires_payment_method',
379
+ payment_method_types: [],
380
+ flow_directions: ['inbound', 'outbound'],
381
+ usage: 'off_session',
382
+ metadata: {},
383
+ });
384
+
385
+ // persist setup intent id
386
+ await checkoutSession.update({ setup_intent_id: setupIntent.id });
387
+ }
388
+ }
389
+
390
+ let subscription: Subscription | null = null;
391
+ if (checkoutSession.mode === 'subscription' || checkoutSession.mode === 'setup') {
392
+ if (checkoutSession.subscription_id) {
393
+ subscription = await Subscription.findByPk(checkoutSession.subscription_id);
394
+ }
395
+ if (subscription) {
396
+ if (subscription.status !== 'incomplete') {
397
+ return res.status(403).json({ error: 'Checkout session subscription status unexpected' });
398
+ }
399
+ subscription = await subscription.update({
400
+ currency_id: req.body.payment_currency,
401
+ customer_id: customer.id,
402
+ default_payment_method_id: req.body.payment_method,
403
+ pending_setup_intent: setupIntent?.id,
404
+ });
405
+ } else {
406
+ const setup = getSubscriptionCreateSetup(lineItems, checkoutSession.subscription_data?.trial_period_days);
407
+ subscription = await Subscription.create({
408
+ livemode: !!checkoutSession.livemode,
409
+ currency_id: req.body.payment_currency,
410
+ customer_id: customer.id,
411
+ status: 'incomplete',
412
+ current_period_start: setup.period.start,
413
+ current_period_end: setup.period.end,
414
+ billing_cycle_anchor: setup.cycle.anchor,
415
+ start_date: dayjs().unix(),
416
+ trail_end: setup.trail.end,
417
+ trail_start: setup.trail.start,
418
+ trail_settings: {
419
+ end_behavior: {
420
+ missing_payment_method: 'create_invoice',
421
+ },
422
+ },
423
+ pending_invoice_item_interval: setup.recurring,
424
+ pending_setup_intent: setupIntent?.id,
425
+ default_payment_method_id: req.body.payment_method,
426
+ cancel_at_period_end: false,
427
+ collection_method: 'charge_automatically',
428
+ // FIXME: support discount
429
+ metadata: {},
430
+ });
431
+
432
+ // create subscription items
433
+ await Promise.all(
434
+ lineItems
435
+ .filter((x) => x.price.type === 'recurring')
436
+ .map((x) =>
437
+ SubscriptionItem.create({
438
+ livemode: !!checkoutSession.livemode,
439
+ // @ts-ignore
440
+ subscription_id: subscription.id,
441
+ price_id: x.price_id,
442
+ quantity: x.quantity,
443
+ metadata: {},
444
+ })
445
+ )
446
+ );
447
+
448
+ // persist subscription id
449
+ await checkoutSession.update({ subscription_id: subscription.id });
450
+ }
451
+ }
452
+
453
+ // if we can complete purchase without any wallet interaction
454
+ const delegation = await isDelegationSufficientForPayment({
455
+ paymentMethod,
456
+ paymentCurrency,
457
+ userDid: customer.did,
458
+ amount: checkoutSession.amount_total,
459
+ });
460
+ if (delegation.sufficient) {
461
+ const paymentSettings = {
462
+ payment_method_types: ['arcblock'],
463
+ payment_method_options: {
464
+ arcblock: { payer: delegation.delegator as string },
465
+ },
466
+ };
467
+
468
+ // all subscription payments are done after delegation
469
+ if (checkoutSession.mode === 'subscription' && subscription) {
470
+ await subscription.update({ payment_settings: paymentSettings });
471
+
472
+ const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, subscription });
473
+ if (invoice) {
474
+ invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
475
+ }
476
+ subscriptionQueue.push({
477
+ id: subscription.id,
478
+ job: { subscriptionId: subscription.id, action: 'cycle' },
479
+ runAt: subscription.trail_end || subscription.current_period_end,
480
+ });
481
+ }
482
+ if (checkoutSession.mode === 'payment' && paymentIntent) {
483
+ await paymentIntent.update({ status: 'requires_capture' });
484
+ const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, paymentIntent });
485
+ if (invoice) {
486
+ await invoice.update({ auto_advance: true, payment_settings: paymentSettings });
487
+ invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
488
+ } else {
489
+ const job = paymentQueue.push({
490
+ id: paymentIntent.id,
491
+ job: { paymentIntentId: paymentIntent.id, paymentSettings, retryOnError: false },
492
+ });
493
+ job.on('finished', async () => {
494
+ await checkoutSession.update({ status: 'complete', payment_status: 'paid' });
495
+ logger.info(`CheckoutSession ${checkoutSession.id} updated on payment done ${paymentIntent?.id}`);
496
+ });
497
+ }
498
+ }
499
+ if (checkoutSession.mode === 'setup' && setupIntent && subscription) {
500
+ await setupIntent.update({ status: 'succeeded', ...paymentSettings });
501
+ await subscription.update({
502
+ status: subscription.trail_end ? 'trialing' : 'active',
503
+ payment_settings: paymentSettings,
504
+ });
505
+ await checkoutSession.update({ status: 'complete', payment_status: 'no_payment_required' });
506
+ logger.info(`CheckoutSession ${checkoutSession.id} updated on payment done ${paymentIntent?.id}`);
507
+ }
508
+ }
509
+
510
+ return res.json({ paymentIntent, setupIntent, subscription, checkoutSession, delegation });
511
+ } catch (err) {
512
+ console.error(err);
513
+ res.status(500).json({ error: err.message });
514
+ }
515
+ });
516
+
517
+ // eslint-disable-next-line consistent-return
518
+ router.put('/:id/expire', auth, async (req, res) => {
519
+ const doc = await CheckoutSession.findByPk(req.params.id);
520
+
521
+ if (!doc) {
522
+ return res.status(404).json({ error: 'Checkout session not found' });
523
+ }
524
+ if (doc.status === 'complete') {
525
+ return res.status(403).json({ error: 'Checkout session completed' });
526
+ }
527
+ if (doc.status === 'expired') {
528
+ return res.status(403).json({ error: 'Checkout session already expired' });
529
+ }
530
+
531
+ await doc.update({ status: 'expired', expires_at: dayjs().unix() });
532
+
533
+ res.json(doc);
534
+ });
535
+
536
+ export default router;