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.
- package/.eslintrc.js +15 -0
- package/README.md +3 -0
- package/api/dev.ts +6 -0
- package/api/hooks/pre-start.js +12 -0
- package/api/src/hooks/pre-start.ts +21 -0
- package/api/src/index.ts +92 -0
- package/api/src/jobs/event.ts +72 -0
- package/api/src/jobs/invoice.ts +148 -0
- package/api/src/jobs/payment.ts +208 -0
- package/api/src/jobs/subscription.ts +301 -0
- package/api/src/jobs/webhook.ts +113 -0
- package/api/src/libs/audit.ts +73 -0
- package/api/src/libs/auth.ts +40 -0
- package/api/src/libs/chain/arcblock.ts +13 -0
- package/api/src/libs/dayjs.ts +17 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/hooks.ts +42 -0
- package/api/src/libs/logger.ts +27 -0
- package/api/src/libs/middleware.ts +12 -0
- package/api/src/libs/payment.ts +53 -0
- package/api/src/libs/queue/index.ts +263 -0
- package/api/src/libs/queue/store.ts +47 -0
- package/api/src/libs/security.ts +95 -0
- package/api/src/libs/session.ts +164 -0
- package/api/src/libs/util.ts +93 -0
- package/api/src/locales/en.ts +3 -0
- package/api/src/locales/index.ts +37 -0
- package/api/src/locales/zh.ts +3 -0
- package/api/src/routes/checkout-sessions.ts +536 -0
- package/api/src/routes/connect/collect.ts +109 -0
- package/api/src/routes/connect/pay.ts +116 -0
- package/api/src/routes/connect/setup.ts +121 -0
- package/api/src/routes/connect/shared.ts +410 -0
- package/api/src/routes/connect/subscribe.ts +128 -0
- package/api/src/routes/customers.ts +70 -0
- package/api/src/routes/events.ts +76 -0
- package/api/src/routes/index.ts +59 -0
- package/api/src/routes/invoices.ts +126 -0
- package/api/src/routes/payment-currencies.ts +38 -0
- package/api/src/routes/payment-intents.ts +122 -0
- package/api/src/routes/payment-links.ts +221 -0
- package/api/src/routes/payment-methods.ts +39 -0
- package/api/src/routes/prices.ts +134 -0
- package/api/src/routes/products.ts +191 -0
- package/api/src/routes/settings.ts +33 -0
- package/api/src/routes/subscription-items.ts +148 -0
- package/api/src/routes/subscriptions.ts +254 -0
- package/api/src/routes/usage-records.ts +120 -0
- package/api/src/routes/webhook-attempts.ts +57 -0
- package/api/src/routes/webhook-endpoints.ts +105 -0
- package/api/src/store/migrate.ts +16 -0
- package/api/src/store/migrations/20230905-genesis.ts +52 -0
- package/api/src/store/migrations/20230911-seeding.ts +145 -0
- package/api/src/store/models/checkout-session.ts +395 -0
- package/api/src/store/models/coupon.ts +137 -0
- package/api/src/store/models/customer.ts +199 -0
- package/api/src/store/models/discount.ts +116 -0
- package/api/src/store/models/event.ts +111 -0
- package/api/src/store/models/index.ts +165 -0
- package/api/src/store/models/invoice-item.ts +185 -0
- package/api/src/store/models/invoice.ts +492 -0
- package/api/src/store/models/job.ts +75 -0
- package/api/src/store/models/payment-currency.ts +139 -0
- package/api/src/store/models/payment-intent.ts +282 -0
- package/api/src/store/models/payment-link.ts +219 -0
- package/api/src/store/models/payment-method.ts +169 -0
- package/api/src/store/models/price.ts +266 -0
- package/api/src/store/models/product.ts +162 -0
- package/api/src/store/models/promotion-code.ts +112 -0
- package/api/src/store/models/setup-intent.ts +206 -0
- package/api/src/store/models/subscription-item.ts +103 -0
- package/api/src/store/models/subscription-schedule.ts +157 -0
- package/api/src/store/models/subscription.ts +307 -0
- package/api/src/store/models/types.ts +406 -0
- package/api/src/store/models/usage-record.ts +132 -0
- package/api/src/store/models/webhook-attempt.ts +96 -0
- package/api/src/store/models/webhook-endpoint.ts +96 -0
- package/api/src/store/sequelize.ts +15 -0
- package/api/third.d.ts +28 -0
- package/blocklet.md +3 -0
- package/blocklet.yml +89 -0
- package/index.html +14 -0
- package/logo.png +0 -0
- package/package.json +133 -0
- package/public/.gitkeep +0 -0
- package/screenshots/.gitkeep +0 -0
- package/screenshots/1-subscription.png +0 -0
- package/screenshots/2-customer-1.png +0 -0
- package/screenshots/3-customer-2.png +0 -0
- package/screenshots/4-admin-3.png +0 -0
- package/screenshots/5-admin-4.png +0 -0
- package/scripts/build-clean.js +6 -0
- package/scripts/bump-version.mjs +35 -0
- package/src/app.tsx +68 -0
- package/src/components/actions.tsx +85 -0
- package/src/components/blockchain/tx.tsx +29 -0
- package/src/components/checkout/amount.tsx +24 -0
- package/src/components/checkout/error.tsx +30 -0
- package/src/components/checkout/footer.tsx +12 -0
- package/src/components/checkout/form/address.tsx +38 -0
- package/src/components/checkout/form/index.tsx +295 -0
- package/src/components/checkout/header.tsx +23 -0
- package/src/components/checkout/pay.tsx +222 -0
- package/src/components/checkout/product-card.tsx +56 -0
- package/src/components/checkout/product-item.tsx +37 -0
- package/src/components/checkout/skeleton/overview.tsx +21 -0
- package/src/components/checkout/skeleton/payment.tsx +35 -0
- package/src/components/checkout/success.tsx +183 -0
- package/src/components/checkout/summary.tsx +34 -0
- package/src/components/collapse.tsx +50 -0
- package/src/components/confirm.tsx +55 -0
- package/src/components/copyable.tsx +38 -0
- package/src/components/currency.tsx +15 -0
- package/src/components/customer/actions.tsx +73 -0
- package/src/components/data.tsx +20 -0
- package/src/components/drawer-form.tsx +77 -0
- package/src/components/error-fallback.tsx +7 -0
- package/src/components/error.tsx +39 -0
- package/src/components/event/list.tsx +217 -0
- package/src/components/info-card.tsx +40 -0
- package/src/components/info-metric.tsx +35 -0
- package/src/components/info-row.tsx +28 -0
- package/src/components/input.tsx +40 -0
- package/src/components/invoice/action.tsx +94 -0
- package/src/components/invoice/list.tsx +225 -0
- package/src/components/invoice/table.tsx +110 -0
- package/src/components/layout.tsx +70 -0
- package/src/components/livemode.tsx +23 -0
- package/src/components/metadata/editor.tsx +57 -0
- package/src/components/metadata/form.tsx +45 -0
- package/src/components/payment-intent/actions.tsx +81 -0
- package/src/components/payment-intent/list.tsx +204 -0
- package/src/components/payment-link/actions.tsx +114 -0
- package/src/components/payment-link/after-pay.tsx +87 -0
- package/src/components/payment-link/before-pay.tsx +175 -0
- package/src/components/payment-link/item.tsx +135 -0
- package/src/components/payment-link/product-select.tsx +66 -0
- package/src/components/payment-link/rename.tsx +64 -0
- package/src/components/portal/invoice/list.tsx +110 -0
- package/src/components/portal/subscription/cancel.tsx +83 -0
- package/src/components/portal/subscription/list.tsx +232 -0
- package/src/components/price/actions.tsx +21 -0
- package/src/components/price/form.tsx +292 -0
- package/src/components/product/actions.tsx +125 -0
- package/src/components/product/add-price.tsx +59 -0
- package/src/components/product/create.tsx +97 -0
- package/src/components/product/edit-price.tsx +75 -0
- package/src/components/product/edit.tsx +67 -0
- package/src/components/product/features.tsx +32 -0
- package/src/components/product/form.tsx +76 -0
- package/src/components/relative-time.tsx +41 -0
- package/src/components/section/header.tsx +29 -0
- package/src/components/status.tsx +12 -0
- package/src/components/subscription/actions/cancel.tsx +66 -0
- package/src/components/subscription/actions/index.tsx +172 -0
- package/src/components/subscription/actions/pause.tsx +83 -0
- package/src/components/subscription/items/actions.tsx +31 -0
- package/src/components/subscription/items/index.tsx +107 -0
- package/src/components/subscription/list.tsx +200 -0
- package/src/components/switch.tsx +48 -0
- package/src/components/table.tsx +66 -0
- package/src/components/uploader.tsx +81 -0
- package/src/components/webhook/attempts.tsx +149 -0
- package/src/contexts/products.tsx +42 -0
- package/src/contexts/session.ts +10 -0
- package/src/contexts/settings.tsx +54 -0
- package/src/env.d.ts +17 -0
- package/src/global.css +97 -0
- package/src/hooks/mobile.ts +15 -0
- package/src/index.tsx +6 -0
- package/src/libs/api.ts +19 -0
- package/src/libs/dayjs.ts +17 -0
- package/src/libs/util.ts +474 -0
- package/src/locales/en.tsx +395 -0
- package/src/locales/index.tsx +8 -0
- package/src/locales/zh.tsx +389 -0
- package/src/pages/admin/billing/index.tsx +56 -0
- package/src/pages/admin/billing/invoices/detail.tsx +215 -0
- package/src/pages/admin/billing/invoices/index.tsx +5 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +237 -0
- package/src/pages/admin/billing/subscriptions/index.tsx +5 -0
- package/src/pages/admin/customers/customers/detail.tsx +209 -0
- package/src/pages/admin/customers/customers/index.tsx +109 -0
- package/src/pages/admin/customers/index.tsx +47 -0
- package/src/pages/admin/developers/events/detail.tsx +77 -0
- package/src/pages/admin/developers/events/index.tsx +5 -0
- package/src/pages/admin/developers/index.tsx +60 -0
- package/src/pages/admin/developers/logs.tsx +3 -0
- package/src/pages/admin/developers/overview.tsx +3 -0
- package/src/pages/admin/developers/webhooks/detail.tsx +109 -0
- package/src/pages/admin/developers/webhooks/index.tsx +102 -0
- package/src/pages/admin/index.tsx +120 -0
- package/src/pages/admin/overview.tsx +3 -0
- package/src/pages/admin/payments/index.tsx +65 -0
- package/src/pages/admin/payments/intents/detail.tsx +205 -0
- package/src/pages/admin/payments/intents/index.tsx +5 -0
- package/src/pages/admin/payments/links/create.tsx +141 -0
- package/src/pages/admin/payments/links/detail.tsx +318 -0
- package/src/pages/admin/payments/links/index.tsx +167 -0
- package/src/pages/admin/products/coupons/index.tsx +3 -0
- package/src/pages/admin/products/index.tsx +81 -0
- package/src/pages/admin/products/prices/actions.tsx +151 -0
- package/src/pages/admin/products/prices/detail.tsx +203 -0
- package/src/pages/admin/products/prices/list.tsx +95 -0
- package/src/pages/admin/products/pricing-tables.tsx +3 -0
- package/src/pages/admin/products/products/create.tsx +105 -0
- package/src/pages/admin/products/products/detail.tsx +246 -0
- package/src/pages/admin/products/products/index.tsx +154 -0
- package/src/pages/admin/settings/branding.tsx +3 -0
- package/src/pages/admin/settings/business.tsx +3 -0
- package/src/pages/admin/settings/index.tsx +47 -0
- package/src/pages/admin/settings/payment-methods.tsx +80 -0
- package/src/pages/checkout/index.tsx +38 -0
- package/src/pages/checkout/pay.tsx +89 -0
- package/src/pages/customer/index.tsx +93 -0
- package/src/pages/customer/invoice.tsx +147 -0
- package/src/pages/home.tsx +9 -0
- package/tsconfig.api.json +9 -0
- package/tsconfig.eslint.json +7 -0
- package/tsconfig.json +99 -0
- package/tsconfig.types.json +11 -0
- 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,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,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;
|