payment-kit 1.13.38 → 1.13.40

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.
Files changed (33) hide show
  1. package/api/src/jobs/payment.ts +17 -4
  2. package/api/src/libs/error.ts +13 -0
  3. package/api/src/libs/queue/store.ts +1 -1
  4. package/api/src/libs/util.ts +0 -14
  5. package/api/src/routes/checkout-sessions.ts +183 -47
  6. package/api/src/routes/payment-links.ts +2 -0
  7. package/api/src/routes/pricing-table.ts +23 -6
  8. package/api/src/routes/products.ts +4 -40
  9. package/api/src/routes/redirect.ts +4 -2
  10. package/api/src/store/migrations/20231030-crosssell.ts +23 -0
  11. package/api/src/store/models/checkout-session.ts +11 -5
  12. package/api/src/store/models/index.ts +9 -1
  13. package/api/src/store/models/payment-link.ts +6 -0
  14. package/api/src/store/models/price.ts +23 -18
  15. package/api/src/store/models/product.ts +73 -14
  16. package/api/src/store/models/types.ts +3 -0
  17. package/blocklet.yml +1 -1
  18. package/package.json +10 -10
  19. package/src/components/checkout/pay.tsx +23 -0
  20. package/src/components/checkout/product-item.tsx +30 -20
  21. package/src/components/checkout/summary.tsx +112 -4
  22. package/src/components/payment-link/before-pay.tsx +16 -0
  23. package/src/components/price/upsell-select.tsx +7 -2
  24. package/src/components/pricing-table/payment-settings.tsx +16 -0
  25. package/src/components/pricing-table/product-settings.tsx +1 -0
  26. package/src/components/product/cross-sell-select.tsx +51 -0
  27. package/src/components/product/cross-sell.tsx +83 -0
  28. package/src/locales/en.tsx +11 -0
  29. package/src/locales/zh.tsx +11 -0
  30. package/src/pages/admin/payments/links/create.tsx +1 -0
  31. package/src/pages/admin/products/products/detail.tsx +7 -0
  32. package/src/pages/checkout/pay.tsx +3 -5
  33. package/src/pages/checkout/pricing-table.tsx +1 -1
@@ -1,5 +1,8 @@
1
+ import { toBN } from '@ocap/util';
2
+
1
3
  import { wallet } from '../libs/auth';
2
4
  import dayjs from '../libs/dayjs';
5
+ import CustomError from '../libs/error';
3
6
  import logger from '../libs/logger';
4
7
  import createQueue from '../libs/queue';
5
8
  import { MAX_RETRY_COUNT, getNextRetry } from '../libs/util';
@@ -59,8 +62,18 @@ export const handlePayment = async (job: PaymentJob) => {
59
62
  // try payment capture and reschedule on error
60
63
  logger.info(`PaymentIntent capture attempt: ${paymentIntent.id}`);
61
64
  try {
62
- await paymentIntent.update({ status: 'processing' });
63
65
  const client = paymentMethod.getOcapClient();
66
+ const payer = paymentSettings?.payment_method_options.arcblock?.payer;
67
+
68
+ // check balance before capture
69
+ const result = await client.getAccountTokens({ address: payer, token: paymentCurrency.contract });
70
+ const balance = result.tokens.find((x: any) => x.address === paymentCurrency.contract)?.balance;
71
+ if (balance === undefined || toBN(balance).lt(toBN(paymentIntent.amount))) {
72
+ throw new CustomError('INSUFFICIENT_BALANCE', 'payer balance not enough for this payment');
73
+ }
74
+
75
+ // do the capture
76
+ await paymentIntent.update({ status: 'processing' });
64
77
  const txHash = await client.sendTransferV2Tx({
65
78
  tx: {
66
79
  itx: {
@@ -78,7 +91,7 @@ export const handlePayment = async (job: PaymentJob) => {
78
91
  },
79
92
  },
80
93
  },
81
- delegator: paymentSettings?.payment_method_options.arcblock?.payer,
94
+ delegator: payer,
82
95
  wallet,
83
96
  });
84
97
  logger.info(`PaymentIntent capture done: ${paymentIntent.id} with tx ${txHash}`);
@@ -89,7 +102,7 @@ export const handlePayment = async (job: PaymentJob) => {
89
102
  payment_details: {
90
103
  arcblock: {
91
104
  tx_hash: txHash,
92
- payer: paymentSettings?.payment_method_options.arcblock?.payer as string,
105
+ payer: payer as string,
93
106
  },
94
107
  },
95
108
  });
@@ -129,7 +142,7 @@ export const handlePayment = async (job: PaymentJob) => {
129
142
  payment_details: {
130
143
  arcblock: {
131
144
  tx_hash: txHash,
132
- payer: paymentSettings?.payment_method_options.arcblock?.payer as string,
145
+ payer: payer as string,
133
146
  },
134
147
  },
135
148
  });
@@ -0,0 +1,13 @@
1
+ export default class CustomError extends Error {
2
+ code: string;
3
+
4
+ constructor(code = 'GENERIC', ...params: any[]) {
5
+ super(...params);
6
+
7
+ if (Error.captureStackTrace) {
8
+ Error.captureStackTrace(this, CustomError);
9
+ }
10
+
11
+ this.code = code;
12
+ }
13
+ }
@@ -1,7 +1,7 @@
1
1
  import { Op } from 'sequelize';
2
2
 
3
3
  import { Job, TJob } from '../../store/models/job';
4
- import { CustomError } from '../util';
4
+ import CustomError from '../error';
5
5
 
6
6
  export default function createQueueStore(queue: string) {
7
7
  return {
@@ -119,20 +119,6 @@ export function tryWithTimeout(asyncFn: Function, timeout = 5000) {
119
119
  });
120
120
  }
121
121
 
122
- export class CustomError extends Error {
123
- code: string;
124
-
125
- constructor(code = 'GENERIC', ...params: any) {
126
- super(...params);
127
-
128
- if (Error.captureStackTrace) {
129
- Error.captureStackTrace(this, CustomError);
130
- }
131
-
132
- this.code = code;
133
- }
134
- }
135
-
136
122
  // simple exponential delay: 2^retryCount
137
123
  export const getNextRetry = (retryCount: number) => {
138
124
  const delay = 2 ** retryCount;
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable consistent-return */
2
2
  import { getUrl } from '@blocklet/sdk/lib/component';
3
3
  import userMiddleware from '@blocklet/sdk/lib/middlewares/user';
4
- import { Request, Response, Router } from 'express';
4
+ import { NextFunction, Request, Response, Router } from 'express';
5
5
  import cloneDeep from 'lodash/cloneDeep';
6
6
  import merge from 'lodash/merge';
7
7
  import omit from 'lodash/omit';
@@ -32,14 +32,15 @@ import {
32
32
  isLineItemAligned,
33
33
  } from '../libs/session';
34
34
  import { createCodeGenerator, formatMetadata } from '../libs/util';
35
- import type { TPaymentCurrency } from '../store/models';
35
+ import type { TPriceExpanded, TProductExpanded } from '../store/models';
36
36
  import { CheckoutSession } from '../store/models/checkout-session';
37
37
  import { Customer } from '../store/models/customer';
38
- import { PaymentCurrency } from '../store/models/payment-currency';
38
+ import { PaymentCurrency, TPaymentCurrency } from '../store/models/payment-currency';
39
39
  import { PaymentIntent } from '../store/models/payment-intent';
40
40
  import { PaymentLink } from '../store/models/payment-link';
41
41
  import { PaymentMethod } from '../store/models/payment-method';
42
42
  import { Price } from '../store/models/price';
43
+ import { Product } from '../store/models/product';
43
44
  import { SetupIntent } from '../store/models/setup-intent';
44
45
  import { Subscription } from '../store/models/subscription';
45
46
  import { SubscriptionItem } from '../store/models/subscription-item';
@@ -91,6 +92,7 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
91
92
  },
92
93
  payment_intent_data: {},
93
94
  submit_type: 'pay',
95
+ cross_sell_behavior: 'auto',
94
96
  },
95
97
  pick(payload, [
96
98
  'currency_id',
@@ -98,6 +100,7 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
98
100
  'line_items',
99
101
  'allow_promotion_codes',
100
102
  'consent_collection',
103
+ 'cross_sell_behavior',
101
104
  'custom_fields',
102
105
  'customer_creation',
103
106
  'invoice_creation',
@@ -193,6 +196,81 @@ export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession
193
196
  };
194
197
  }
195
198
 
199
+ export async function ensureCheckoutSessionOpen(req: Request, res: Response, next: NextFunction) {
200
+ const doc = await CheckoutSession.findByPk(req.params.id);
201
+
202
+ if (!doc) {
203
+ return res.status(404).json({ error: 'Checkout session not found' });
204
+ }
205
+ if (doc.status === 'complete') {
206
+ return res.status(403).json({ error: 'Checkout session completed' });
207
+ }
208
+ if (doc.status === 'expired') {
209
+ return res.status(403).json({ error: 'Checkout session already expired' });
210
+ }
211
+
212
+ req.doc = doc;
213
+
214
+ next();
215
+ }
216
+
217
+ export async function getCrossSellItem(checkoutSession: CheckoutSession) {
218
+ // FIXME: perhaps we can support cross sell even if the current session have multiple items
219
+ if (checkoutSession.line_items.length > 1) {
220
+ return { error: 'Cross sell not supported for checkoutSession with multiple line items' };
221
+ }
222
+
223
+ const items = await Price.expand(checkoutSession.line_items, { upsell: false });
224
+ const item = items.find(
225
+ (x) => x.upsell_price?.product?.cross_sell?.cross_sells_to_id || x.price.product.cross_sell?.cross_sells_to_id
226
+ );
227
+ if (!item) {
228
+ return { error: 'Cross sell not configured for all line item products' };
229
+ }
230
+
231
+ const to = (await Product.expand(
232
+ item.upsell_price?.product?.cross_sell.cross_sells_to_id || item.price.product.cross_sell.cross_sells_to_id
233
+ )) as TProductExpanded;
234
+ if (!to) {
235
+ return { error: 'Cross sell configure not valid anymore' };
236
+ }
237
+
238
+ const from = item.upsell_price || item.price;
239
+
240
+ const toCrossSellProduct = (price: TPriceExpanded) => ({
241
+ ...price,
242
+ product: omit(to, ['prices', 'default_price', 'cross_sell']),
243
+ });
244
+
245
+ // one_time can only cross sell to one_time
246
+ if (from.type === 'one_time') {
247
+ const oneTime = to.prices.find((x) => x.type === 'one_time');
248
+ if (oneTime) {
249
+ return toCrossSellProduct(oneTime);
250
+ }
251
+ }
252
+
253
+ // recurring can cross sell to matching recurring or one_time
254
+ if (from.type === 'recurring') {
255
+ const recurring = to.prices.find(
256
+ (x) =>
257
+ x.type === 'recurring' &&
258
+ x.recurring?.interval === from.recurring?.interval &&
259
+ x.recurring?.interval_count === from.recurring?.interval_count
260
+ );
261
+ if (recurring) {
262
+ return toCrossSellProduct(recurring);
263
+ }
264
+
265
+ const oneTime = to.prices.find((x) => x.type === 'one_time');
266
+ if (oneTime) {
267
+ return toCrossSellProduct(oneTime);
268
+ }
269
+ }
270
+
271
+ return { error: 'Cross sell not suitable' };
272
+ }
273
+
196
274
  // create checkout session
197
275
  router.post('/', auth, async (req, res) => {
198
276
  const raw: Partial<CheckoutSession> = await formatCheckoutSession(req.body);
@@ -324,22 +402,23 @@ router.get('/retrieve/:id', user, async (req, res) => {
324
402
  });
325
403
 
326
404
  // submit order
327
- router.put('/:id/submit', user, async (req, res) => {
405
+ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
328
406
  try {
329
407
  if (!req.user) {
330
408
  return res.status(403).json({ error: 'Please login to continue' });
331
409
  }
332
410
 
333
- // validate session
334
- const checkoutSession = await CheckoutSession.findByPk(req.params.id);
335
- if (!checkoutSession) {
336
- return res.status(404).json({ error: 'Checkout session not found' });
337
- }
338
- if (checkoutSession.status === 'complete') {
339
- return res.status(403).json({ error: 'Checkout session completed' });
340
- }
341
- if (checkoutSession.status === 'expired') {
342
- return res.status(403).json({ error: 'Checkout session expired' });
411
+ const checkoutSession = req.doc as CheckoutSession;
412
+
413
+ // validate cross sell
414
+ if (checkoutSession.cross_sell_behavior === 'required') {
415
+ if (checkoutSession.line_items.some((x) => x.cross_sell) === false) {
416
+ const result = await getCrossSellItem(checkoutSession);
417
+ // @ts-ignore
418
+ if (result.id) {
419
+ return res.status(400).json({ error: 'Please select cross sell product to continue' });
420
+ }
421
+ }
343
422
  }
344
423
 
345
424
  // validate payment settings
@@ -694,19 +773,10 @@ router.put('/:id/submit', user, async (req, res) => {
694
773
  });
695
774
 
696
775
  // upsell
697
- router.put('/:id/upsell', user, async (req, res) => {
776
+ router.put('/:id/upsell', user, ensureCheckoutSessionOpen, async (req, res) => {
698
777
  try {
699
778
  // validate session
700
- const checkoutSession = await CheckoutSession.findByPk(req.params.id);
701
- if (!checkoutSession) {
702
- return res.status(404).json({ error: 'Checkout session not found' });
703
- }
704
- if (checkoutSession.status === 'complete') {
705
- return res.status(403).json({ error: 'Checkout session completed' });
706
- }
707
- if (checkoutSession.status === 'expired') {
708
- return res.status(403).json({ error: 'Checkout session expired' });
709
- }
779
+ const checkoutSession = req.doc as CheckoutSession;
710
780
 
711
781
  if (checkoutSession.line_items) {
712
782
  // validate line items
@@ -749,19 +819,10 @@ router.put('/:id/upsell', user, async (req, res) => {
749
819
  }
750
820
  });
751
821
 
752
- router.put('/:id/downsell', user, async (req, res) => {
822
+ router.put('/:id/downsell', user, ensureCheckoutSessionOpen, async (req, res) => {
753
823
  try {
754
824
  // validate session
755
- const checkoutSession = await CheckoutSession.findByPk(req.params.id);
756
- if (!checkoutSession) {
757
- return res.status(404).json({ error: 'Checkout session not found' });
758
- }
759
- if (checkoutSession.status === 'complete') {
760
- return res.status(403).json({ error: 'Checkout session completed' });
761
- }
762
- if (checkoutSession.status === 'expired') {
763
- return res.status(403).json({ error: 'Checkout session expired' });
764
- }
825
+ const checkoutSession = req.doc as CheckoutSession;
765
826
 
766
827
  // validate from
767
828
  const from = await Price.findByPk(req.body.from);
@@ -793,22 +854,97 @@ router.put('/:id/downsell', user, async (req, res) => {
793
854
  });
794
855
 
795
856
  // eslint-disable-next-line consistent-return
796
- router.put('/:id/expire', auth, async (req, res) => {
797
- const doc = await CheckoutSession.findByPk(req.params.id);
857
+ router.put('/:id/expire', auth, ensureCheckoutSessionOpen, async (req, res) => {
858
+ const doc = req.doc as CheckoutSession;
798
859
 
799
- if (!doc) {
800
- return res.status(404).json({ error: 'Checkout session not found' });
801
- }
802
- if (doc.status === 'complete') {
803
- return res.status(403).json({ error: 'Checkout session completed' });
860
+ await doc.update({ status: 'expired', expires_at: dayjs().unix() });
861
+
862
+ res.json(doc);
863
+ });
864
+
865
+ // Return the expanded price to cross-sell-to for the checkout session
866
+ router.get('/:id/cross-sell', user, ensureCheckoutSessionOpen, async (req, res) => {
867
+ try {
868
+ const checkoutSession = req.doc as CheckoutSession;
869
+ const result = await getCrossSellItem(checkoutSession);
870
+ // @ts-ignore
871
+ return res.status(result.error ? 400 : 200).json(result);
872
+ } catch (err) {
873
+ console.error(err);
874
+ res.status(500).json({ error: err.message });
804
875
  }
805
- if (doc.status === 'expired') {
806
- return res.status(403).json({ error: 'Checkout session already expired' });
876
+ });
877
+
878
+ router.put('/:id/cross-sell', user, ensureCheckoutSessionOpen, async (req, res) => {
879
+ try {
880
+ const checkoutSession = req.doc as CheckoutSession;
881
+ if (!req.body.to) {
882
+ return res.status(400).json({ error: 'Cross sell item is required' });
883
+ }
884
+
885
+ const result = await getCrossSellItem(checkoutSession);
886
+ // @ts-ignore
887
+ if (result.error) {
888
+ return res.status(400).json(result);
889
+ }
890
+
891
+ // @ts-ignore
892
+ const to = result as TPriceExpanded;
893
+ if (to.id !== req.body.to) {
894
+ return res.status(400).json({ error: 'Cross sell item does not match' });
895
+ }
896
+
897
+ if (checkoutSession.line_items) {
898
+ const index = checkoutSession.line_items.findIndex((x) => x.upsell_price_id === to.id);
899
+ if (index > -1) {
900
+ return res.status(400).json({ error: 'Cross sell item already exist' });
901
+ }
902
+
903
+ const items = cloneDeep(checkoutSession.line_items);
904
+ items.push({
905
+ price_id: to.id,
906
+ quantity: 1,
907
+ cross_sell: true,
908
+ });
909
+ await checkoutSession.update({ line_items: items });
910
+ logger.info('CheckoutSession updated on add cross-sell', { id: req.params.id, crossSell: to.id });
911
+
912
+ // recalculate amount
913
+ await checkoutSession.update(await getCheckoutSessionAmounts(checkoutSession));
914
+ }
915
+
916
+ const items = await Price.expand(checkoutSession.line_items, { upsell: true });
917
+ res.json({ ...checkoutSession.toJSON(), line_items: items });
918
+ } catch (err) {
919
+ console.error(err);
920
+ res.status(500).json({ error: err.message });
807
921
  }
922
+ });
808
923
 
809
- await doc.update({ status: 'expired', expires_at: dayjs().unix() });
924
+ router.delete('/:id/cross-sell', user, ensureCheckoutSessionOpen, async (req, res) => {
925
+ try {
926
+ const checkoutSession = req.doc as CheckoutSession;
927
+ if (checkoutSession.line_items) {
928
+ const index = checkoutSession.line_items.findIndex((x) => x.cross_sell);
929
+ if (index === -1) {
930
+ return res.status(400).json({ error: 'Cross sell item not exist' });
931
+ }
810
932
 
811
- res.json(doc);
933
+ const items = cloneDeep(checkoutSession.line_items);
934
+ items.splice(index, 1);
935
+ await checkoutSession.update({ line_items: items });
936
+ logger.info('CheckoutSession updated on remove cross-sell', { id: req.params.id });
937
+
938
+ // recalculate amount
939
+ await checkoutSession.update(await getCheckoutSessionAmounts(checkoutSession));
940
+ }
941
+
942
+ const items = await Price.expand(checkoutSession.line_items, { upsell: true });
943
+ res.json({ ...checkoutSession.toJSON(), line_items: items });
944
+ } catch (err) {
945
+ console.error(err);
946
+ res.status(500).json({ error: err.message });
947
+ }
812
948
  });
813
949
 
814
950
  export default router;
@@ -44,6 +44,7 @@ const formatBeforeSave = (payload: any) => {
44
44
  factory: '',
45
45
  },
46
46
  submit_type: 'pay',
47
+ cross_sell_behavior: 'auto',
47
48
  },
48
49
  pick(payload, [
49
50
  'name',
@@ -60,6 +61,7 @@ const formatBeforeSave = (payload: any) => {
60
61
  'submit_type',
61
62
  'subscription_data',
62
63
  'nft_mint_settings',
64
+ 'cross_sell_behavior',
63
65
  'metadata',
64
66
  ])
65
67
  );
@@ -6,6 +6,7 @@ import uniq from 'lodash/uniq';
6
6
  import { Op, WhereOptions } from 'sequelize';
7
7
 
8
8
  import { checkPassportForPricingTable } from '../integrations/blocklet/passport';
9
+ import logger from '../libs/logger';
9
10
  import { authenticate } from '../libs/security';
10
11
  import { isLineItemCurrencyAligned } from '../libs/session';
11
12
  import { formatMetadata } from '../libs/util';
@@ -68,21 +69,23 @@ const formatPricingTable = (payload: any) => {
68
69
  factory: '',
69
70
  },
70
71
  submit_type: 'auto',
72
+ cross_sell_behavior: 'auto',
71
73
  },
72
74
  pick(x, [
73
- 'product_id',
74
- 'price_id',
75
- 'is_highlight',
76
- 'highlight_text',
77
75
  'adjustable_quantity',
78
76
  'after_completion',
79
77
  'allow_promotion_codes',
78
+ 'billing_address_collection',
80
79
  'consent_collection',
80
+ 'cross_sell_behavior',
81
81
  'custom_fields',
82
+ 'highlight_text',
83
+ 'is_highlight',
84
+ 'nft_mint_settings',
82
85
  'phone_number_collection',
83
- 'billing_address_collection',
86
+ 'price_id',
87
+ 'product_id',
84
88
  'submit_type',
85
- 'nft_mint_settings',
86
89
  'subscription_data',
87
90
  ])
88
91
  );
@@ -330,6 +333,7 @@ router.post('/:id/checkout/:priceId', async (req, res) => {
330
333
  'phone_number_collection',
331
334
  'billing_address_collection',
332
335
  'submit_type',
336
+ 'cross_sell_behavior',
333
337
  'nft_mint_settings',
334
338
  'subscription_data',
335
339
  ]),
@@ -347,6 +351,19 @@ router.post('/:id/checkout/:priceId', async (req, res) => {
347
351
  if (req.query.redirect) {
348
352
  raw.success_url = req.query.redirect as string;
349
353
  raw.cancel_url = req.query.redirect as string;
354
+ logger.info('use redirect from query when checkout from pricing table', { v: raw.success_url });
355
+ }
356
+ if (req.query.cross_sell_behavior) {
357
+ raw.cross_sell_behavior = req.query.cross_sell_behavior as string;
358
+ logger.info('use cross_sell_behavior from query when checkout from pricing table', { v: raw.cross_sell_behavior });
359
+ }
360
+ if (req.query.nft_mint_factory) {
361
+ raw.nft_mint_settings = {
362
+ enabled: true,
363
+ behavior: 'per_checkout_session',
364
+ factory: req.query.nft_mint_factory as string,
365
+ };
366
+ logger.info('use nft_mint_settings from query when checkout from pricing table', { v: raw.nft_mint_settings });
350
367
  }
351
368
 
352
369
  const session = await CheckoutSession.create(raw as any);
@@ -140,46 +140,9 @@ router.get('/', auth, async (req, res) => {
140
140
  res.json({ count, list });
141
141
  });
142
142
 
143
- export async function getExpandedProduct(id: string) {
144
- const product = await Product.findOne({
145
- where: { id },
146
- include: [
147
- { model: Price, as: 'prices', include: [{ model: PaymentCurrency, as: 'currency' }] },
148
- { model: Price, as: 'default_price' },
149
- ],
150
- });
151
-
152
- if (product) {
153
- const doc = product.toJSON();
154
- const currencies = await PaymentCurrency.findAll();
155
- // @ts-ignore
156
- for (const price of doc.prices) {
157
- if (Array.isArray(price.currency_options)) {
158
- price.currency_options.forEach((x: any) => {
159
- x.currency = currencies.find((c) => c.id === x.currency_id);
160
- });
161
- const base = price.currency_options.find((x: any) => x.currency_id === price.currency_id);
162
- if (!base) {
163
- price.currency_options.unshift(
164
- pick(price, ['currency_id', 'unit_amount', 'tiers', 'custom_unit_amount', 'currency'])
165
- );
166
- }
167
- } else {
168
- price.currency_options = [
169
- pick(price, ['currency_id', 'unit_amount', 'tiers', 'custom_unit_amount', 'currency']),
170
- ];
171
- }
172
- }
173
-
174
- return doc;
175
- }
176
-
177
- return null;
178
- }
179
-
180
143
  // get product detail
181
144
  router.get('/:id', auth, async (req, res) => {
182
- res.json(await getExpandedProduct(req.params.id as string));
145
+ res.json(await Product.expand(req.params.id as string));
183
146
  });
184
147
 
185
148
  // update product
@@ -205,13 +168,14 @@ router.put('/:id', auth, async (req, res) => {
205
168
  'features',
206
169
  'nft_factory',
207
170
  'metadata',
171
+ 'cross_sell',
208
172
  ]);
209
173
  if (updates.metadata) {
210
174
  updates.metadata = formatMetadata(updates.metadata);
211
175
  }
212
176
  await product.update(updates);
213
177
 
214
- return res.json(await getExpandedProduct(req.params.id as string));
178
+ return res.json(await Product.expand(req.params.id as string));
215
179
  });
216
180
 
217
181
  // archive
@@ -228,7 +192,7 @@ router.put('/:id/archive', auth, async (req, res) => {
228
192
  await product.update({ active: !product.active });
229
193
 
230
194
  // FIXME: deactivate payment-links, pricing-tables
231
- return res.json(await getExpandedProduct(req.params.id as string));
195
+ return res.json(await Product.expand(req.params.id as string));
232
196
  });
233
197
 
234
198
  // delete product
@@ -1,3 +1,5 @@
1
+ import qs from 'querystring';
2
+
1
3
  import { getUrl } from '@blocklet/sdk/lib/component';
2
4
  import { Router } from 'express';
3
5
 
@@ -6,10 +8,10 @@ const router = Router();
6
8
  router.get('/checkout/:entryId', (req, res) => {
7
9
  const { entryId } = req.params;
8
10
  if (entryId.startsWith('plink_')) {
9
- return res.redirect(getUrl(`/checkout/pay/${entryId}?redirect=${req.query.redirect || ''}`));
11
+ return res.redirect(getUrl(`/checkout/pay/${entryId}?${qs.stringify(req.query as any)}`));
10
12
  }
11
13
  if (entryId.startsWith('prctbl_')) {
12
- return res.redirect(getUrl(`/checkout/pricing-table/${entryId}?redirect=${req.query.redirect || ''}`));
14
+ return res.redirect(getUrl(`/checkout/pricing-table/${entryId}?${qs.stringify(req.query as any)}`));
13
15
  }
14
16
 
15
17
  return res.redirect(getUrl('/'));
@@ -0,0 +1,23 @@
1
+ import { DataTypes } from 'sequelize';
2
+
3
+ import type { Migration } from '../migrate';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ await context.addColumn('products', 'cross_sell', { type: DataTypes.JSON, allowNull: true });
7
+ await context.addColumn('checkout_sessions', 'cross_sell_behavior', {
8
+ type: DataTypes.ENUM('auto', 'required'),
9
+ defaultValue: 'auto',
10
+ });
11
+ await context.addColumn('payment_links', 'cross_sell_behavior', {
12
+ type: DataTypes.ENUM('auto', 'required'),
13
+ defaultValue: 'auto',
14
+ });
15
+ await context.bulkUpdate('checkout_sessions', { cross_sell_behavior: 'auto' }, {});
16
+ await context.bulkUpdate('payment_links', { cross_sell_behavior: 'auto' }, {});
17
+ };
18
+
19
+ export const down: Migration = async ({ context }) => {
20
+ await context.removeColumn('products', 'cross_sell');
21
+ await context.removeColumn('checkout_sessions', 'cross_sell_behavior');
22
+ await context.removeColumn('payment_links', 'cross_sell_behavior');
23
+ };
@@ -184,6 +184,8 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
184
184
  declare nft_mint_settings?: NftMintSettings;
185
185
  declare nft_mint_details?: NftMintDetails;
186
186
 
187
+ declare cross_sell_behavior?: LiteralUnion<'auto' | 'required', string>;
188
+
187
189
  // FIXME: Only exist on creation
188
190
  // declare discounts?: {
189
191
  // coupon?: string;
@@ -266,11 +268,6 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
266
268
  type: DataTypes.ENUM('paid', 'unpaid', 'no_payment_required'),
267
269
  allowNull: false,
268
270
  },
269
- nft_mint_status: {
270
- type: DataTypes.ENUM('disabled', 'pending', 'minted', 'sent', 'error'),
271
- defaultValue: 'disabled',
272
- allowNull: false,
273
- },
274
271
  line_items: {
275
272
  type: DataTypes.JSON,
276
273
  allowNull: false,
@@ -399,6 +396,11 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
399
396
  type: DataTypes.JSON,
400
397
  allowNull: true,
401
398
  },
399
+ nft_mint_status: {
400
+ type: DataTypes.ENUM('disabled', 'pending', 'minted', 'sent', 'error'),
401
+ defaultValue: 'disabled',
402
+ allowNull: false,
403
+ },
402
404
  nft_mint_settings: {
403
405
  type: DataTypes.JSON,
404
406
  allowNull: true,
@@ -407,6 +409,10 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
407
409
  type: DataTypes.JSON,
408
410
  allowNull: true,
409
411
  },
412
+ cross_sell_behavior: {
413
+ type: DataTypes.ENUM('auto', 'required'),
414
+ defaultValue: 'auto',
415
+ },
410
416
  },
411
417
  {
412
418
  sequelize,
@@ -97,7 +97,15 @@ export type TPriceExpanded = TPrice & {
97
97
 
98
98
  export type TLineItemExpanded = LineItem & { price: TPriceExpanded; upsell_price: TPriceExpanded };
99
99
 
100
- export type TProductExpanded = TProduct & { object: 'price'; prices: TPrice[]; default_price: TPrice };
100
+ export type TProductExpanded = TProduct & {
101
+ object: 'price';
102
+ prices: TPriceExpanded[];
103
+ default_price: TPriceExpanded;
104
+ cross_sell?: {
105
+ cross_sells_to: TProductExpanded;
106
+ cross_sells_to_id: string;
107
+ };
108
+ };
101
109
 
102
110
  export type TPaymentLinkExpanded = TPaymentLink & { object: 'payment_link'; line_items: TLineItemExpanded[] };
103
111