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,410 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
2
|
+
|
|
3
|
+
import { blocklet } from '../../libs/auth';
|
|
4
|
+
import dayjs from '../../libs/dayjs';
|
|
5
|
+
import logger from '../../libs/logger';
|
|
6
|
+
import { TLineItemExpanded, getStatementDescriptor } from '../../libs/session';
|
|
7
|
+
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
8
|
+
import { Customer } from '../../store/models/customer';
|
|
9
|
+
import { Invoice } from '../../store/models/invoice';
|
|
10
|
+
import { InvoiceItem } from '../../store/models/invoice-item';
|
|
11
|
+
import { PaymentCurrency } from '../../store/models/payment-currency';
|
|
12
|
+
import { PaymentIntent } from '../../store/models/payment-intent';
|
|
13
|
+
import { PaymentMethod } from '../../store/models/payment-method';
|
|
14
|
+
import { Price } from '../../store/models/price';
|
|
15
|
+
import { SetupIntent } from '../../store/models/setup-intent';
|
|
16
|
+
import { Subscription } from '../../store/models/subscription';
|
|
17
|
+
import { SubscriptionItem } from '../../store/models/subscription-item';
|
|
18
|
+
|
|
19
|
+
type Result = {
|
|
20
|
+
checkoutSession: CheckoutSession;
|
|
21
|
+
customer: Customer;
|
|
22
|
+
paymentIntent?: PaymentIntent;
|
|
23
|
+
subscription?: Subscription;
|
|
24
|
+
paymentCurrency: PaymentCurrency;
|
|
25
|
+
paymentMethod: PaymentMethod;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export async function ensureCheckoutSession(checkoutSessionId: string) {
|
|
29
|
+
const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
|
|
30
|
+
if (!checkoutSession) {
|
|
31
|
+
throw new Error('Checkout session not found');
|
|
32
|
+
}
|
|
33
|
+
if (checkoutSession.status === 'complete') {
|
|
34
|
+
throw new Error('Checkout session completed');
|
|
35
|
+
}
|
|
36
|
+
if (checkoutSession.status === 'expired') {
|
|
37
|
+
throw new Error('Checkout session expired');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return checkoutSession;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function ensurePaymentIntent(checkoutSessionId: string, userDid: string): Promise<Result> {
|
|
44
|
+
const checkoutSession = await ensureCheckoutSession(checkoutSessionId);
|
|
45
|
+
|
|
46
|
+
let customerId;
|
|
47
|
+
let paymentCurrencyId;
|
|
48
|
+
let paymentMethodId;
|
|
49
|
+
|
|
50
|
+
let paymentIntent;
|
|
51
|
+
if (checkoutSession.payment_intent_id) {
|
|
52
|
+
paymentIntent = await PaymentIntent.findByPk(checkoutSession.payment_intent_id);
|
|
53
|
+
if (!paymentIntent) {
|
|
54
|
+
throw new Error('Payment intent not found');
|
|
55
|
+
}
|
|
56
|
+
if (paymentIntent.status === 'succeeded') {
|
|
57
|
+
throw new Error('Payment intent completed');
|
|
58
|
+
}
|
|
59
|
+
if (paymentIntent.status === 'canceled') {
|
|
60
|
+
throw new Error('Payment intent canceled');
|
|
61
|
+
}
|
|
62
|
+
if (paymentIntent.status === 'processing') {
|
|
63
|
+
throw new Error('Payment intent processing');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
customerId = paymentIntent.customer_id;
|
|
67
|
+
paymentCurrencyId = paymentIntent.currency_id;
|
|
68
|
+
paymentMethodId = paymentIntent.payment_method_id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let subscription;
|
|
72
|
+
if (checkoutSession.subscription_id) {
|
|
73
|
+
subscription = await Subscription.findByPk(checkoutSession.subscription_id);
|
|
74
|
+
if (!subscription) {
|
|
75
|
+
throw new Error('Subscription not found');
|
|
76
|
+
}
|
|
77
|
+
if (subscription.status !== 'incomplete') {
|
|
78
|
+
throw new Error('Subscription is not in incomplete status');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
customerId = subscription.customer_id;
|
|
82
|
+
paymentCurrencyId = subscription.currency_id;
|
|
83
|
+
paymentMethodId = subscription.default_payment_method_id;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let customer;
|
|
87
|
+
if (customerId) {
|
|
88
|
+
customer = await Customer.findByPk(customerId);
|
|
89
|
+
if (!customer) {
|
|
90
|
+
throw new Error('Customer not found');
|
|
91
|
+
}
|
|
92
|
+
const { user } = await blocklet.getUser(userDid, { enableConnectedAccount: true });
|
|
93
|
+
if (customer.did !== user.did) {
|
|
94
|
+
throw new Error('This is not your payment intent');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const [paymentMethod, paymentCurrency] = await Promise.all([
|
|
99
|
+
PaymentMethod.findByPk(paymentMethodId),
|
|
100
|
+
PaymentCurrency.findByPk(paymentCurrencyId),
|
|
101
|
+
]);
|
|
102
|
+
if (!paymentMethod) {
|
|
103
|
+
throw new Error('Payment method not found');
|
|
104
|
+
}
|
|
105
|
+
if (!paymentCurrency) {
|
|
106
|
+
throw new Error('Payment currency not found');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (['arcblock', 'ethereum'].includes(paymentMethod.type) === false) {
|
|
110
|
+
throw new Error(`Payment method ${paymentMethod.type} should not be here`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
checkoutSession.line_items = await Price.expand(checkoutSession.line_items, false);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
checkoutSession,
|
|
117
|
+
paymentIntent,
|
|
118
|
+
customer: customer as Customer,
|
|
119
|
+
subscription,
|
|
120
|
+
paymentMethod,
|
|
121
|
+
paymentCurrency,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function ensureSetupIntent(checkoutSessionId: string, userDid: string) {
|
|
126
|
+
const checkoutSession = await ensureCheckoutSession(checkoutSessionId);
|
|
127
|
+
|
|
128
|
+
let customerId;
|
|
129
|
+
let paymentCurrencyId;
|
|
130
|
+
let paymentMethodId;
|
|
131
|
+
|
|
132
|
+
let setupIntent;
|
|
133
|
+
if (checkoutSession.setup_intent_id) {
|
|
134
|
+
setupIntent = await SetupIntent.findByPk(checkoutSession.setup_intent_id);
|
|
135
|
+
if (!setupIntent) {
|
|
136
|
+
throw new Error('Payment intent not found');
|
|
137
|
+
}
|
|
138
|
+
if (setupIntent.status === 'succeeded') {
|
|
139
|
+
throw new Error('Payment intent completed');
|
|
140
|
+
}
|
|
141
|
+
if (setupIntent.status === 'canceled') {
|
|
142
|
+
throw new Error('Payment intent canceled');
|
|
143
|
+
}
|
|
144
|
+
if (setupIntent.status === 'processing') {
|
|
145
|
+
throw new Error('Payment intent processing');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
customerId = setupIntent.customer_id;
|
|
149
|
+
paymentCurrencyId = setupIntent.currency_id;
|
|
150
|
+
paymentMethodId = setupIntent.payment_method_id;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let subscription;
|
|
154
|
+
if (checkoutSession.subscription_id) {
|
|
155
|
+
subscription = await Subscription.findByPk(checkoutSession.subscription_id);
|
|
156
|
+
if (!subscription) {
|
|
157
|
+
throw new Error('Subscription not found');
|
|
158
|
+
}
|
|
159
|
+
if (subscription.status !== 'incomplete') {
|
|
160
|
+
throw new Error('Subscription is not in incomplete status');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
customerId = subscription.customer_id;
|
|
164
|
+
paymentCurrencyId = subscription.currency_id;
|
|
165
|
+
paymentMethodId = subscription.default_payment_method_id;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let customer;
|
|
169
|
+
if (customerId) {
|
|
170
|
+
customer = await Customer.findByPk(customerId);
|
|
171
|
+
if (!customer) {
|
|
172
|
+
throw new Error('Customer not found');
|
|
173
|
+
}
|
|
174
|
+
const { user } = await blocklet.getUser(userDid, { enableConnectedAccount: true });
|
|
175
|
+
if (customer.did !== user.did) {
|
|
176
|
+
throw new Error('This is not your payment intent');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const [paymentMethod, paymentCurrency] = await Promise.all([
|
|
181
|
+
PaymentMethod.findByPk(paymentMethodId),
|
|
182
|
+
PaymentCurrency.findByPk(paymentCurrencyId),
|
|
183
|
+
]);
|
|
184
|
+
if (!paymentMethod) {
|
|
185
|
+
throw new Error('Payment method not found');
|
|
186
|
+
}
|
|
187
|
+
if (!paymentCurrency) {
|
|
188
|
+
throw new Error('Payment currency not found');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (['arcblock', 'ethereum'].includes(paymentMethod.type) === false) {
|
|
192
|
+
throw new Error(`Payment method ${paymentMethod.type} should not be here`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
checkoutSession.line_items = await Price.expand(checkoutSession.line_items, false);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
checkoutSession,
|
|
199
|
+
setupIntent: setupIntent as SetupIntent,
|
|
200
|
+
customer: customer as Customer,
|
|
201
|
+
subscription,
|
|
202
|
+
paymentMethod,
|
|
203
|
+
paymentCurrency,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type Args = {
|
|
208
|
+
checkoutSession: CheckoutSession;
|
|
209
|
+
customer: Customer;
|
|
210
|
+
paymentIntent?: PaymentIntent;
|
|
211
|
+
subscription?: Subscription;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export async function ensureInvoiceForCheckout({
|
|
215
|
+
checkoutSession,
|
|
216
|
+
customer,
|
|
217
|
+
paymentIntent,
|
|
218
|
+
subscription,
|
|
219
|
+
}: Args): Promise<{ invoice: Invoice | null; items: InvoiceItem[] }> {
|
|
220
|
+
// invoices are optional when checkout session is in payment mode
|
|
221
|
+
if (checkoutSession.mode === 'payment' && !checkoutSession.invoice_creation?.enabled) {
|
|
222
|
+
logger.warn('Invoice creation disabled for payment mode');
|
|
223
|
+
return { invoice: null, items: [] };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Do not create invoice if it's already created
|
|
227
|
+
if (checkoutSession.invoice_id) {
|
|
228
|
+
logger.warn(`Invoice already created for checkout session ${checkoutSession.id}: ${checkoutSession.invoice_id}`);
|
|
229
|
+
return {
|
|
230
|
+
invoice: await Invoice.findByPk(checkoutSession.invoice_id),
|
|
231
|
+
items: await InvoiceItem.findAll({ where: { invoice_id: checkoutSession.invoice_id } }),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const invoice = await Invoice.create({
|
|
236
|
+
livemode: checkoutSession.livemode,
|
|
237
|
+
number: await customer.getInvoiceNumber(),
|
|
238
|
+
description: paymentIntent?.description || 'Subscription creation',
|
|
239
|
+
statement_descriptor: paymentIntent?.statement_descriptor || getStatementDescriptor(checkoutSession.line_items),
|
|
240
|
+
period_start: subscription?.current_period_start ?? 0,
|
|
241
|
+
period_end: subscription?.current_period_end ?? 0,
|
|
242
|
+
|
|
243
|
+
auto_advance: !paymentIntent,
|
|
244
|
+
paid: false,
|
|
245
|
+
paid_out_of_band: false,
|
|
246
|
+
|
|
247
|
+
status: 'open',
|
|
248
|
+
collection_method: 'charge_automatically',
|
|
249
|
+
billing_reason: subscription ? 'subscription_create' : 'manual',
|
|
250
|
+
|
|
251
|
+
currency_id: checkoutSession.currency_id,
|
|
252
|
+
customer_id: customer.id,
|
|
253
|
+
payment_intent_id: paymentIntent?.id,
|
|
254
|
+
subscription_id: subscription?.id,
|
|
255
|
+
checkout_session_id: checkoutSession.id,
|
|
256
|
+
|
|
257
|
+
subtotal: checkoutSession.amount_subtotal,
|
|
258
|
+
subtotal_excluding_tax: checkoutSession.amount_subtotal,
|
|
259
|
+
tax: '0',
|
|
260
|
+
total: checkoutSession.amount_total,
|
|
261
|
+
amount_due: checkoutSession.amount_total,
|
|
262
|
+
amount_paid: '0',
|
|
263
|
+
amount_remaining: checkoutSession.amount_total,
|
|
264
|
+
amount_shipping: '0',
|
|
265
|
+
|
|
266
|
+
starting_balance: '0',
|
|
267
|
+
ending_balance: '0',
|
|
268
|
+
|
|
269
|
+
attempt_count: 0,
|
|
270
|
+
attempted: false,
|
|
271
|
+
// next_payment_attempt: undefined,
|
|
272
|
+
|
|
273
|
+
custom_fields: [],
|
|
274
|
+
customer_address: customer.address,
|
|
275
|
+
customer_email: customer.email,
|
|
276
|
+
customer_name: customer.name,
|
|
277
|
+
customer_phone: customer.phone,
|
|
278
|
+
|
|
279
|
+
discounts: [],
|
|
280
|
+
total_discount_amounts: [],
|
|
281
|
+
|
|
282
|
+
due_date: undefined, // The date on which payment for this invoice is due
|
|
283
|
+
effective_at: dayjs().unix(), // The date when this invoice is in effect
|
|
284
|
+
status_transitions: {
|
|
285
|
+
finalized_at: dayjs().unix(),
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
payment_settings: subscription?.payment_settings,
|
|
289
|
+
default_payment_method_id: (subscription?.default_payment_method_id || paymentIntent?.payment_method_id) as string,
|
|
290
|
+
|
|
291
|
+
account_country: '',
|
|
292
|
+
account_name: '',
|
|
293
|
+
metadata: {},
|
|
294
|
+
});
|
|
295
|
+
logger.info(`Invoice created for checkoutSession ${checkoutSession.id}: ${invoice.id}`);
|
|
296
|
+
|
|
297
|
+
// persist invoice id
|
|
298
|
+
await checkoutSession.update({ invoice_id: invoice.id });
|
|
299
|
+
if (paymentIntent) {
|
|
300
|
+
await paymentIntent.update({ invoice_id: invoice.id });
|
|
301
|
+
}
|
|
302
|
+
if (subscription) {
|
|
303
|
+
await subscription.update({ latest_invoice_id: invoice.id });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// create invoice items: for those require payment this time
|
|
307
|
+
const subscriptionItems = subscription
|
|
308
|
+
? await SubscriptionItem.findAll({ where: { subscription_id: subscription?.id } })
|
|
309
|
+
: [];
|
|
310
|
+
const lineItems = await Price.expand(checkoutSession.line_items, true);
|
|
311
|
+
|
|
312
|
+
const trailing = !!checkoutSession.subscription_data?.trial_period_days;
|
|
313
|
+
const getLineSetup = (x: TLineItemExpanded) => {
|
|
314
|
+
if (x.price.type === 'recurring' && trailing) {
|
|
315
|
+
return {
|
|
316
|
+
amount: '0',
|
|
317
|
+
// @ts-ignore
|
|
318
|
+
description: trailing ? `${x.price.product.name} (trailing)` : x.price.product.name,
|
|
319
|
+
period: {
|
|
320
|
+
start: subscription?.current_period_start as number,
|
|
321
|
+
end: subscription?.current_period_end as number,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
amount: new BN(x.price.unit_amount).mul(new BN(x.quantity)).toString(),
|
|
328
|
+
// @ts-ignore
|
|
329
|
+
description: x.price.product.name,
|
|
330
|
+
period: undefined,
|
|
331
|
+
};
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const items = await Promise.all(
|
|
335
|
+
lineItems.map((x: TLineItemExpanded) => {
|
|
336
|
+
const setup = getLineSetup(x);
|
|
337
|
+
let { quantity } = x;
|
|
338
|
+
if (x.price.type === 'recurring') {
|
|
339
|
+
if (x.price.recurring?.usage_type === 'metered') {
|
|
340
|
+
quantity = 0;
|
|
341
|
+
}
|
|
342
|
+
if (trailing) {
|
|
343
|
+
quantity = 0;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return InvoiceItem.create({
|
|
348
|
+
livemode: checkoutSession.livemode,
|
|
349
|
+
amount: setup.amount,
|
|
350
|
+
quantity,
|
|
351
|
+
description: setup.description,
|
|
352
|
+
period: setup.period,
|
|
353
|
+
currency_id: checkoutSession.currency_id,
|
|
354
|
+
customer_id: customer.id,
|
|
355
|
+
price_id: x.price_id,
|
|
356
|
+
invoice_id: invoice.id,
|
|
357
|
+
subscription_id: subscription?.id,
|
|
358
|
+
subscription_item_id: subscriptionItems.find((si) => si.price_id === x.price_id)?.id,
|
|
359
|
+
discountable: false,
|
|
360
|
+
discounts: [],
|
|
361
|
+
discount_amounts: [],
|
|
362
|
+
proration: false,
|
|
363
|
+
proration_details: {},
|
|
364
|
+
metadata: {},
|
|
365
|
+
});
|
|
366
|
+
})
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
return { invoice, items };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function ensureInvoiceForCollect(invoiceId: string) {
|
|
373
|
+
const invoice = await Invoice.findByPk(invoiceId);
|
|
374
|
+
if (!invoice) {
|
|
375
|
+
throw new Error('Invoice not found');
|
|
376
|
+
}
|
|
377
|
+
if (invoice.status === 'paid') {
|
|
378
|
+
throw new Error('Invoice already paid');
|
|
379
|
+
}
|
|
380
|
+
if (invoice.status === 'void') {
|
|
381
|
+
throw new Error('Invoice already void');
|
|
382
|
+
}
|
|
383
|
+
if (invoice.status === 'draft') {
|
|
384
|
+
throw new Error('Invoice is draft');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
388
|
+
if (!paymentIntent) {
|
|
389
|
+
throw new Error('Payment intent not found for invoice');
|
|
390
|
+
}
|
|
391
|
+
if (paymentIntent.status === 'canceled') {
|
|
392
|
+
throw new Error('Payment intent already canceled');
|
|
393
|
+
}
|
|
394
|
+
if (paymentIntent.status === 'succeeded') {
|
|
395
|
+
throw new Error('Payment intent already succeeded');
|
|
396
|
+
}
|
|
397
|
+
if (paymentIntent.status === 'processing') {
|
|
398
|
+
throw new Error('Payment intent processing');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const paymentCurrency = await PaymentCurrency.findByPk(paymentIntent.currency_id);
|
|
402
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentIntent.payment_method_id);
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
invoice,
|
|
406
|
+
paymentIntent,
|
|
407
|
+
paymentCurrency: paymentCurrency as PaymentCurrency,
|
|
408
|
+
paymentMethod: paymentMethod as PaymentMethod,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { toTypeInfo } from '@arcblock/did';
|
|
2
|
+
import { toDelegateAddress } from '@arcblock/did-util';
|
|
3
|
+
import type { Transaction } from '@ocap/client';
|
|
4
|
+
import { BN } from '@ocap/util';
|
|
5
|
+
import { fromPublicKey } from '@ocap/wallet';
|
|
6
|
+
|
|
7
|
+
import { invoiceQueue } from '../../jobs/invoice';
|
|
8
|
+
import { subscriptionQueue } from '../../jobs/subscription';
|
|
9
|
+
import type { CallbackArgs } from '../../libs/auth';
|
|
10
|
+
import { wallet } from '../../libs/auth';
|
|
11
|
+
import { getClient } from '../../libs/chain/arcblock';
|
|
12
|
+
import { ensureInvoiceForCheckout, ensurePaymentIntent } from './shared';
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
action: 'subscription',
|
|
16
|
+
onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
|
|
17
|
+
const { checkoutSessionId } = extraParams;
|
|
18
|
+
const { checkoutSession, paymentMethod, paymentCurrency, subscription } = await ensurePaymentIntent(
|
|
19
|
+
checkoutSessionId,
|
|
20
|
+
userDid
|
|
21
|
+
);
|
|
22
|
+
if (!subscription) {
|
|
23
|
+
throw new Error('Subscription for checkoutSession not found');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// TODO: support multiple chain and multiple currency
|
|
27
|
+
if (paymentMethod.type === 'arcblock') {
|
|
28
|
+
if (checkoutSession.amount_total > '0') {
|
|
29
|
+
const client = getClient(paymentMethod.settings.arcblock?.api_host as string);
|
|
30
|
+
const result = await client.getAccountTokens({ address: userDid, token: paymentCurrency.contract });
|
|
31
|
+
const balance = result.tokens[0]?.balance || '0';
|
|
32
|
+
if (new BN(balance).lt(new BN(checkoutSession.amount_total))) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Your account ${userDid} does not have enough ${paymentCurrency.symbol} to complete this subscription`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
signature: {
|
|
41
|
+
type: 'DelegateTx',
|
|
42
|
+
description: `Sign the delegation to complete subscription ${subscription.id}`,
|
|
43
|
+
wallet: fromPublicKey(userPk, toTypeInfo(userDid)),
|
|
44
|
+
data: {
|
|
45
|
+
itx: {
|
|
46
|
+
address: toDelegateAddress(userDid, wallet.address),
|
|
47
|
+
to: wallet.address,
|
|
48
|
+
// FIXME: we need to enforce which token can be transferred, and how much, and at what interval on chain
|
|
49
|
+
ops: [{ typeUrl: 'fg:t:transfer_v2', rules: [] }],
|
|
50
|
+
data: {
|
|
51
|
+
type: 'json',
|
|
52
|
+
// @ts-ignore
|
|
53
|
+
value: {
|
|
54
|
+
appId: wallet.address,
|
|
55
|
+
subscriptionId: subscription.id,
|
|
56
|
+
checkoutSessionId,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
chainInfo: {
|
|
62
|
+
host: paymentMethod.settings?.arcblock?.api_host as string,
|
|
63
|
+
id: paymentMethod.settings?.arcblock?.chain_id as string,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
70
|
+
},
|
|
71
|
+
onAuth: async ({ userDid, userPk, claims, request, extraParams }: CallbackArgs) => {
|
|
72
|
+
const { checkoutSessionId } = extraParams;
|
|
73
|
+
const { checkoutSession, customer, paymentMethod, subscription } = await ensurePaymentIntent(
|
|
74
|
+
checkoutSessionId,
|
|
75
|
+
userDid
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (!subscription) {
|
|
79
|
+
throw new Error('Subscription for checkoutSession not found');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (paymentMethod.type === 'arcblock') {
|
|
83
|
+
await subscription.update({
|
|
84
|
+
payment_settings: {
|
|
85
|
+
payment_method_types: ['arcblock'],
|
|
86
|
+
payment_method_options: {
|
|
87
|
+
arcblock: { payer: userDid },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, subscription });
|
|
93
|
+
|
|
94
|
+
const client = getClient(paymentMethod.settings.arcblock?.api_host as string);
|
|
95
|
+
const claim = claims.find((x) => x.type === 'signature');
|
|
96
|
+
|
|
97
|
+
// execute the delegate tx
|
|
98
|
+
const tx: Partial<Transaction> = client.decodeTx(claim.origin);
|
|
99
|
+
const txHash = await client.sendDelegateTx(
|
|
100
|
+
// @ts-ignore
|
|
101
|
+
{ tx, wallet: fromPublicKey(userPk, toTypeInfo(userDid)), signature: claim.sig },
|
|
102
|
+
{ headers: client.pickGasPayerHeaders(request) }
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
await subscription.update({
|
|
106
|
+
payment_details: {
|
|
107
|
+
tx_hash: txHash,
|
|
108
|
+
payer: userDid,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// FIXME: handle error on the invoice payment job
|
|
113
|
+
if (invoice) {
|
|
114
|
+
invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
|
|
115
|
+
}
|
|
116
|
+
subscriptionQueue.push({
|
|
117
|
+
id: subscription.id,
|
|
118
|
+
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
119
|
+
// our next invoice should be generated at the end of current period, either trailing or normal
|
|
120
|
+
runAt: subscription.trail_end || subscription.current_period_end,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return { hash: txHash };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { user } from '@blocklet/sdk/lib/middlewares';
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import Joi from 'joi';
|
|
4
|
+
import type { WhereOptions } from 'sequelize';
|
|
5
|
+
|
|
6
|
+
import { authenticate } from '../libs/security';
|
|
7
|
+
import { Customer } from '../store/models/customer';
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
const auth = authenticate<Customer>({ component: true, roles: ['owner', 'admin'] });
|
|
11
|
+
|
|
12
|
+
const schema = Joi.object<{
|
|
13
|
+
page: number;
|
|
14
|
+
size: number;
|
|
15
|
+
livemode?: boolean;
|
|
16
|
+
}>({
|
|
17
|
+
page: Joi.number().integer().min(1).default(1),
|
|
18
|
+
size: Joi.number().integer().min(1).max(100).default(20),
|
|
19
|
+
livemode: Joi.boolean().empty(''),
|
|
20
|
+
});
|
|
21
|
+
router.get('/', auth, async (req, res) => {
|
|
22
|
+
const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
23
|
+
const where: WhereOptions<Customer> = {};
|
|
24
|
+
|
|
25
|
+
if (typeof query.livemode === 'boolean') {
|
|
26
|
+
where.livemode = query.livemode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { rows: list, count } = await Customer.findAndCountAll({
|
|
31
|
+
where,
|
|
32
|
+
order: [['created_at', 'DESC']],
|
|
33
|
+
offset: (page - 1) * size,
|
|
34
|
+
limit: size,
|
|
35
|
+
include: [],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
res.json({ count, list });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error(err);
|
|
41
|
+
res.json({ count: 0, list: [] });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line consistent-return
|
|
46
|
+
router.get('/me', user(), async (req, res) => {
|
|
47
|
+
if (!req.user) {
|
|
48
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const doc = await Customer.findByPkOrDid(req.user.did as string);
|
|
53
|
+
res.json(doc);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error(err);
|
|
56
|
+
res.json(null);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
router.get('/:id', auth, async (req, res) => {
|
|
61
|
+
try {
|
|
62
|
+
const doc = await Customer.findByPkOrDid(req.params.id as string);
|
|
63
|
+
res.json(doc);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(err);
|
|
66
|
+
res.json(null);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export default router;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import Joi from 'joi';
|
|
3
|
+
import type { WhereOptions } from 'sequelize';
|
|
4
|
+
|
|
5
|
+
import { authenticate } from '../libs/security';
|
|
6
|
+
import { Event } from '../store/models/event';
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
const auth = authenticate<Event>({ component: true, roles: ['owner', 'admin'] });
|
|
10
|
+
|
|
11
|
+
const schema = Joi.object<{
|
|
12
|
+
page: number;
|
|
13
|
+
size: number;
|
|
14
|
+
livemode?: boolean;
|
|
15
|
+
type?: string;
|
|
16
|
+
object_id?: string;
|
|
17
|
+
}>({
|
|
18
|
+
page: Joi.number().integer().min(1).default(1),
|
|
19
|
+
size: Joi.number().integer().min(1).max(100).default(20),
|
|
20
|
+
livemode: Joi.boolean().empty(''),
|
|
21
|
+
type: Joi.string().empty(''),
|
|
22
|
+
object_id: Joi.string().empty(''),
|
|
23
|
+
});
|
|
24
|
+
router.get('/', auth, async (req, res) => {
|
|
25
|
+
const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
26
|
+
const where: WhereOptions<Event> = {};
|
|
27
|
+
|
|
28
|
+
if (query.type) {
|
|
29
|
+
where.type = query.type
|
|
30
|
+
.split(',')
|
|
31
|
+
.map((x) => x.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
if (query.object_id) {
|
|
35
|
+
where.object_id = query.object_id;
|
|
36
|
+
}
|
|
37
|
+
if (typeof query.livemode === 'boolean') {
|
|
38
|
+
where.livemode = query.livemode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const { rows: list, count } = await Event.findAndCountAll({
|
|
43
|
+
where,
|
|
44
|
+
attributes: { exclude: ['data', 'request'] },
|
|
45
|
+
order: [['created_at', 'DESC']],
|
|
46
|
+
offset: (page - 1) * size,
|
|
47
|
+
limit: size,
|
|
48
|
+
include: [],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
res.json({ count, list });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(err);
|
|
54
|
+
res.json({ count: 0, list: [] });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
router.get('/:id', auth, async (req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
const doc = await Event.findOne({
|
|
61
|
+
where: { id: req.params.id },
|
|
62
|
+
include: [],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (doc) {
|
|
66
|
+
res.json(doc);
|
|
67
|
+
} else {
|
|
68
|
+
res.json(null);
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error(err);
|
|
72
|
+
res.json(null);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export default router;
|