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,254 @@
|
|
|
1
|
+
import { isValid } from '@arcblock/did';
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import Joi from 'joi';
|
|
4
|
+
import type { WhereOptions } from 'sequelize';
|
|
5
|
+
|
|
6
|
+
import { subscriptionQueue } from '../jobs/subscription';
|
|
7
|
+
import dayjs from '../libs/dayjs';
|
|
8
|
+
import logger from '../libs/logger';
|
|
9
|
+
import { authenticate } from '../libs/security';
|
|
10
|
+
import { expandLineItems } from '../libs/session';
|
|
11
|
+
import { Customer } from '../store/models/customer';
|
|
12
|
+
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
13
|
+
import { PaymentMethod } from '../store/models/payment-method';
|
|
14
|
+
import { Price } from '../store/models/price';
|
|
15
|
+
import { Product } from '../store/models/product';
|
|
16
|
+
import { Subscription } from '../store/models/subscription';
|
|
17
|
+
import { SubscriptionItem } from '../store/models/subscription-item';
|
|
18
|
+
|
|
19
|
+
const router = Router();
|
|
20
|
+
const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
|
|
21
|
+
const authMine = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'], mine: true });
|
|
22
|
+
const authPortal = authenticate<Subscription>({
|
|
23
|
+
component: true,
|
|
24
|
+
roles: ['owner', 'admin'],
|
|
25
|
+
record: {
|
|
26
|
+
// @ts-ignore
|
|
27
|
+
model: Subscription,
|
|
28
|
+
field: 'customer_id',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const schema = Joi.object<{
|
|
33
|
+
page: number;
|
|
34
|
+
size: number;
|
|
35
|
+
status?: string;
|
|
36
|
+
customer_id?: string;
|
|
37
|
+
customer_did?: string;
|
|
38
|
+
livemode?: boolean;
|
|
39
|
+
}>({
|
|
40
|
+
page: Joi.number().integer().min(1).default(1),
|
|
41
|
+
size: Joi.number().integer().min(1).max(100).default(20),
|
|
42
|
+
status: Joi.string().empty(''),
|
|
43
|
+
customer_id: Joi.string().empty(''),
|
|
44
|
+
customer_did: Joi.string().empty(''),
|
|
45
|
+
livemode: Joi.boolean().empty(''),
|
|
46
|
+
});
|
|
47
|
+
router.get('/', authMine, async (req, res) => {
|
|
48
|
+
const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
49
|
+
const where: WhereOptions<Subscription> = {};
|
|
50
|
+
|
|
51
|
+
if (query.status) {
|
|
52
|
+
where.status = query.status
|
|
53
|
+
.split(',')
|
|
54
|
+
.map((x) => x.trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
if (query.customer_id) {
|
|
58
|
+
where.customer_id = query.customer_id;
|
|
59
|
+
}
|
|
60
|
+
if (query.customer_did && isValid(query.customer_did)) {
|
|
61
|
+
const customer = await Customer.findOne({ where: { did: query.customer_did } });
|
|
62
|
+
if (customer) {
|
|
63
|
+
where.customer_id = customer.id;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (typeof query.livemode === 'boolean') {
|
|
67
|
+
where.livemode = query.livemode;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const { rows: list, count } = await Subscription.findAndCountAll({
|
|
72
|
+
where,
|
|
73
|
+
order: [['created_at', 'DESC']],
|
|
74
|
+
offset: (page - 1) * size,
|
|
75
|
+
limit: size,
|
|
76
|
+
include: [
|
|
77
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
78
|
+
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
79
|
+
{ model: SubscriptionItem, as: 'items' },
|
|
80
|
+
{ model: Customer, as: 'customer' },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const products = (await Product.findAll()).map((x) => x.toJSON());
|
|
85
|
+
const prices = (await Price.findAll()).map((x) => x.toJSON());
|
|
86
|
+
const docs = list.map((x) => x.toJSON());
|
|
87
|
+
// @ts-ignore
|
|
88
|
+
docs.forEach((x) => expandLineItems(x.items, products, prices));
|
|
89
|
+
|
|
90
|
+
res.json({ count, list: docs });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(err);
|
|
93
|
+
res.json({ count: 0, list: [] });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// FIXME: exclude some sensitive fields from PaymentMethod
|
|
98
|
+
router.get('/:id', authPortal, async (req, res) => {
|
|
99
|
+
try {
|
|
100
|
+
const doc = await Subscription.findOne({
|
|
101
|
+
where: { id: req.params.id },
|
|
102
|
+
include: [
|
|
103
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
104
|
+
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
105
|
+
{ model: SubscriptionItem, as: 'items' },
|
|
106
|
+
{ model: Customer, as: 'customer' },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (doc) {
|
|
111
|
+
const json = doc.toJSON();
|
|
112
|
+
const products = (await Product.findAll()).map((x) => x.toJSON());
|
|
113
|
+
const prices = (await Price.findAll()).map((x) => x.toJSON());
|
|
114
|
+
// @ts-ignore
|
|
115
|
+
expandLineItems(json.items, products, prices);
|
|
116
|
+
res.json(json);
|
|
117
|
+
} else {
|
|
118
|
+
res.json(null);
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(err);
|
|
122
|
+
res.json(null);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
127
|
+
const doc = await Subscription.findByPk(req.params.id);
|
|
128
|
+
|
|
129
|
+
if (!doc) {
|
|
130
|
+
return res.status(404).json({ error: 'subscription not found' });
|
|
131
|
+
}
|
|
132
|
+
if (doc.status === 'canceled') {
|
|
133
|
+
return res.status(400).json({ error: 'Subscription already canceled' });
|
|
134
|
+
}
|
|
135
|
+
if (doc.cancel_at) {
|
|
136
|
+
return res.status(400).json({ error: 'subscription scheduled to canceled' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { at = 'current_period_end', time, feedback = 'other', comment = '' } = req.body;
|
|
140
|
+
if (at === 'custom' && dayjs(time).unix() < dayjs().unix()) {
|
|
141
|
+
return res.status(400).json({ error: 'cancel at must be a future timestamp' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// update cancel at
|
|
145
|
+
const updates: Partial<Subscription> = {};
|
|
146
|
+
if (req.user?.via === 'portal') {
|
|
147
|
+
updates.cancel_at_period_end = true;
|
|
148
|
+
updates.cancel_at = doc.current_period_end;
|
|
149
|
+
updates.cancelation_details = { reason: 'cancellation_requested', feedback, comment };
|
|
150
|
+
} else if (at === 'now') {
|
|
151
|
+
updates.status = 'canceled';
|
|
152
|
+
updates.cancel_at = dayjs().unix();
|
|
153
|
+
updates.canceled_at = dayjs().unix();
|
|
154
|
+
} else if (at === 'current_period_end') {
|
|
155
|
+
updates.cancel_at_period_end = true;
|
|
156
|
+
updates.cancel_at = doc.current_period_end;
|
|
157
|
+
} else {
|
|
158
|
+
updates.cancel_at = dayjs(time).unix();
|
|
159
|
+
subscriptionQueue.push({
|
|
160
|
+
id: `cancel-${doc.id}`,
|
|
161
|
+
job: { subscriptionId: doc.id, action: 'cancel' },
|
|
162
|
+
runAt: updates.cancel_at,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await doc.update(updates);
|
|
167
|
+
|
|
168
|
+
return res.json(doc);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
router.put('/:id/recover', authPortal, async (req, res) => {
|
|
172
|
+
const doc = await Subscription.findByPk(req.params.id);
|
|
173
|
+
|
|
174
|
+
if (!doc) {
|
|
175
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
176
|
+
}
|
|
177
|
+
if (!doc.cancel_at_period_end) {
|
|
178
|
+
return res.status(400).json({ error: 'Subscription not recoverable from cancellation config' });
|
|
179
|
+
}
|
|
180
|
+
if (doc.status === 'canceled') {
|
|
181
|
+
return res.status(400).json({ error: 'Subscription not recoverable from cancellation' });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await doc.update({ cancel_at: 0, cancel_at_period_end: false });
|
|
185
|
+
|
|
186
|
+
// reschedule jobs
|
|
187
|
+
subscriptionQueue
|
|
188
|
+
.cancel(`cancel-${doc.id}`)
|
|
189
|
+
.then(() => logger.info('subscription cancel job is canceled'))
|
|
190
|
+
.catch((err) => logger.error('subscription cancel job failed to cancel', { error: err }));
|
|
191
|
+
subscriptionQueue.push({
|
|
192
|
+
id: doc.id,
|
|
193
|
+
job: { subscriptionId: doc.id, action: 'cycle' },
|
|
194
|
+
runAt: doc.current_period_end,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return res.json(doc);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
router.put('/:id/pause', auth, async (req, res) => {
|
|
201
|
+
const doc = await Subscription.findByPk(req.params.id);
|
|
202
|
+
|
|
203
|
+
if (!doc) {
|
|
204
|
+
return res.status(404).json({ error: 'subscription not found' });
|
|
205
|
+
}
|
|
206
|
+
if (doc.status === 'paused') {
|
|
207
|
+
return res.status(400).json({ error: 'Subscription already paused' });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { type, resumesAt, behavior } = req.body;
|
|
211
|
+
if (type === 'custom' && dayjs(resumesAt).unix() < dayjs().unix()) {
|
|
212
|
+
return res.status(400).json({ error: 'resumesAt must be a future timestamp' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const timestamp = type === 'custom' ? dayjs(resumesAt).unix() : 0;
|
|
216
|
+
await doc.update({
|
|
217
|
+
status: 'paused',
|
|
218
|
+
pause_collection: {
|
|
219
|
+
resumes_at: timestamp,
|
|
220
|
+
behavior: behavior || 'keep_as_draft',
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (timestamp) {
|
|
225
|
+
subscriptionQueue.push({
|
|
226
|
+
id: `resume-${doc.id}`,
|
|
227
|
+
job: { subscriptionId: doc.id, action: 'resume' },
|
|
228
|
+
runAt: timestamp,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return res.json(doc);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
router.put('/:id/resume', auth, async (req, res) => {
|
|
236
|
+
const doc = await Subscription.findByPk(req.params.id);
|
|
237
|
+
|
|
238
|
+
if (!doc) {
|
|
239
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
240
|
+
}
|
|
241
|
+
if (doc.status !== 'paused') {
|
|
242
|
+
return res.status(400).json({ error: 'Subscription not paused' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await doc.update({ status: 'active', pause_collection: undefined });
|
|
246
|
+
subscriptionQueue
|
|
247
|
+
.cancel(`resume-${doc.id}`)
|
|
248
|
+
.then(() => logger.info('subscription resume job is canceled'))
|
|
249
|
+
.catch((err) => logger.error('subscription resume job failed to cancel', { error: err }));
|
|
250
|
+
|
|
251
|
+
return res.json(doc);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
export default router;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* eslint-disable consistent-return */
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import Joi from 'joi';
|
|
4
|
+
import pick from 'lodash/pick';
|
|
5
|
+
|
|
6
|
+
import dayjs from '../libs/dayjs';
|
|
7
|
+
import { authenticate } from '../libs/security';
|
|
8
|
+
import { formatMetadata } from '../libs/util';
|
|
9
|
+
import { Invoice, Price, Subscription, SubscriptionItem, UsageRecord } from '../store/models';
|
|
10
|
+
|
|
11
|
+
const router = Router();
|
|
12
|
+
const auth = authenticate<UsageRecord>({ component: true, roles: ['owner', 'admin'] });
|
|
13
|
+
|
|
14
|
+
// @link https://stripe.com/docs/api/usage_records/create
|
|
15
|
+
router.post('/', auth, async (req, res) => {
|
|
16
|
+
const raw: Partial<UsageRecord> = pick(req.body, ['timestamp', 'quantity', 'subscription_item_id', 'metadata']);
|
|
17
|
+
if (raw.metadata) {
|
|
18
|
+
raw.metadata = formatMetadata(raw.metadata);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const item = await SubscriptionItem.findByPk(raw.subscription_item_id);
|
|
22
|
+
if (!item) {
|
|
23
|
+
return res.status(400).json({ error: `SubscriptionItem not found: ${raw.subscription_item_id}` });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// @ts-ignore
|
|
27
|
+
if (!raw.timestamp || raw.timestamp === 'now') {
|
|
28
|
+
raw.timestamp = dayjs().unix();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const exist = await UsageRecord.findOne({ where: { timestamp: raw.timestamp } });
|
|
32
|
+
if (exist) {
|
|
33
|
+
if (req.body.action === 'increment') {
|
|
34
|
+
await exist.increment('quantity', { by: raw.quantity });
|
|
35
|
+
} else {
|
|
36
|
+
const subscription = await Subscription.findByPk(item.subscription_id);
|
|
37
|
+
if (subscription?.billing_thresholds) {
|
|
38
|
+
return res
|
|
39
|
+
.status(400)
|
|
40
|
+
.json({ error: 'UsageRecord action must be increment for subscriptions with billing_thresholds' });
|
|
41
|
+
}
|
|
42
|
+
await exist.update({ quantity: raw.quantity });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
raw.livemode = req.livemode;
|
|
47
|
+
const doc = await UsageRecord.create(raw as UsageRecord);
|
|
48
|
+
return res.json(doc);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// @link https://stripe.com/docs/api/usage_records/subscription_item_summary_list
|
|
52
|
+
const schema = Joi.object<{
|
|
53
|
+
page: number;
|
|
54
|
+
size: number;
|
|
55
|
+
subscription_item_id: string;
|
|
56
|
+
livemode?: boolean;
|
|
57
|
+
}>({
|
|
58
|
+
page: Joi.number().integer().min(1).default(1),
|
|
59
|
+
size: Joi.number().integer().min(1).max(100).default(20),
|
|
60
|
+
subscription_item_id: Joi.string().required(),
|
|
61
|
+
livemode: Joi.boolean().empty(''),
|
|
62
|
+
});
|
|
63
|
+
router.get('/', auth, async (req, res) => {
|
|
64
|
+
const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const item = await SubscriptionItem.findByPk(query.subscription_item_id, {
|
|
68
|
+
include: [{ model: Price, as: 'price' }],
|
|
69
|
+
});
|
|
70
|
+
if (!item) {
|
|
71
|
+
return res.status(400).json({ error: `SubscriptionItem not found: ${query.subscription_item_id}` });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// const subscription = await Subscription.findByPk(item.subscription_id);
|
|
75
|
+
// const result = await UsageRecord.getSummary(
|
|
76
|
+
// item.id,
|
|
77
|
+
// subscription?.current_period_start as number,
|
|
78
|
+
// subscription?.current_period_end as number,
|
|
79
|
+
// // @ts-ignore
|
|
80
|
+
// item.price.recurring.aggregate_usage
|
|
81
|
+
// );
|
|
82
|
+
// return res.json({ result });
|
|
83
|
+
|
|
84
|
+
const { rows, count } = await Invoice.findAndCountAll({
|
|
85
|
+
where: { subscription_id: item.subscription_id },
|
|
86
|
+
attributes: ['id', 'period_end', 'period_start'],
|
|
87
|
+
order: [['created_at', 'DESC']],
|
|
88
|
+
offset: (page - 1) * size,
|
|
89
|
+
limit: size,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const list = await Promise.all(
|
|
93
|
+
rows.map(async (invoice) => {
|
|
94
|
+
return {
|
|
95
|
+
livemode: invoice.livemode,
|
|
96
|
+
invoice_id: invoice.id,
|
|
97
|
+
subscription_item_id: item.id,
|
|
98
|
+
total_usage: await UsageRecord.getSummary(
|
|
99
|
+
item.id,
|
|
100
|
+
invoice.period_start,
|
|
101
|
+
invoice.period_end,
|
|
102
|
+
// @ts-ignore
|
|
103
|
+
item.price.recurring.aggregate_usage
|
|
104
|
+
),
|
|
105
|
+
period: {
|
|
106
|
+
start: invoice.period_start,
|
|
107
|
+
end: invoice.period_end,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
res.json({ count, list });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error(err);
|
|
116
|
+
res.json({ count: 0, list: [] });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export default router;
|
|
@@ -0,0 +1,57 @@
|
|
|
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, WebhookAttempt, WebhookEndpoint } from '../store/models';
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
const auth = authenticate<WebhookAttempt>({ component: true, roles: ['owner', 'admin'] });
|
|
10
|
+
|
|
11
|
+
const schema = Joi.object<{
|
|
12
|
+
page: number;
|
|
13
|
+
size: number;
|
|
14
|
+
livemode?: boolean;
|
|
15
|
+
event_id?: string;
|
|
16
|
+
webhook_endpoint_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
|
+
event_id: Joi.string().empty(''),
|
|
22
|
+
webhook_endpoint_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<WebhookAttempt> = {};
|
|
27
|
+
|
|
28
|
+
if (typeof query.livemode === 'boolean') {
|
|
29
|
+
where.livemode = query.livemode;
|
|
30
|
+
}
|
|
31
|
+
if (query.event_id) {
|
|
32
|
+
where.event_id = query.event_id;
|
|
33
|
+
}
|
|
34
|
+
if (query.webhook_endpoint_id) {
|
|
35
|
+
where.webhook_endpoint_id = query.webhook_endpoint_id;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const { rows: list, count } = await WebhookAttempt.findAndCountAll({
|
|
40
|
+
where,
|
|
41
|
+
order: [['created_at', 'DESC']],
|
|
42
|
+
offset: (page - 1) * size,
|
|
43
|
+
limit: size,
|
|
44
|
+
include: [
|
|
45
|
+
{ model: Event, as: 'event' },
|
|
46
|
+
{ model: WebhookEndpoint, as: 'endpoint' },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
res.json({ count, list });
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(err);
|
|
53
|
+
res.json({ count: 0, list: [] });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export default router;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import Joi from 'joi';
|
|
3
|
+
import pick from 'lodash/pick';
|
|
4
|
+
import type { WhereOptions } from 'sequelize';
|
|
5
|
+
|
|
6
|
+
import { authenticate } from '../libs/security';
|
|
7
|
+
import { formatMetadata } from '../libs/util';
|
|
8
|
+
import { WebhookEndpoint } from '../store/models';
|
|
9
|
+
|
|
10
|
+
const router = Router();
|
|
11
|
+
const auth = authenticate<WebhookEndpoint>({ component: true, roles: ['owner', 'admin'] });
|
|
12
|
+
|
|
13
|
+
router.post('/', auth, async (req, res) => {
|
|
14
|
+
const raw: Partial<WebhookEndpoint> = pick(req.body, ['url', 'description', 'metadata', 'status', 'enabled_events']);
|
|
15
|
+
const exist = await WebhookEndpoint.findOne({ where: { url: raw.url } });
|
|
16
|
+
if (exist) {
|
|
17
|
+
return res.status(400).json({ error: 'webhook endpoint with same url already exist' });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
raw.livemode = req.livemode;
|
|
21
|
+
raw.api_version = '2023-09-05';
|
|
22
|
+
raw.status = raw.status || 'enabled';
|
|
23
|
+
if (raw.metadata) {
|
|
24
|
+
raw.metadata = formatMetadata(raw.metadata);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const doc = await WebhookEndpoint.create(raw as WebhookEndpoint);
|
|
28
|
+
return res.json(doc);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const schema = Joi.object<{
|
|
32
|
+
page: number;
|
|
33
|
+
size: number;
|
|
34
|
+
livemode?: boolean;
|
|
35
|
+
}>({
|
|
36
|
+
page: Joi.number().integer().min(1).default(1),
|
|
37
|
+
size: Joi.number().integer().min(1).max(100).default(20),
|
|
38
|
+
livemode: Joi.boolean().empty(''),
|
|
39
|
+
});
|
|
40
|
+
router.get('/', auth, async (req, res) => {
|
|
41
|
+
const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
|
|
42
|
+
const where: WhereOptions<WebhookEndpoint> = {};
|
|
43
|
+
|
|
44
|
+
if (typeof query.livemode === 'boolean') {
|
|
45
|
+
where.livemode = query.livemode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const { rows: list, count } = await WebhookEndpoint.findAndCountAll({
|
|
50
|
+
where,
|
|
51
|
+
order: [['created_at', 'DESC']],
|
|
52
|
+
offset: (page - 1) * size,
|
|
53
|
+
limit: size,
|
|
54
|
+
include: [],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
res.json({ count, list });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(err);
|
|
60
|
+
res.json({ count: 0, list: [] });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
router.get('/:id', auth, async (req, res) => {
|
|
65
|
+
try {
|
|
66
|
+
const doc = await WebhookEndpoint.findOne({
|
|
67
|
+
where: { id: req.params.id },
|
|
68
|
+
include: [],
|
|
69
|
+
});
|
|
70
|
+
res.json(doc);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(err);
|
|
73
|
+
res.json(null);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
router.put('/:id', auth, async (req, res) => {
|
|
78
|
+
const doc = await WebhookEndpoint.findByPk(req.params.id as string);
|
|
79
|
+
|
|
80
|
+
if (!doc) {
|
|
81
|
+
return res.status(404).json({ error: 'webhook endpoint not found' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const updates: Partial<WebhookEndpoint> = pick(req.body, ['description', 'metadata', 'status', 'enabled_events']);
|
|
85
|
+
if (updates.metadata) {
|
|
86
|
+
updates.metadata = formatMetadata(updates.metadata);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
await doc.update(updates);
|
|
90
|
+
|
|
91
|
+
return res.json(doc);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
router.delete('/:id', auth, async (req, res) => {
|
|
95
|
+
const doc = await WebhookEndpoint.findByPk(req.params.id);
|
|
96
|
+
|
|
97
|
+
if (!doc) {
|
|
98
|
+
return res.status(404).json({ error: 'webhook endpoint not found' });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await doc.destroy();
|
|
102
|
+
return res.json(doc);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export default router;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
2
|
+
|
|
3
|
+
import { sequelize } from './sequelize';
|
|
4
|
+
|
|
5
|
+
const umzug = new Umzug({
|
|
6
|
+
migrations: { glob: ['migrations/*.{ts,js}', { cwd: __dirname }] },
|
|
7
|
+
context: sequelize.getQueryInterface(),
|
|
8
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
9
|
+
logger: console,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export default function migrate() {
|
|
13
|
+
return umzug.up();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Migration = typeof umzug._types.migration;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Migration } from '../migrate';
|
|
2
|
+
import models from '../models';
|
|
3
|
+
|
|
4
|
+
export const up: Migration = async ({ context: queryInterface }) => {
|
|
5
|
+
await queryInterface.createTable('checkout_sessions', models.CheckoutSession.GENESIS_ATTRIBUTES);
|
|
6
|
+
await queryInterface.createTable('coupons', models.Coupon.GENESIS_ATTRIBUTES);
|
|
7
|
+
await queryInterface.createTable('customers', models.Customer.GENESIS_ATTRIBUTES);
|
|
8
|
+
await queryInterface.createTable('discounts', models.Discount.GENESIS_ATTRIBUTES);
|
|
9
|
+
await queryInterface.createTable('events', models.Event.GENESIS_ATTRIBUTES);
|
|
10
|
+
await queryInterface.createTable('jobs', models.Job.GENESIS_ATTRIBUTES);
|
|
11
|
+
await queryInterface.createTable('invoices', models.Invoice.GENESIS_ATTRIBUTES);
|
|
12
|
+
await queryInterface.createTable('invoice_items', models.InvoiceItem.GENESIS_ATTRIBUTES);
|
|
13
|
+
await queryInterface.createTable('payment_currencies', models.PaymentCurrency.GENESIS_ATTRIBUTES);
|
|
14
|
+
await queryInterface.createTable('payment_intents', models.PaymentIntent.GENESIS_ATTRIBUTES);
|
|
15
|
+
await queryInterface.createTable('payment_links', models.PaymentLink.GENESIS_ATTRIBUTES);
|
|
16
|
+
await queryInterface.createTable('payment_methods', models.PaymentMethod.GENESIS_ATTRIBUTES);
|
|
17
|
+
await queryInterface.createTable('prices', models.Price.GENESIS_ATTRIBUTES);
|
|
18
|
+
await queryInterface.createTable('products', models.Product.GENESIS_ATTRIBUTES);
|
|
19
|
+
await queryInterface.createTable('promotion_codes', models.PromotionCode.GENESIS_ATTRIBUTES);
|
|
20
|
+
await queryInterface.createTable('setup_intents', models.SetupIntent.GENESIS_ATTRIBUTES);
|
|
21
|
+
await queryInterface.createTable('subscription_items', models.SubscriptionItem.GENESIS_ATTRIBUTES);
|
|
22
|
+
await queryInterface.createTable('subscription_schedules', models.SubscriptionSchedule.GENESIS_ATTRIBUTES);
|
|
23
|
+
await queryInterface.createTable('subscriptions', models.Subscription.GENESIS_ATTRIBUTES);
|
|
24
|
+
await queryInterface.createTable('usage_records', models.UsageRecord.GENESIS_ATTRIBUTES);
|
|
25
|
+
await queryInterface.createTable('webhook_attempts', models.WebhookAttempt.GENESIS_ATTRIBUTES);
|
|
26
|
+
await queryInterface.createTable('webhook_endpoints', models.WebhookEndpoint.GENESIS_ATTRIBUTES);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const down: Migration = async ({ context: queryInterface }) => {
|
|
30
|
+
await queryInterface.dropTable('checkout_sessions');
|
|
31
|
+
await queryInterface.dropTable('coupons');
|
|
32
|
+
await queryInterface.dropTable('customers');
|
|
33
|
+
await queryInterface.dropTable('discounts');
|
|
34
|
+
await queryInterface.dropTable('events');
|
|
35
|
+
await queryInterface.dropTable('jobs');
|
|
36
|
+
await queryInterface.dropTable('invoices');
|
|
37
|
+
await queryInterface.dropTable('invoice_items');
|
|
38
|
+
await queryInterface.dropTable('payment_currencies');
|
|
39
|
+
await queryInterface.dropTable('payment_intents');
|
|
40
|
+
await queryInterface.dropTable('payment_links');
|
|
41
|
+
await queryInterface.dropTable('payment_methods');
|
|
42
|
+
await queryInterface.dropTable('prices');
|
|
43
|
+
await queryInterface.dropTable('products');
|
|
44
|
+
await queryInterface.dropTable('promotion_codes');
|
|
45
|
+
await queryInterface.dropTable('setup_intents');
|
|
46
|
+
await queryInterface.dropTable('subscription_items');
|
|
47
|
+
await queryInterface.dropTable('subscription_schedules');
|
|
48
|
+
await queryInterface.dropTable('subscriptions');
|
|
49
|
+
await queryInterface.dropTable('usage_records');
|
|
50
|
+
await queryInterface.dropTable('webhook_endpoints');
|
|
51
|
+
await queryInterface.dropTable('webhook_attempts');
|
|
52
|
+
};
|