payment-kit 1.13.23 → 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/jobs/event.ts +10 -4
- package/api/src/jobs/webhook.ts +17 -8
- package/api/src/libs/audit.ts +3 -3
- package/api/src/libs/event.ts +3 -0
- package/api/src/libs/util.ts +5 -0
- package/api/src/routes/checkout-sessions.ts +3 -3
- package/api/src/routes/connect/pay.ts +1 -1
- 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/routes/subscriptions.ts +15 -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 +2 -2
- package/package.json +4 -3
- package/src/app.tsx +1 -1
- package/src/components/blockchain/tx.tsx +8 -0
- package/src/components/payment-link/actions.tsx +20 -9
- 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/price/form.tsx +4 -1
- 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 +54 -5
- package/src/locales/en.tsx +28 -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/admin/products/products/create.tsx +8 -4
- 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
|
|
package/api/src/jobs/event.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Op } from 'sequelize';
|
|
2
2
|
|
|
3
|
+
import { events } from '../libs/event';
|
|
3
4
|
import logger from '../libs/logger';
|
|
4
5
|
import createQueue from '../libs/queue';
|
|
6
|
+
import { getWebhookJobId } from '../libs/util';
|
|
5
7
|
import { Event } from '../store/models/event';
|
|
6
8
|
import { WebhookAttempt } from '../store/models/webhook-attempt';
|
|
7
9
|
import { WebhookEndpoint } from '../store/models/webhook-endpoint';
|
|
8
|
-
import {
|
|
10
|
+
import { webhookQueue } from './webhook';
|
|
9
11
|
|
|
10
12
|
type EventJob = {
|
|
11
13
|
eventId: string;
|
|
@@ -43,7 +45,7 @@ export const handleEvent = async (job: EventJob) => {
|
|
|
43
45
|
if (!attempted) {
|
|
44
46
|
logger.info('schedule initial attempt for event', job);
|
|
45
47
|
webhookQueue.push({
|
|
46
|
-
id:
|
|
48
|
+
id: getWebhookJobId(event.id, webhook.id),
|
|
47
49
|
job: { eventId: event.id, webhookId: webhook.id },
|
|
48
50
|
});
|
|
49
51
|
}
|
|
@@ -60,14 +62,14 @@ export const eventQueue = createQueue<EventJob>({
|
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
export const startEventQueue = async () => {
|
|
63
|
-
const
|
|
65
|
+
const docs = await Event.findAll({
|
|
64
66
|
where: {
|
|
65
67
|
pending_webhooks: { [Op.gt]: 0 },
|
|
66
68
|
},
|
|
67
69
|
attributes: ['id'],
|
|
68
70
|
});
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
docs.forEach(async (x) => {
|
|
71
73
|
const exist = await eventQueue.get(x.id);
|
|
72
74
|
if (!exist) {
|
|
73
75
|
eventQueue.push({ id: x.id, job: { eventId: x.id } });
|
|
@@ -78,3 +80,7 @@ export const startEventQueue = async () => {
|
|
|
78
80
|
eventQueue.on('failed', ({ id, job, error }) => {
|
|
79
81
|
logger.error('event job failed', { id, job, error });
|
|
80
82
|
});
|
|
83
|
+
|
|
84
|
+
events.on('event.created', (event) => {
|
|
85
|
+
eventQueue.push({ id: event.id, job: { eventId: event.id } });
|
|
86
|
+
});
|
package/api/src/jobs/webhook.ts
CHANGED
|
@@ -4,8 +4,10 @@ import axios, { AxiosError } from 'axios';
|
|
|
4
4
|
import { wallet } from '../libs/auth';
|
|
5
5
|
import logger from '../libs/logger';
|
|
6
6
|
import createQueue from '../libs/queue';
|
|
7
|
-
import { MAX_RETRY_COUNT, getNextRetry,
|
|
7
|
+
import { MAX_RETRY_COUNT, getNextRetry, getWebhookJobId } from '../libs/util';
|
|
8
|
+
import { Customer } from '../store/models/customer';
|
|
8
9
|
import { Event } from '../store/models/event';
|
|
10
|
+
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
9
11
|
import { WebhookAttempt } from '../store/models/webhook-attempt';
|
|
10
12
|
import { WebhookEndpoint } from '../store/models/webhook-endpoint';
|
|
11
13
|
|
|
@@ -14,10 +16,6 @@ type WebhookJob = {
|
|
|
14
16
|
webhookId: string;
|
|
15
17
|
};
|
|
16
18
|
|
|
17
|
-
export const getJobId = (eventId: string, webhookId: string) => {
|
|
18
|
-
return md5([eventId, webhookId].join('-'));
|
|
19
|
-
};
|
|
20
|
-
|
|
21
19
|
// https://stripe.com/docs/webhooks
|
|
22
20
|
export const handleWebhook = async (job: WebhookJob) => {
|
|
23
21
|
logger.info('handle webhook', job);
|
|
@@ -45,16 +43,27 @@ export const handleWebhook = async (job: WebhookJob) => {
|
|
|
45
43
|
const retryCount = lastRetryCount ? +lastRetryCount + 1 : 1;
|
|
46
44
|
|
|
47
45
|
try {
|
|
46
|
+
const json = event.toJSON();
|
|
47
|
+
|
|
48
|
+
// expand basic fields
|
|
49
|
+
const { object } = json.data;
|
|
50
|
+
if (object.customer_id && !object.customer) {
|
|
51
|
+
object.customer = await Customer.findByPk(object.customer_id);
|
|
52
|
+
}
|
|
53
|
+
if (object.currency_id && !object.currency) {
|
|
54
|
+
object.currency = await PaymentCurrency.findByPk(object.currency_id);
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
// verify similar to component call, but supports external urls
|
|
49
58
|
const result = await axios({
|
|
50
59
|
url: webhook.url,
|
|
51
60
|
method: 'POST',
|
|
52
61
|
timeout: 60 * 1000,
|
|
53
|
-
data:
|
|
62
|
+
data: json,
|
|
54
63
|
headers: {
|
|
55
64
|
'x-app-id': wallet.address,
|
|
56
65
|
'x-app-pk': wallet.publicKey,
|
|
57
|
-
'x-component-sig': sign(
|
|
66
|
+
'x-component-sig': sign(json),
|
|
58
67
|
'x-component-did': process.env.BLOCKLET_COMPONENT_DID as string,
|
|
59
68
|
},
|
|
60
69
|
});
|
|
@@ -87,7 +96,7 @@ export const handleWebhook = async (job: WebhookJob) => {
|
|
|
87
96
|
if (retryCount < MAX_RETRY_COUNT) {
|
|
88
97
|
process.nextTick(() => {
|
|
89
98
|
webhookQueue.push({
|
|
90
|
-
id:
|
|
99
|
+
id: getWebhookJobId(event.id, webhook.id),
|
|
91
100
|
job: { eventId: event.id, webhookId: webhook.id },
|
|
92
101
|
runAt: getNextRetry(retryCount),
|
|
93
102
|
});
|
package/api/src/libs/audit.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import pick from 'lodash/pick';
|
|
2
2
|
|
|
3
|
-
import { eventQueue } from '../jobs/event';
|
|
4
3
|
import { Event } from '../store/models/event';
|
|
4
|
+
import { events } from './event';
|
|
5
5
|
|
|
6
6
|
export async function createEvent(scope: string, type: string, model: any, options: any) {
|
|
7
7
|
// console.log('createEvent', scope, type, model, options);
|
|
@@ -28,7 +28,7 @@ export async function createEvent(scope: string, type: string, model: any, optio
|
|
|
28
28
|
pending_webhooks: 99, // force all events goto the event queue
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
events.emit('event.created', { id: event.id });
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export async function createStatusEvent(
|
|
@@ -69,5 +69,5 @@ export async function createStatusEvent(
|
|
|
69
69
|
pending_webhooks: 99, // force all events goto the event queue
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
events.emit('event.created', { id: event.id });
|
|
73
73
|
}
|
package/api/src/libs/util.ts
CHANGED
|
@@ -66,6 +66,7 @@ export function createCodeGenerator(prefix: string, size: number = 24) {
|
|
|
66
66
|
return prefix ? () => `${prefix}_${generator()}` : generator;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// FIXME: merge with old metadata
|
|
69
70
|
export function formatMetadata(metadata?: Record<string, any>): Record<string, any> {
|
|
70
71
|
if (!metadata) {
|
|
71
72
|
return {};
|
|
@@ -137,3 +138,7 @@ export const getNextRetry = (retryCount: number) => {
|
|
|
137
138
|
const now = dayjs().unix();
|
|
138
139
|
return now + delay;
|
|
139
140
|
};
|
|
141
|
+
|
|
142
|
+
export const getWebhookJobId = (eventId: string, webhookId: string) => {
|
|
143
|
+
return md5([eventId, webhookId].join('-'));
|
|
144
|
+
};
|
|
@@ -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;
|
|
@@ -8,7 +8,7 @@ import dayjs from '../../libs/dayjs';
|
|
|
8
8
|
import { ensureInvoiceForCheckout, ensurePaymentIntent, getAuthPrincipalClaim } from './shared';
|
|
9
9
|
|
|
10
10
|
export default {
|
|
11
|
-
action: '
|
|
11
|
+
action: 'payment',
|
|
12
12
|
authPrincipal: false,
|
|
13
13
|
claims: {
|
|
14
14
|
authPrincipal: async ({ extraParams }: CallbackArgs) => {
|
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;
|
|
@@ -8,6 +8,7 @@ import dayjs from '../libs/dayjs';
|
|
|
8
8
|
import logger from '../libs/logger';
|
|
9
9
|
import { authenticate } from '../libs/security';
|
|
10
10
|
import { expandLineItems } from '../libs/session';
|
|
11
|
+
import { formatMetadata } from '../libs/util';
|
|
11
12
|
import { Customer } from '../store/models/customer';
|
|
12
13
|
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
13
14
|
import { PaymentMethod } from '../store/models/payment-method';
|
|
@@ -302,4 +303,18 @@ router.put('/:id/resume', auth, async (req, res) => {
|
|
|
302
303
|
return res.json(doc);
|
|
303
304
|
});
|
|
304
305
|
|
|
306
|
+
router.put('/:id', auth, async (req, res) => {
|
|
307
|
+
const doc = await Subscription.findByPk(req.params.id);
|
|
308
|
+
|
|
309
|
+
if (!doc) {
|
|
310
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (req.body.metadata) {
|
|
314
|
+
await doc.update({ metadata: formatMetadata(req.body.metadata) });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return res.json(doc);
|
|
318
|
+
});
|
|
319
|
+
|
|
305
320
|
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
|
+
};
|