payment-kit 1.13.24 → 1.13.25
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/README.md +4 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/payment-intent.ts +2 -2
- package/api/src/integrations/stripe/handlers/setup-intent.ts +1 -1
- package/api/src/integrations/stripe/handlers/subscription.ts +2 -2
- package/api/src/routes/checkout-sessions.ts +3 -3
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/payment-links.ts +0 -1
- package/api/src/routes/pricing-table.ts +342 -0
- package/api/src/store/migrations/20231017-pricing-table.ts +10 -0
- package/api/src/store/models/index.ts +14 -1
- package/api/src/store/models/pricing-table.ts +107 -0
- package/api/src/store/models/types.ts +53 -0
- package/blocklet.yml +1 -1
- package/package.json +3 -3
- package/src/app.tsx +1 -1
- package/src/components/payment-link/actions.tsx +1 -1
- package/src/components/payment-link/chrome.tsx +5 -3
- package/src/components/payment-link/preview.tsx +8 -5
- package/src/components/payment-link/rename.tsx +3 -3
- package/src/components/pricing-table/actions.tsx +126 -0
- package/src/components/pricing-table/customer-settings.tsx +17 -0
- package/src/components/pricing-table/payment-settings.tsx +179 -0
- package/src/components/pricing-table/preview.tsx +34 -0
- package/src/components/pricing-table/price-item.tsx +64 -0
- package/src/components/pricing-table/product-item.tsx +86 -0
- package/src/components/pricing-table/product-settings.tsx +195 -0
- package/src/components/pricing-table/rename.tsx +67 -0
- package/src/libs/util.ts +43 -0
- package/src/locales/en.tsx +26 -0
- package/src/pages/admin/payments/links/create.tsx +1 -1
- package/src/pages/admin/products/index.tsx +8 -13
- package/src/pages/admin/products/pricing-tables/create.tsx +140 -0
- package/src/pages/admin/products/pricing-tables/detail.tsx +237 -0
- package/src/pages/admin/products/pricing-tables/index.tsx +154 -0
- package/src/pages/checkout/index.tsx +2 -1
- package/src/pages/checkout/pricing-table.tsx +195 -0
- package/src/pages/admin/products/pricing-tables.tsx +0 -3
package/README.md
CHANGED
|
@@ -15,3 +15,7 @@ The decentralized stripe for blocklet platform.
|
|
|
15
15
|
1. Install and login with instructions from: https://stripe.com/docs/stripe-cli
|
|
16
16
|
2. Start your local payment-kit server, get it's port
|
|
17
17
|
3. Run `stripe listen --forward-to http://127.0.0.1:8188/api/integrations/stripe/webhook --log-level=debug --latest`
|
|
18
|
+
|
|
19
|
+
### Test Stripe
|
|
20
|
+
|
|
21
|
+
Invoices for subscriptions are not finalized automatically. You can use stripe postman collection to finalize it and then confirm the payment.
|
|
@@ -219,10 +219,10 @@ export async function handleInvoiceEvent(event: TEventExpanded, client: Stripe)
|
|
|
219
219
|
try {
|
|
220
220
|
await waitForStripeInvoiceMirrored(event.data.object.id);
|
|
221
221
|
} catch (err) {
|
|
222
|
-
logger.error('wait for stripe invoice mirror error', {
|
|
222
|
+
logger.error('wait for stripe invoice mirror error', { id: event.id, type: event.type, error: err });
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
logger.warn('local invoice id not found in strip event', {
|
|
225
|
+
logger.warn('local invoice id not found in strip event', { id: event.id, type: event.type });
|
|
226
226
|
return;
|
|
227
227
|
}
|
|
228
228
|
}
|
|
@@ -123,10 +123,10 @@ export async function handlePaymentIntentEvent(event: TEventExpanded, client: St
|
|
|
123
123
|
try {
|
|
124
124
|
await waitForStripePaymentMirrored(event.data.object.id);
|
|
125
125
|
} catch (err) {
|
|
126
|
-
logger.error('wait for stripe payment intent mirror error', {
|
|
126
|
+
logger.error('wait for stripe payment intent mirror error', { id: event.id, type: event.type, error: err });
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
logger.warn('local payment intent id not found in strip event', {
|
|
129
|
+
logger.warn('local payment intent id not found in strip event', { id: event.id, type: event.type });
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -11,7 +11,7 @@ export async function handleSetupIntentEvent(event: TEventExpanded, _: Stripe) {
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
if (!subscription) {
|
|
14
|
-
logger.warn('local subscription not found for setup intent', { stripeIntentId });
|
|
14
|
+
logger.warn('local subscription not found for setup intent', { id: event.id, type: event.type, stripeIntentId });
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -26,12 +26,12 @@ export async function handleStripeSubscriptionSucceed(subscription: Subscription
|
|
|
26
26
|
export async function handleSubscriptionEvent(event: TEventExpanded, _: Stripe) {
|
|
27
27
|
const localSubscriptionId = event.data.object.metadata?.id;
|
|
28
28
|
if (!localSubscriptionId) {
|
|
29
|
-
logger.warn('local subscription id not found in strip event', {
|
|
29
|
+
logger.warn('local subscription id not found in strip event', { id: event.id, type: event.type });
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
32
|
const subscription = await Subscription.findByPk(localSubscriptionId);
|
|
33
33
|
if (!subscription) {
|
|
34
|
-
logger.warn('local subscription not found', { localSubscriptionId });
|
|
34
|
+
logger.warn('local subscription not found', { id: event.id, type: event.type, localSubscriptionId });
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -61,7 +61,7 @@ const getPaymentTypes = async (items: any[]) => {
|
|
|
61
61
|
return methods.map((x) => x.type);
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
const
|
|
64
|
+
export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = true) => {
|
|
65
65
|
const raw: Partial<CheckoutSession> = Object.assign(
|
|
66
66
|
{
|
|
67
67
|
allow_promotion_codes: false,
|
|
@@ -172,7 +172,7 @@ const formatBeforeSave = async (payload: any, throwOnEmptyItems = true) => {
|
|
|
172
172
|
|
|
173
173
|
// create checkout session
|
|
174
174
|
router.post('/', auth, async (req, res) => {
|
|
175
|
-
const raw: Partial<CheckoutSession> = await
|
|
175
|
+
const raw: Partial<CheckoutSession> = await formatCheckoutSession(req.body);
|
|
176
176
|
raw.livemode = !!req.livemode;
|
|
177
177
|
raw.created_via = req.user?.via as string;
|
|
178
178
|
|
|
@@ -197,7 +197,7 @@ router.post('/start/:id', user, async (req, res) => {
|
|
|
197
197
|
|
|
198
198
|
const items = await Price.expand(link.line_items);
|
|
199
199
|
|
|
200
|
-
const raw: Partial<CheckoutSession> = await
|
|
200
|
+
const raw: Partial<CheckoutSession> = await formatCheckoutSession(link, false);
|
|
201
201
|
raw.livemode = link.livemode;
|
|
202
202
|
raw.created_via = 'portal';
|
|
203
203
|
raw.currency_id = link.currency_id || req.currency.id;
|
package/api/src/routes/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import paymentIntents from './payment-intents';
|
|
|
11
11
|
import paymentLinks from './payment-links';
|
|
12
12
|
import paymentMethods from './payment-methods';
|
|
13
13
|
import prices from './prices';
|
|
14
|
+
import pricingTables from './pricing-table';
|
|
14
15
|
import products from './products';
|
|
15
16
|
import settings from './settings';
|
|
16
17
|
import subscriptionItems from './subscription-items';
|
|
@@ -50,6 +51,7 @@ router.use('/payment-links', paymentLinks);
|
|
|
50
51
|
router.use('/payment-methods', paymentMethods);
|
|
51
52
|
router.use('/payment-currencies', paymentCurrencies);
|
|
52
53
|
router.use('/prices', prices);
|
|
54
|
+
router.use('/pricing-tables', pricingTables);
|
|
53
55
|
router.use('/products', products);
|
|
54
56
|
router.use('/settings', settings);
|
|
55
57
|
router.use('/subscription-items', subscriptionItems);
|
|
@@ -244,7 +244,6 @@ router.post('/stash', auth, async (req, res) => {
|
|
|
244
244
|
raw.active = true;
|
|
245
245
|
raw.livemode = !!req.livemode;
|
|
246
246
|
raw.created_via = req.user?.via;
|
|
247
|
-
raw.created_via = 'portal';
|
|
248
247
|
raw.currency_id = raw.currency_id || req.currency.id;
|
|
249
248
|
|
|
250
249
|
let doc = await PaymentLink.findByPk(raw.id);
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { getUrl } from '@blocklet/sdk/lib/component';
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import Joi from 'joi';
|
|
4
|
+
import pick from 'lodash/pick';
|
|
5
|
+
import uniq from 'lodash/uniq';
|
|
6
|
+
import { Op, WhereOptions } from 'sequelize';
|
|
7
|
+
|
|
8
|
+
import { authenticate } from '../libs/security';
|
|
9
|
+
import { isLineItemCurrencyAligned } from '../libs/session';
|
|
10
|
+
import { formatMetadata } from '../libs/util';
|
|
11
|
+
import { CheckoutSession } from '../store/models/checkout-session';
|
|
12
|
+
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
13
|
+
import { Price } from '../store/models/price';
|
|
14
|
+
import { PricingTable } from '../store/models/pricing-table';
|
|
15
|
+
import { Product } from '../store/models/product';
|
|
16
|
+
import { formatCheckoutSession } from './checkout-sessions';
|
|
17
|
+
|
|
18
|
+
const router = Router();
|
|
19
|
+
const auth = authenticate<PricingTable>({ component: true, roles: ['owner', 'admin'] });
|
|
20
|
+
|
|
21
|
+
const formatPricingTable = (payload: any) => {
|
|
22
|
+
const raw: Partial<PricingTable> = Object.assign(
|
|
23
|
+
{
|
|
24
|
+
branding_settings: {
|
|
25
|
+
background_color: '#ffffff',
|
|
26
|
+
border_style: 'default',
|
|
27
|
+
button_color: '#0074d4',
|
|
28
|
+
font_family: 'default',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
pick(payload, ['name', 'items', 'metadata', 'brand_settings'])
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
raw.items = raw.items?.map((x) => {
|
|
35
|
+
const item = Object.assign(
|
|
36
|
+
{
|
|
37
|
+
adjustable_quantity: {
|
|
38
|
+
enabled: false,
|
|
39
|
+
maximum: 1,
|
|
40
|
+
minimum: 0,
|
|
41
|
+
},
|
|
42
|
+
after_completion: {
|
|
43
|
+
type: 'hosted_confirmation',
|
|
44
|
+
hosted_confirmation: {
|
|
45
|
+
custom_message: '',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
allow_promotion_codes: false,
|
|
49
|
+
customer_creation: 'always',
|
|
50
|
+
consent_collection: {
|
|
51
|
+
promotions: 'none',
|
|
52
|
+
terms_of_service: 'none',
|
|
53
|
+
},
|
|
54
|
+
invoice_creation: {
|
|
55
|
+
enabled: true,
|
|
56
|
+
},
|
|
57
|
+
phone_number_collection: {
|
|
58
|
+
enabled: false,
|
|
59
|
+
},
|
|
60
|
+
billing_address_collection: 'auto',
|
|
61
|
+
subscription_data: {
|
|
62
|
+
description: '',
|
|
63
|
+
trial_period_days: 0,
|
|
64
|
+
},
|
|
65
|
+
submit_type: 'auto',
|
|
66
|
+
},
|
|
67
|
+
pick(x, [
|
|
68
|
+
'product_id',
|
|
69
|
+
'price_id',
|
|
70
|
+
'is_highlight',
|
|
71
|
+
'highlight_text',
|
|
72
|
+
'adjustable_quantity',
|
|
73
|
+
'after_completion',
|
|
74
|
+
'allow_promotion_codes',
|
|
75
|
+
'consent_collection',
|
|
76
|
+
'custom_fields',
|
|
77
|
+
'phone_number_collection',
|
|
78
|
+
'billing_address_collection',
|
|
79
|
+
'submit_type',
|
|
80
|
+
'subscription_data',
|
|
81
|
+
])
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (item.adjustable_quantity?.enabled) {
|
|
85
|
+
item.adjustable_quantity.minimum = Number(item.adjustable_quantity?.minimum);
|
|
86
|
+
item.adjustable_quantity.maximum = Number(item.adjustable_quantity?.maximum);
|
|
87
|
+
}
|
|
88
|
+
if (item.after_completion?.type === 'hosted_confirmation') {
|
|
89
|
+
// @ts-ignore
|
|
90
|
+
item.after_completion.redirect = null;
|
|
91
|
+
}
|
|
92
|
+
if (item.after_completion?.type === 'redirect') {
|
|
93
|
+
// @ts-ignore
|
|
94
|
+
item.after_completion.hosted_confirmation = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return item;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (payload.highlight && payload.highlight_product_id) {
|
|
101
|
+
raw.items?.forEach((x) => {
|
|
102
|
+
if (x.product_id === payload.highlight_product_id) {
|
|
103
|
+
x.is_highlight = x.product_id === payload.highlight_product_id;
|
|
104
|
+
x.highlight_text = payload.highlight_text || 'popular';
|
|
105
|
+
} else {
|
|
106
|
+
x.is_highlight = false;
|
|
107
|
+
x.highlight_text = 'popular';
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
raw.metadata = formatMetadata(raw.metadata);
|
|
113
|
+
|
|
114
|
+
return raw;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// FIXME: @wangshijun use schema validation
|
|
118
|
+
// eslint-disable-next-line consistent-return
|
|
119
|
+
router.post('/', auth, async (req, res) => {
|
|
120
|
+
const raw: Partial<PricingTable> = formatPricingTable(req.body);
|
|
121
|
+
raw.active = true;
|
|
122
|
+
raw.locked = false;
|
|
123
|
+
raw.livemode = !!req.livemode;
|
|
124
|
+
raw.created_via = req.user?.via;
|
|
125
|
+
|
|
126
|
+
if (!raw.items?.length) {
|
|
127
|
+
return res.status(400).json({ error: 'items should not be empty for pricing table' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// @ts-ignore
|
|
131
|
+
const items = await Price.expand(raw.items);
|
|
132
|
+
for (let i = 0; i < items.length; i++) {
|
|
133
|
+
if (isLineItemCurrencyAligned(items, i) === false) {
|
|
134
|
+
return res.status(400).json({ error: 'items should have same currency' });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const link = await PricingTable.create(raw as PricingTable);
|
|
139
|
+
|
|
140
|
+
res.json(link);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// list pricing tables
|
|
144
|
+
const paginationSchema = Joi.object<{
|
|
145
|
+
page: number;
|
|
146
|
+
pageSize: number;
|
|
147
|
+
active?: boolean;
|
|
148
|
+
livemode?: boolean;
|
|
149
|
+
}>({
|
|
150
|
+
page: Joi.number().integer().min(1).default(1),
|
|
151
|
+
pageSize: Joi.number().integer().min(1).max(100).default(20),
|
|
152
|
+
active: Joi.boolean().empty(''),
|
|
153
|
+
livemode: Joi.boolean().empty(''),
|
|
154
|
+
});
|
|
155
|
+
router.get('/', auth, async (req, res) => {
|
|
156
|
+
const { page, pageSize, ...query } = await paginationSchema.validateAsync(req.query, { stripUnknown: true });
|
|
157
|
+
const where: WhereOptions<PricingTable> = { id: { [Op.notIn]: [`prctbl_${req.user?.did}`] } };
|
|
158
|
+
|
|
159
|
+
if (typeof query.active === 'boolean') {
|
|
160
|
+
where.active = query.active;
|
|
161
|
+
}
|
|
162
|
+
if (typeof query.livemode === 'boolean') {
|
|
163
|
+
where.livemode = query.livemode;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const { rows: list, count } = await PricingTable.findAndCountAll({
|
|
168
|
+
where,
|
|
169
|
+
order: [['created_at', 'DESC']],
|
|
170
|
+
offset: (page - 1) * pageSize,
|
|
171
|
+
limit: pageSize,
|
|
172
|
+
include: [],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const priceIds: string[] = uniq(list.reduce((acc: string[], x) => acc.concat(x.items.map((i) => i.price_id)), []));
|
|
176
|
+
const prices = await Price.findAll({ where: { id: priceIds }, include: [{ model: Product, as: 'product' }] });
|
|
177
|
+
const products = await Product.findAll({ where: { id: uniq(prices.map((x) => x.product_id)) } });
|
|
178
|
+
|
|
179
|
+
list.forEach((x) => {
|
|
180
|
+
x.items.forEach((i) => {
|
|
181
|
+
// @ts-ignore
|
|
182
|
+
i.price = prices.find((p) => p.id === i.price_id);
|
|
183
|
+
// @ts-ignore
|
|
184
|
+
i.product = products.find((p) => p.id === i.product_id);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
res.json({ count, list });
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(err);
|
|
191
|
+
res.json({ count: 0, list: [] });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// eslint-disable-next-line consistent-return
|
|
196
|
+
router.get('/:id', async (req, res) => {
|
|
197
|
+
const doc = await PricingTable.findByPk(req.params.id);
|
|
198
|
+
|
|
199
|
+
if (!doc) {
|
|
200
|
+
return res.status(404).json({ error: 'pricing table not found' });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const prices = await Price.findAll({ where: { id: uniq(doc.items.map((x) => x.price_id)) } });
|
|
204
|
+
const products = await Product.findAll({ where: { id: uniq(doc.items.map((x) => x.product_id)) } });
|
|
205
|
+
|
|
206
|
+
doc.items.forEach((i) => {
|
|
207
|
+
// @ts-ignore
|
|
208
|
+
i.price = prices.find((p) => p.id === i.price_id);
|
|
209
|
+
// @ts-ignore
|
|
210
|
+
i.product = products.find((p) => p.id === i.product_id);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const currency = await PaymentCurrency.findOne({ where: { livemode: doc.livemode, is_base_currency: true } });
|
|
214
|
+
|
|
215
|
+
res.json({ ...doc.toJSON(), currency });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// update
|
|
219
|
+
// eslint-disable-next-line consistent-return
|
|
220
|
+
router.put('/:id', auth, async (req, res) => {
|
|
221
|
+
const doc = await PricingTable.findByPk(req.params.id);
|
|
222
|
+
|
|
223
|
+
if (!doc) {
|
|
224
|
+
return res.status(404).json({ error: 'pricing table not found' });
|
|
225
|
+
}
|
|
226
|
+
if (doc.active === false) {
|
|
227
|
+
return res.status(403).json({ error: 'pricing table archived' });
|
|
228
|
+
}
|
|
229
|
+
// if (doc.locked) {
|
|
230
|
+
// return res.status(403).json({ error: 'pricing table locked' });
|
|
231
|
+
// }
|
|
232
|
+
|
|
233
|
+
// FIXME: should only allow update some fields
|
|
234
|
+
await doc.update(formatPricingTable(req.body));
|
|
235
|
+
|
|
236
|
+
res.json(doc);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// archive
|
|
240
|
+
router.put('/:id/archive', auth, async (req, res) => {
|
|
241
|
+
const doc = await PricingTable.findByPk(req.params.id);
|
|
242
|
+
|
|
243
|
+
if (!doc) {
|
|
244
|
+
return res.status(404).json({ error: 'pricing table not found' });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (doc.active === false) {
|
|
248
|
+
return res.status(403).json({ error: 'pricing table already archived' });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await doc.update({ active: false });
|
|
252
|
+
return res.json(doc);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// delete
|
|
256
|
+
router.delete('/:id', auth, async (req, res) => {
|
|
257
|
+
const doc = await PricingTable.findByPk(req.params.id);
|
|
258
|
+
|
|
259
|
+
if (!doc) {
|
|
260
|
+
return res.status(404).json({ error: 'pricing table not found' });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (doc.active === false) {
|
|
264
|
+
return res.status(403).json({ error: 'pricing table archived' });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (doc.locked) {
|
|
268
|
+
return res.status(403).json({ error: 'pricing table locked' });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await doc.destroy();
|
|
272
|
+
return res.json(doc);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
router.post('/stash', auth, async (req, res) => {
|
|
276
|
+
try {
|
|
277
|
+
const raw: Partial<PricingTable> = req.body;
|
|
278
|
+
raw.id = `prctbl_${req.user?.did}`;
|
|
279
|
+
raw.active = true;
|
|
280
|
+
raw.locked = false;
|
|
281
|
+
raw.livemode = !!req.livemode;
|
|
282
|
+
raw.created_via = req.user?.via;
|
|
283
|
+
|
|
284
|
+
let doc = await PricingTable.findByPk(raw.id);
|
|
285
|
+
if (doc) {
|
|
286
|
+
await doc.update(formatPricingTable(req.body));
|
|
287
|
+
} else {
|
|
288
|
+
doc = await PricingTable.create(raw as PricingTable);
|
|
289
|
+
}
|
|
290
|
+
res.json(doc);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
console.error(err);
|
|
293
|
+
res.status(500).json({ error: err.message });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// eslint-disable-next-line consistent-return
|
|
298
|
+
router.post('/:id/checkout/:priceId', async (req, res) => {
|
|
299
|
+
const doc = await PricingTable.findByPk(req.params.id);
|
|
300
|
+
|
|
301
|
+
if (!doc) {
|
|
302
|
+
return res.status(404).json({ error: 'pricing table not found' });
|
|
303
|
+
}
|
|
304
|
+
if (doc.active === false) {
|
|
305
|
+
return res.status(403).json({ error: 'pricing table archived' });
|
|
306
|
+
}
|
|
307
|
+
if (doc.locked) {
|
|
308
|
+
return res.status(403).json({ error: 'pricing table locked' });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const price = await doc.items.find((x) => x.price_id === req.params.priceId);
|
|
312
|
+
if (!price) {
|
|
313
|
+
return res.status(403).json({ error: 'pricing table item not valid' });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const raw: Partial<CheckoutSession> = await formatCheckoutSession({
|
|
317
|
+
line_items: [{ price_id: price.price_id, quantity: 1, adjustable_quantity: price.adjustable_quantity }],
|
|
318
|
+
...pick(price, [
|
|
319
|
+
'allow_promotion_codes',
|
|
320
|
+
'consent_collection',
|
|
321
|
+
'custom_fields',
|
|
322
|
+
'customer_creation',
|
|
323
|
+
'invoice_creation',
|
|
324
|
+
'phone_number_collection',
|
|
325
|
+
'billing_address_collection',
|
|
326
|
+
'submit_type',
|
|
327
|
+
'subscription_data',
|
|
328
|
+
]),
|
|
329
|
+
metadata: {
|
|
330
|
+
pricing_table: doc.id,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
raw.livemode = doc.livemode;
|
|
335
|
+
raw.created_via = 'portal';
|
|
336
|
+
raw.currency_id = req.currency.id;
|
|
337
|
+
|
|
338
|
+
const session = await CheckoutSession.create(raw as any);
|
|
339
|
+
res.json({ ...session.toJSON(), url: getUrl(`/checkout/pay/${session.id}`) });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
export default router;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Migration } from '../migrate';
|
|
2
|
+
import models from '../models';
|
|
3
|
+
|
|
4
|
+
export const up: Migration = async ({ context: queryInterface }) => {
|
|
5
|
+
await queryInterface.createTable('pricing_tables', models.PricingTable.GENESIS_ATTRIBUTES);
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const down: Migration = async ({ context: queryInterface }) => {
|
|
9
|
+
await queryInterface.dropTable('pricing_tables');
|
|
10
|
+
};
|
|
@@ -11,13 +11,14 @@ import { PaymentIntent, TPaymentIntent } from './payment-intent';
|
|
|
11
11
|
import { PaymentLink, TPaymentLink } from './payment-link';
|
|
12
12
|
import { PaymentMethod, TPaymentMethod } from './payment-method';
|
|
13
13
|
import { Price, TPrice } from './price';
|
|
14
|
+
import { PricingTable, TPricingTable } from './pricing-table';
|
|
14
15
|
import { Product, TProduct } from './product';
|
|
15
16
|
import { PromotionCode } from './promotion-code';
|
|
16
17
|
import { SetupIntent, TSetupIntent } from './setup-intent';
|
|
17
18
|
import { Subscription, TSubscription } from './subscription';
|
|
18
19
|
import { SubscriptionItem, TSubscriptionItem } from './subscription-item';
|
|
19
20
|
import { SubscriptionSchedule } from './subscription-schedule';
|
|
20
|
-
import type { LineItem } from './types';
|
|
21
|
+
import type { LineItem, PricingTableItem } from './types';
|
|
21
22
|
import { TUsageRecord, UsageRecord } from './usage-record';
|
|
22
23
|
import { TWebhookAttempt, WebhookAttempt } from './webhook-attempt';
|
|
23
24
|
import { TWebhookEndpoint, WebhookEndpoint } from './webhook-endpoint';
|
|
@@ -35,6 +36,7 @@ const models = {
|
|
|
35
36
|
PaymentLink,
|
|
36
37
|
PaymentMethod,
|
|
37
38
|
Price,
|
|
39
|
+
PricingTable,
|
|
38
40
|
Product,
|
|
39
41
|
PromotionCode,
|
|
40
42
|
SetupIntent,
|
|
@@ -71,6 +73,7 @@ export * from './payment-intent';
|
|
|
71
73
|
export * from './payment-link';
|
|
72
74
|
export * from './payment-method';
|
|
73
75
|
export * from './price';
|
|
76
|
+
export * from './pricing-table';
|
|
74
77
|
export * from './product';
|
|
75
78
|
export * from './promotion-code';
|
|
76
79
|
export * from './setup-intent';
|
|
@@ -187,3 +190,13 @@ export type TUsageRecordSummary = {
|
|
|
187
190
|
export type TSetupIntentExpanded = TSetupIntent & {
|
|
188
191
|
object: 'setup_intent';
|
|
189
192
|
};
|
|
193
|
+
|
|
194
|
+
export type TPricingTableItem = PricingTableItem & {
|
|
195
|
+
price: TPrice;
|
|
196
|
+
product: TProduct;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export type TPricingTableExpanded = TPricingTable & {
|
|
200
|
+
items: TPricingTableItem[];
|
|
201
|
+
currency: TPaymentCurrency;
|
|
202
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/lines-between-class-members */
|
|
2
|
+
import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
|
|
3
|
+
import type { LiteralUnion } from 'type-fest';
|
|
4
|
+
|
|
5
|
+
import { createEvent } from '../../libs/audit';
|
|
6
|
+
import { createIdGenerator } from '../../libs/util';
|
|
7
|
+
import type { BrandSettings, PricingTableItem } from './types';
|
|
8
|
+
|
|
9
|
+
const nextId = createIdGenerator('prctbl', 24);
|
|
10
|
+
|
|
11
|
+
// @link https://stripe.com/docs/api/payment_links/payment_links
|
|
12
|
+
export class PricingTable extends Model<InferAttributes<PricingTable>, InferCreationAttributes<PricingTable>> {
|
|
13
|
+
// Unique identifier for the object.
|
|
14
|
+
declare id: CreationOptional<string>;
|
|
15
|
+
|
|
16
|
+
// Whether the PricingTable is currently available for purchase.
|
|
17
|
+
declare active: boolean;
|
|
18
|
+
declare livemode: boolean;
|
|
19
|
+
declare locked: boolean;
|
|
20
|
+
|
|
21
|
+
declare name: string;
|
|
22
|
+
|
|
23
|
+
// The items representing what is being sold. Each line item represents an item being sold. Up to 20 line items are supported.
|
|
24
|
+
declare items: PricingTableItem[];
|
|
25
|
+
|
|
26
|
+
declare branding_settings: BrandSettings;
|
|
27
|
+
|
|
28
|
+
declare metadata?: Record<string, any>;
|
|
29
|
+
|
|
30
|
+
declare created_at: CreationOptional<Date>;
|
|
31
|
+
declare created_via: LiteralUnion<'api' | 'dashboard' | 'portal', string>;
|
|
32
|
+
declare updated_at: CreationOptional<Date>;
|
|
33
|
+
|
|
34
|
+
public static readonly GENESIS_ATTRIBUTES = {
|
|
35
|
+
id: {
|
|
36
|
+
type: DataTypes.STRING(42),
|
|
37
|
+
primaryKey: true,
|
|
38
|
+
allowNull: false,
|
|
39
|
+
defaultValue: nextId,
|
|
40
|
+
},
|
|
41
|
+
active: {
|
|
42
|
+
type: DataTypes.BOOLEAN,
|
|
43
|
+
allowNull: false,
|
|
44
|
+
},
|
|
45
|
+
livemode: {
|
|
46
|
+
type: DataTypes.BOOLEAN,
|
|
47
|
+
allowNull: false,
|
|
48
|
+
},
|
|
49
|
+
locked: {
|
|
50
|
+
type: DataTypes.BOOLEAN,
|
|
51
|
+
allowNull: false,
|
|
52
|
+
},
|
|
53
|
+
name: {
|
|
54
|
+
type: DataTypes.STRING(255),
|
|
55
|
+
allowNull: true,
|
|
56
|
+
},
|
|
57
|
+
items: {
|
|
58
|
+
type: DataTypes.JSON,
|
|
59
|
+
defaultValue: [],
|
|
60
|
+
},
|
|
61
|
+
branding_settings: {
|
|
62
|
+
type: DataTypes.JSON,
|
|
63
|
+
allowNull: false,
|
|
64
|
+
},
|
|
65
|
+
metadata: {
|
|
66
|
+
type: DataTypes.JSON,
|
|
67
|
+
allowNull: true,
|
|
68
|
+
},
|
|
69
|
+
created_at: {
|
|
70
|
+
type: DataTypes.DATE,
|
|
71
|
+
defaultValue: DataTypes.NOW,
|
|
72
|
+
allowNull: false,
|
|
73
|
+
},
|
|
74
|
+
created_via: {
|
|
75
|
+
type: DataTypes.ENUM('api', 'dashboard', 'portal'),
|
|
76
|
+
},
|
|
77
|
+
updated_at: {
|
|
78
|
+
type: DataTypes.DATE,
|
|
79
|
+
defaultValue: DataTypes.NOW,
|
|
80
|
+
allowNull: false,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
public static initialize(sequelize: any) {
|
|
85
|
+
this.init(PricingTable.GENESIS_ATTRIBUTES, {
|
|
86
|
+
sequelize,
|
|
87
|
+
modelName: 'PricingTable',
|
|
88
|
+
tableName: 'pricing_tables',
|
|
89
|
+
createdAt: 'created_at',
|
|
90
|
+
updatedAt: 'updated_at',
|
|
91
|
+
hooks: {
|
|
92
|
+
afterCreate: (model: PricingTable, options) =>
|
|
93
|
+
createEvent('PricingTable', 'pricing_table.created', model, options).catch(console.error),
|
|
94
|
+
afterUpdate: (model: PricingTable, options) =>
|
|
95
|
+
createEvent('PricingTable', 'pricing_table.updated', model, options).catch(console.error),
|
|
96
|
+
afterDestroy: (model: PricingTable, options) =>
|
|
97
|
+
createEvent('PricingTable', 'pricing_table.deleted', model, options).catch(console.error),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public static associate() {
|
|
103
|
+
// Do nothing
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type TPricingTable = InferAttributes<PricingTable>;
|
|
@@ -262,6 +262,59 @@ export type PaymentDetails = {
|
|
|
262
262
|
};
|
|
263
263
|
};
|
|
264
264
|
|
|
265
|
+
// Very similar to PaymentLink
|
|
266
|
+
export type PricingTableItem = {
|
|
267
|
+
price_id: string;
|
|
268
|
+
product_id: string;
|
|
269
|
+
|
|
270
|
+
adjustable_quantity: {
|
|
271
|
+
enabled: boolean;
|
|
272
|
+
maximum: number;
|
|
273
|
+
minimum: number;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// The specified behavior after the purchase is complete.
|
|
277
|
+
after_completion?: AfterPayment;
|
|
278
|
+
|
|
279
|
+
// Enables user redeemable promotion codes.
|
|
280
|
+
allow_promotion_codes: boolean;
|
|
281
|
+
|
|
282
|
+
// Configuration for collecting the customer’s billing address.
|
|
283
|
+
billing_address_collection?: LiteralUnion<'auto' | 'required', string>;
|
|
284
|
+
|
|
285
|
+
is_highlight: boolean;
|
|
286
|
+
highlight_text?: LiteralUnion<'deal' | 'popular' | 'recommended', string>;
|
|
287
|
+
|
|
288
|
+
consent_collection?: {
|
|
289
|
+
promotions?: LiteralUnion<'auto' | 'none', string>;
|
|
290
|
+
terms_of_service?: LiteralUnion<'required' | 'none', string>;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Collect additional information from your customer using custom fields. Up to 2 fields are supported.
|
|
294
|
+
custom_fields: CustomField[];
|
|
295
|
+
|
|
296
|
+
// Controls phone number collection settings during checkout.
|
|
297
|
+
phone_number_collection?: {
|
|
298
|
+
enabled: boolean;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Describes the type of transaction being performed in order to customize relevant text
|
|
302
|
+
submit_type: LiteralUnion<'auto' | 'book' | 'donate' | 'pay', string>;
|
|
303
|
+
|
|
304
|
+
// When creating a subscription, the specified configuration data will be used.
|
|
305
|
+
subscription_data?: {
|
|
306
|
+
description: string;
|
|
307
|
+
trial_period_days: number;
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export type BrandSettings = {
|
|
312
|
+
background_color: string;
|
|
313
|
+
border_style: string;
|
|
314
|
+
button_color: string;
|
|
315
|
+
font_family: string;
|
|
316
|
+
};
|
|
317
|
+
|
|
265
318
|
export type EventType = LiteralUnion<
|
|
266
319
|
| 'account.application.authorized'
|
|
267
320
|
| 'account.application.deauthorized'
|