payment-kit 1.13.17 → 1.13.19

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 (109) hide show
  1. package/README.md +14 -0
  2. package/api/src/index.ts +17 -6
  3. package/api/src/integrations/stripe/handlers/index.ts +53 -0
  4. package/api/src/integrations/stripe/handlers/invoice.ts +252 -0
  5. package/api/src/integrations/stripe/handlers/payment-intent.ts +172 -0
  6. package/api/src/integrations/stripe/handlers/setup-intent.ts +42 -0
  7. package/api/src/integrations/stripe/handlers/subscription.ts +61 -0
  8. package/api/src/integrations/stripe/resource.ts +317 -0
  9. package/api/src/integrations/stripe/setup.ts +50 -0
  10. package/api/src/jobs/invoice.ts +11 -0
  11. package/api/src/jobs/payment.ts +15 -7
  12. package/api/src/jobs/subscription.ts +18 -2
  13. package/api/src/libs/session.ts +104 -8
  14. package/api/src/libs/util.ts +47 -1
  15. package/api/src/routes/checkout-sessions.ts +134 -27
  16. package/api/src/routes/connect/collect.ts +12 -4
  17. package/api/src/routes/connect/pay.ts +30 -20
  18. package/api/src/routes/connect/setup.ts +12 -4
  19. package/api/src/routes/connect/shared.ts +28 -4
  20. package/api/src/routes/connect/subscribe.ts +12 -5
  21. package/api/src/routes/customers.ts +5 -5
  22. package/api/src/routes/events.ts +9 -6
  23. package/api/src/routes/index.ts +2 -0
  24. package/api/src/routes/integrations/stripe.ts +64 -0
  25. package/api/src/routes/invoices.ts +19 -9
  26. package/api/src/routes/payment-intents.ts +19 -9
  27. package/api/src/routes/payment-links.ts +57 -15
  28. package/api/src/routes/payment-methods.ts +98 -1
  29. package/api/src/routes/prices.ts +71 -14
  30. package/api/src/routes/products.ts +79 -22
  31. package/api/src/routes/settings.ts +10 -11
  32. package/api/src/routes/subscription-items.ts +5 -5
  33. package/api/src/routes/subscriptions.ts +61 -10
  34. package/api/src/routes/usage-records.ts +52 -18
  35. package/api/src/routes/webhook-attempts.ts +5 -5
  36. package/api/src/routes/webhook-endpoints.ts +5 -5
  37. package/api/src/store/migrations/20230905-genesis.ts +2 -2
  38. package/api/src/store/migrations/20230911-seeding.ts +4 -3
  39. package/api/src/store/models/checkout-session.ts +15 -7
  40. package/api/src/store/models/index.ts +31 -7
  41. package/api/src/store/models/invoice.ts +1 -1
  42. package/api/src/store/models/payment-intent.ts +2 -5
  43. package/api/src/store/models/payment-link.ts +1 -1
  44. package/api/src/store/models/payment-method.ts +54 -33
  45. package/api/src/store/models/price.ts +52 -17
  46. package/api/src/store/models/product.ts +0 -3
  47. package/api/src/store/models/subscription.ts +3 -5
  48. package/api/src/store/models/types.ts +56 -2
  49. package/api/third.d.ts +2 -0
  50. package/blocklet.yml +1 -1
  51. package/package.json +36 -29
  52. package/public/currencies/dai.png +0 -0
  53. package/public/currencies/dollar.png +0 -0
  54. package/public/currencies/usdc.png +0 -0
  55. package/public/currencies/usdt.png +0 -0
  56. package/public/methods/arcblock.png +0 -0
  57. package/public/methods/binance.png +0 -0
  58. package/public/methods/coinbase.png +0 -0
  59. package/public/methods/ethereum.jpg +0 -0
  60. package/public/methods/stripe.png +0 -0
  61. package/src/components/checkout/form/address.tsx +86 -10
  62. package/src/components/checkout/form/index.tsx +169 -83
  63. package/src/components/checkout/form/phone.tsx +96 -0
  64. package/src/components/checkout/form/stripe.tsx +195 -0
  65. package/src/components/checkout/pay.tsx +115 -34
  66. package/src/components/checkout/product-item.tsx +4 -3
  67. package/src/components/checkout/summary.tsx +5 -4
  68. package/src/components/drawer-form.tsx +4 -4
  69. package/src/components/input.tsx +22 -4
  70. package/src/components/invoice/table.tsx +8 -3
  71. package/src/components/payment-link/before-pay.tsx +11 -6
  72. package/src/components/payment-link/chrome.tsx +13 -0
  73. package/src/components/payment-link/preview.tsx +31 -0
  74. package/src/components/payment-link/product-select.tsx +8 -3
  75. package/src/components/payment-method/arcblock.tsx +53 -0
  76. package/src/components/payment-method/bitcoin.tsx +53 -0
  77. package/src/components/payment-method/ethereum.tsx +53 -0
  78. package/src/components/payment-method/form.tsx +54 -0
  79. package/src/components/payment-method/stripe.tsx +45 -0
  80. package/src/components/portal/invoice/list.tsx +1 -1
  81. package/src/components/portal/subscription/list.tsx +1 -1
  82. package/src/components/price/currency-select.tsx +53 -0
  83. package/src/components/price/form.tsx +118 -24
  84. package/src/components/product/add-price.tsx +1 -1
  85. package/src/components/product/edit-price.tsx +6 -2
  86. package/src/components/subscription/items/index.tsx +7 -6
  87. package/src/components/subscription/items/usage-records.tsx +98 -0
  88. package/src/components/subscription/list.tsx +3 -2
  89. package/src/components/subscription/status.tsx +68 -0
  90. package/src/contexts/settings.tsx +2 -2
  91. package/src/env.d.ts +2 -0
  92. package/src/libs/util.ts +116 -21
  93. package/src/locales/en.tsx +71 -3
  94. package/src/pages/admin/billing/invoices/detail.tsx +5 -2
  95. package/src/pages/admin/billing/subscriptions/detail.tsx +6 -6
  96. package/src/pages/admin/customers/customers/detail.tsx +13 -1
  97. package/src/pages/admin/payments/intents/detail.tsx +8 -3
  98. package/src/pages/admin/payments/links/create.tsx +23 -3
  99. package/src/pages/admin/payments/links/detail.tsx +13 -26
  100. package/src/pages/admin/products/prices/detail.tsx +55 -11
  101. package/src/pages/admin/products/prices/list.tsx +7 -1
  102. package/src/pages/admin/products/products/create.tsx +1 -1
  103. package/src/pages/admin/products/products/detail.tsx +14 -7
  104. package/src/pages/admin/settings/index.tsx +16 -6
  105. package/src/pages/admin/settings/payment-methods/create.tsx +81 -0
  106. package/src/pages/admin/settings/{payment-methods.tsx → payment-methods/index.tsx} +9 -6
  107. package/src/pages/checkout/pay.tsx +3 -1
  108. package/src/pages/customer/index.tsx +12 -1
  109. package/public/.gitkeep +0 -0
@@ -9,10 +9,17 @@ import { subscriptionQueue } from '../../jobs/subscription';
9
9
  import type { CallbackArgs } from '../../libs/auth';
10
10
  import { wallet } from '../../libs/auth';
11
11
  import { getClient } from '../../libs/chain/arcblock';
12
- import { ensureInvoiceForCheckout, ensurePaymentIntent } from './shared';
12
+ import { ensureInvoiceForCheckout, ensurePaymentIntent, getAuthPrincipalClaim } from './shared';
13
13
 
14
14
  export default {
15
15
  action: 'subscription',
16
+ authPrincipal: false,
17
+ claims: {
18
+ authPrincipal: async ({ extraParams }: CallbackArgs) => {
19
+ const { paymentMethod } = await ensurePaymentIntent(extraParams.checkoutSessionId);
20
+ return getAuthPrincipalClaim(paymentMethod, 'pay');
21
+ },
22
+ },
16
23
  onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
17
24
  const { checkoutSessionId } = extraParams;
18
25
  const { checkoutSession, paymentMethod, paymentCurrency, subscription } = await ensurePaymentIntent(
@@ -23,7 +30,6 @@ export default {
23
30
  throw new Error('Subscription for checkoutSession not found');
24
31
  }
25
32
 
26
- // TODO: support multiple chain and multiple currency
27
33
  if (paymentMethod.type === 'arcblock') {
28
34
  if (checkoutSession.amount_total > '0') {
29
35
  const client = getClient(paymentMethod.settings.arcblock?.api_host as string);
@@ -45,7 +51,6 @@ export default {
45
51
  itx: {
46
52
  address: toDelegateAddress(userDid, wallet.address),
47
53
  to: wallet.address,
48
- // FIXME: we need to enforce which token can be transferred, and how much, and at what interval on chain
49
54
  ops: [{ typeUrl: 'fg:t:transfer_v2', rules: [] }],
50
55
  data: {
51
56
  type: 'json',
@@ -104,8 +109,10 @@ export default {
104
109
 
105
110
  await subscription.update({
106
111
  payment_details: {
107
- tx_hash: txHash,
108
- payer: userDid,
112
+ arcblock: {
113
+ tx_hash: txHash,
114
+ payer: userDid,
115
+ },
109
116
  },
110
117
  });
111
118
 
@@ -11,15 +11,15 @@ const auth = authenticate<Customer>({ component: true, roles: ['owner', 'admin']
11
11
 
12
12
  const schema = Joi.object<{
13
13
  page: number;
14
- size: number;
14
+ pageSize: number;
15
15
  livemode?: boolean;
16
16
  }>({
17
17
  page: Joi.number().integer().min(1).default(1),
18
- size: Joi.number().integer().min(1).max(100).default(20),
18
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
19
19
  livemode: Joi.boolean().empty(''),
20
20
  });
21
21
  router.get('/', auth, async (req, res) => {
22
- const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
22
+ const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
23
23
  const where: WhereOptions<Customer> = {};
24
24
 
25
25
  if (typeof query.livemode === 'boolean') {
@@ -30,8 +30,8 @@ router.get('/', auth, async (req, res) => {
30
30
  const { rows: list, count } = await Customer.findAndCountAll({
31
31
  where,
32
32
  order: [['created_at', 'DESC']],
33
- offset: (page - 1) * size,
34
- limit: size,
33
+ offset: (page - 1) * pageSize,
34
+ limit: pageSize,
35
35
  include: [],
36
36
  });
37
37
 
@@ -10,19 +10,19 @@ const auth = authenticate<Event>({ component: true, roles: ['owner', 'admin'] })
10
10
 
11
11
  const schema = Joi.object<{
12
12
  page: number;
13
- size: number;
13
+ pageSize: number;
14
14
  livemode?: boolean;
15
15
  type?: string;
16
16
  object_id?: string;
17
17
  }>({
18
18
  page: Joi.number().integer().min(1).default(1),
19
- size: Joi.number().integer().min(1).max(100).default(20),
19
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
20
20
  livemode: Joi.boolean().empty(''),
21
21
  type: Joi.string().empty(''),
22
22
  object_id: Joi.string().empty(''),
23
23
  });
24
24
  router.get('/', auth, async (req, res) => {
25
- const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
25
+ const { page, pageSize, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
26
26
  const where: WhereOptions<Event> = {};
27
27
 
28
28
  if (query.type) {
@@ -32,7 +32,10 @@ router.get('/', auth, async (req, res) => {
32
32
  .filter(Boolean);
33
33
  }
34
34
  if (query.object_id) {
35
- where.object_id = query.object_id;
35
+ where.object_id = query.object_id
36
+ .split(',')
37
+ .map((x) => x.trim())
38
+ .filter(Boolean);
36
39
  }
37
40
  if (typeof query.livemode === 'boolean') {
38
41
  where.livemode = query.livemode;
@@ -43,8 +46,8 @@ router.get('/', auth, async (req, res) => {
43
46
  where,
44
47
  attributes: { exclude: ['data', 'request'] },
45
48
  order: [['created_at', 'DESC']],
46
- offset: (page - 1) * size,
47
- limit: size,
49
+ offset: (page - 1) * pageSize,
50
+ limit: pageSize,
48
51
  include: [],
49
52
  });
50
53
 
@@ -4,6 +4,7 @@ import { PaymentCurrency } from '../store/models/payment-currency';
4
4
  import checkoutSessions from './checkout-sessions';
5
5
  import customers from './customers';
6
6
  import events from './events';
7
+ import stripe from './integrations/stripe';
7
8
  import invoices from './invoices';
8
9
  import paymentCurrencies from './payment-currencies';
9
10
  import paymentIntents from './payment-intents';
@@ -43,6 +44,7 @@ router.use('/checkout-sessions', checkoutSessions);
43
44
  router.use('/customers', customers);
44
45
  router.use('/events', events);
45
46
  router.use('/invoices', invoices);
47
+ router.use('/integrations/stripe', stripe);
46
48
  router.use('/payment-intents', paymentIntents);
47
49
  router.use('/payment-links', paymentLinks);
48
50
  router.use('/payment-methods', paymentMethods);
@@ -0,0 +1,64 @@
1
+ import env from '@blocklet/sdk/lib/env';
2
+ import express, { NextFunction, Request, Response, Router } from 'express';
3
+ import get from 'lodash/get';
4
+
5
+ import handleStripeEvent from '../../integrations/stripe/handlers';
6
+ import logger from '../../libs/logger';
7
+ import { STRIPE_EVENTS } from '../../libs/util';
8
+ import { PaymentMethod } from '../../store/models';
9
+
10
+ const router = Router();
11
+
12
+ const verifyWebhookSig = async (req: Request, res: Response, next: NextFunction) => {
13
+ try {
14
+ const signature = req.get('stripe-signature');
15
+ if (!signature) {
16
+ return res.status(400).json({ error: 'No stripe webhook signature found' });
17
+ }
18
+
19
+ const json = JSON.parse(req.body.toString('utf8'));
20
+ const method = await PaymentMethod.findOne({ where: { type: 'stripe', livemode: json.livemode } });
21
+ if (!method) {
22
+ return res.status(400).json({ error: 'No stripe payment method found' });
23
+ }
24
+
25
+ const stripe = method.getStripe();
26
+ const settings = PaymentMethod.decryptSettings(method.settings);
27
+ const secret =
28
+ process.env.BLOCKLET_MODE === 'development' && process.env.STRIPE_WEBHOOK_SECRET
29
+ ? process.env.STRIPE_WEBHOOK_SECRET
30
+ : settings.stripe?.webhook_signing_secret;
31
+ req.stripeEvent = stripe.webhooks.constructEvent(req.body, signature, secret as string);
32
+ req.stripeClient = stripe;
33
+
34
+ return next();
35
+ } catch (err) {
36
+ logger.error('verify signature error', { error: err });
37
+ return res.status(400).json({ error: err.message });
38
+ }
39
+ };
40
+
41
+ const handleEvent = async (req: Request, res: Response) => {
42
+ const { stripeEvent, stripeClient } = req;
43
+
44
+ if (STRIPE_EVENTS.includes(stripeEvent.type) === false) {
45
+ logger.debug('webhook event not interested', { id: stripeEvent.id, type: stripeEvent.type });
46
+ return res.status(400).json({ error: 'Not implemented' });
47
+ }
48
+
49
+ // only events from this app should be processed
50
+ const appPid = get(stripeEvent, 'data.object.metadata.appPid');
51
+ if (appPid && appPid !== env.appPid) {
52
+ logger.debug('webhook event for other app', { id: stripeEvent.id, type: stripeEvent.type });
53
+ return res.json({ received: true });
54
+ }
55
+
56
+ logger.debug('webhook received event', { id: stripeEvent.id, type: stripeEvent.type });
57
+ await handleStripeEvent(stripeEvent, stripeClient);
58
+
59
+ return res.json({ received: true });
60
+ };
61
+
62
+ router.post('/webhook', express.raw({ type: 'application/json' }), verifyWebhookSig, handleEvent);
63
+
64
+ export default router;
@@ -29,7 +29,7 @@ const authPortal = authenticate<Invoice>({
29
29
 
30
30
  const schema = Joi.object<{
31
31
  page: number;
32
- size: number;
32
+ pageSize: number;
33
33
  livemode?: boolean;
34
34
  status?: string;
35
35
  customer_id?: string;
@@ -37,7 +37,7 @@ const schema = Joi.object<{
37
37
  subscription_id?: string;
38
38
  }>({
39
39
  page: Joi.number().integer().min(1).default(1),
40
- size: Joi.number().integer().min(1).max(100).default(20),
40
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
41
41
  livemode: Joi.boolean().empty(''),
42
42
  status: Joi.string().empty(''),
43
43
  customer_id: Joi.string().empty(''),
@@ -45,11 +45,14 @@ const schema = Joi.object<{
45
45
  subscription_id: Joi.string().empty(''),
46
46
  });
47
47
  router.get('/', authMine, async (req, res) => {
48
- const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
48
+ const { page, pageSize, livemode, status, ...query } = await schema.validateAsync(req.query, {
49
+ stripUnknown: false,
50
+ allowUnknown: true,
51
+ });
49
52
  const where: WhereOptions<Invoice> = {};
50
53
 
51
- if (query.status) {
52
- where.status = query.status
54
+ if (status) {
55
+ where.status = status
53
56
  .split(',')
54
57
  .map((x) => x.trim())
55
58
  .filter(Boolean);
@@ -66,16 +69,23 @@ router.get('/', authMine, async (req, res) => {
66
69
  if (query.subscription_id) {
67
70
  where.subscription_id = query.subscription_id;
68
71
  }
69
- if (typeof query.livemode === 'boolean') {
70
- where.livemode = query.livemode;
72
+ if (typeof livemode === 'boolean') {
73
+ where.livemode = livemode;
71
74
  }
72
75
 
76
+ Object.keys(query)
77
+ .filter((x) => x.startsWith('metadata.'))
78
+ .forEach((key: string) => {
79
+ // @ts-ignore
80
+ where[key] = query[key];
81
+ });
82
+
73
83
  try {
74
84
  const { rows: list, count } = await Invoice.findAndCountAll({
75
85
  where,
76
86
  order: [['created_at', 'DESC']],
77
- offset: (page - 1) * size,
78
- limit: size,
87
+ offset: (page - 1) * pageSize,
88
+ limit: pageSize,
79
89
  include: [
80
90
  { model: PaymentCurrency, as: 'paymentCurrency' },
81
91
  // { model: PaymentMethod, as: 'paymentMethod' },
@@ -27,7 +27,7 @@ const authPortal = authenticate<PaymentIntent>({
27
27
  // list payment links
28
28
  const paginationSchema = Joi.object<{
29
29
  page: number;
30
- size: number;
30
+ pageSize: number;
31
31
  status?: string;
32
32
  livemode?: boolean;
33
33
  invoice_id?: string;
@@ -35,7 +35,7 @@ const paginationSchema = Joi.object<{
35
35
  customer_did?: string;
36
36
  }>({
37
37
  page: Joi.number().integer().min(1).default(1),
38
- size: Joi.number().integer().min(1).max(100).default(20),
38
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
39
39
  status: Joi.string().empty(''),
40
40
  livemode: Joi.boolean().empty(''),
41
41
  invoice_id: Joi.string().empty(''),
@@ -43,11 +43,14 @@ const paginationSchema = Joi.object<{
43
43
  customer_did: Joi.string().empty(''),
44
44
  });
45
45
  router.get('/', authMine, async (req, res) => {
46
- const { page, size, ...query } = await paginationSchema.validateAsync(req.query, { stripUnknown: true });
46
+ const { page, pageSize, status, livemode, ...query } = await paginationSchema.validateAsync(req.query, {
47
+ stripUnknown: false,
48
+ allowUnknown: true,
49
+ });
47
50
  const where: WhereOptions<PaymentIntent> = {};
48
51
 
49
- if (query.status) {
50
- where.status = query.status
52
+ if (status) {
53
+ where.status = status
51
54
  .split(',')
52
55
  .map((x) => x.trim())
53
56
  .filter(Boolean);
@@ -64,16 +67,23 @@ router.get('/', authMine, async (req, res) => {
64
67
  if (query.invoice_id) {
65
68
  where.invoice_id = query.invoice_id;
66
69
  }
67
- if (typeof query.livemode === 'boolean') {
68
- where.livemode = query.livemode;
70
+ if (typeof livemode === 'boolean') {
71
+ where.livemode = livemode;
69
72
  }
70
73
 
74
+ Object.keys(query)
75
+ .filter((x) => x.startsWith('metadata.'))
76
+ .forEach((key: string) => {
77
+ // @ts-ignore
78
+ where[key] = query[key];
79
+ });
80
+
71
81
  try {
72
82
  const { rows: list, count } = await PaymentIntent.findAndCountAll({
73
83
  where,
74
84
  order: [['created_at', 'DESC']],
75
- offset: (page - 1) * size,
76
- limit: size,
85
+ offset: (page - 1) * pageSize,
86
+ limit: pageSize,
77
87
  include: [
78
88
  { model: PaymentCurrency, as: 'paymentCurrency' },
79
89
  { model: PaymentMethod, as: 'paymentMethod' },
@@ -2,9 +2,10 @@ import { Router } from 'express';
2
2
  import Joi from 'joi';
3
3
  // import isEmpty from 'lodash/isEmpty';
4
4
  import pick from 'lodash/pick';
5
- import type { WhereOptions } from 'sequelize';
5
+ import { Op, WhereOptions } from 'sequelize';
6
6
 
7
7
  import { authenticate } from '../libs/security';
8
+ import { isLineItemAligned } from '../libs/session';
8
9
  import { formatMetadata } from '../libs/util';
9
10
  import { PaymentLink } from '../store/models/payment-link';
10
11
  import { Price } from '../store/models/price';
@@ -29,7 +30,6 @@ const formatBeforeSave = (payload: any) => {
29
30
  terms_of_service: 'none',
30
31
  },
31
32
  invoice_creation: {
32
- // FIXME: force to true if we are subscription
33
33
  enabled: false,
34
34
  },
35
35
  phone_number_collection: {
@@ -60,13 +60,16 @@ const formatBeforeSave = (payload: any) => {
60
60
  ])
61
61
  );
62
62
  if (raw.after_completion?.type === 'hosted_confirmation') {
63
- delete raw.after_completion?.redirect;
63
+ // @ts-ignore
64
+ raw.after_completion.redirect = null;
64
65
  }
65
66
  if (raw.after_completion?.type === 'redirect') {
66
- delete raw.after_completion?.hosted_confirmation;
67
+ // @ts-ignore
68
+ raw.after_completion.hosted_confirmation = null;
67
69
  }
68
70
  if (!payload.include_free_trial) {
69
- delete raw.subscription_data;
71
+ // @ts-ignore
72
+ raw.subscription_data = null;
70
73
  }
71
74
 
72
75
  raw.line_items?.forEach((x) => {
@@ -81,8 +84,8 @@ const formatBeforeSave = (payload: any) => {
81
84
  return raw;
82
85
  };
83
86
 
84
- // create payment link
85
87
  // FIXME: @wangshijun use schema validation
88
+ // eslint-disable-next-line consistent-return
86
89
  router.post('/', auth, async (req, res) => {
87
90
  const raw: Partial<PaymentLink> = formatBeforeSave(req.body);
88
91
  raw.active = true;
@@ -90,10 +93,26 @@ router.post('/', auth, async (req, res) => {
90
93
  raw.created_via = req.user?.via;
91
94
  raw.currency_id = raw.currency_id || req.currency.id;
92
95
 
93
- const link = await PaymentLink.create(raw as PaymentLink);
96
+ if (!raw.line_items?.length) {
97
+ return res.status(400).json({ error: 'line_items should not be empty for payment link' });
98
+ }
99
+
100
+ const items = await Price.expand(raw.line_items);
101
+ for (let i = 0; i < items.length; i++) {
102
+ const result = isLineItemAligned(items, i);
103
+ if (result.currency === false) {
104
+ return res.status(400).json({ error: 'line_items should have same currency' });
105
+ }
106
+ if (result.recurring === false) {
107
+ return res.status(400).json({ error: 'line_items should have same recurring' });
108
+ }
109
+ }
94
110
 
95
- // lock prices
96
- await Promise.all(link.line_items.map((x) => Price.update({ locked: true }, { where: { id: x.price_id } })));
111
+ if (items.some((x) => x.price.type === 'recurring')) {
112
+ raw.invoice_creation = { enabled: true };
113
+ }
114
+
115
+ const link = await PaymentLink.create(raw as PaymentLink);
97
116
 
98
117
  res.json(link);
99
118
  });
@@ -101,18 +120,18 @@ router.post('/', auth, async (req, res) => {
101
120
  // list payment links
102
121
  const paginationSchema = Joi.object<{
103
122
  page: number;
104
- size: number;
123
+ pageSize: number;
105
124
  active?: boolean;
106
125
  livemode?: boolean;
107
126
  }>({
108
127
  page: Joi.number().integer().min(1).default(1),
109
- size: Joi.number().integer().min(1).max(100).default(20),
128
+ pageSize: Joi.number().integer().min(1).max(100).default(20),
110
129
  active: Joi.boolean().empty(''),
111
130
  livemode: Joi.boolean().empty(''),
112
131
  });
113
132
  router.get('/', auth, async (req, res) => {
114
- const { page, size, ...query } = await paginationSchema.validateAsync(req.query, { stripUnknown: true });
115
- const where: WhereOptions<PaymentLink> = {};
133
+ const { page, pageSize, ...query } = await paginationSchema.validateAsync(req.query, { stripUnknown: true });
134
+ const where: WhereOptions<PaymentLink> = { id: { [Op.notIn]: [`plink_${req.user?.did}`] } };
116
135
 
117
136
  if (typeof query.active === 'boolean') {
118
137
  where.active = query.active;
@@ -125,8 +144,8 @@ router.get('/', auth, async (req, res) => {
125
144
  const { rows: list, count } = await PaymentLink.findAndCountAll({
126
145
  where,
127
146
  order: [['created_at', 'DESC']],
128
- offset: (page - 1) * size,
129
- limit: size,
147
+ offset: (page - 1) * pageSize,
148
+ limit: pageSize,
130
149
  include: [],
131
150
  });
132
151
 
@@ -218,4 +237,27 @@ router.delete('/:id', auth, async (req, res) => {
218
237
  return res.json(doc);
219
238
  });
220
239
 
240
+ router.post('/stash', auth, async (req, res) => {
241
+ try {
242
+ const raw: Partial<PaymentLink> = req.body;
243
+ raw.id = `plink_${req.user?.did}`;
244
+ raw.active = true;
245
+ raw.livemode = !!req.livemode;
246
+ raw.created_via = req.user?.via;
247
+ raw.created_via = 'portal';
248
+ raw.currency_id = raw.currency_id || req.currency.id;
249
+
250
+ let doc = await PaymentLink.findByPk(raw.id);
251
+ if (doc) {
252
+ await doc.update(formatBeforeSave(req.body));
253
+ } else {
254
+ doc = await PaymentLink.create(raw as PaymentLink);
255
+ }
256
+ res.json(doc);
257
+ } catch (err) {
258
+ console.error(err);
259
+ res.status(500).json({ error: err.message });
260
+ }
261
+ });
262
+
221
263
  export default router;
@@ -1,14 +1,94 @@
1
+ import { getUrl } from '@blocklet/sdk/lib/component';
1
2
  import { Router } from 'express';
2
3
  import { InferAttributes, Op, WhereOptions } from 'sequelize';
3
4
 
5
+ import { ensureWebhookRegistered } from '../integrations/stripe/setup';
4
6
  import { authenticate } from '../libs/security';
5
7
  import { PaymentCurrency } from '../store/models/payment-currency';
6
- import { PaymentMethod } from '../store/models/payment-method';
8
+ import { PaymentMethod, TPaymentMethod } from '../store/models/payment-method';
7
9
 
8
10
  const router = Router();
9
11
 
10
12
  const auth = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'] });
11
13
 
14
+ router.post('/', auth, async (req, res) => {
15
+ const raw: Partial<TPaymentMethod> = req.body;
16
+
17
+ raw.livemode = req.livemode;
18
+ raw.locked = false;
19
+ raw.active = true;
20
+
21
+ if (!raw.name) {
22
+ return res.status(400).json({ error: 'payment method name is required' });
23
+ }
24
+ if (!raw.description) {
25
+ return res.status(400).json({ error: 'payment method description is required' });
26
+ }
27
+
28
+ if (!raw.settings) {
29
+ return res.status(400).json({ error: 'payment method settings is required' });
30
+ }
31
+
32
+ if (raw.type === 'stripe') {
33
+ const exist = await PaymentMethod.findOne({ where: { type: 'stripe', livemode: raw.livemode } });
34
+ if (exist) {
35
+ return res.status(400).json({ error: 'stripe payment method already exist' });
36
+ }
37
+
38
+ if (!raw.settings.stripe?.publishable_key) {
39
+ return res.status(400).json({ error: 'stripe publishable key is required' });
40
+ }
41
+ if (!raw.settings.stripe?.secret_key) {
42
+ return res.status(400).json({ error: 'stripe secret key is required' });
43
+ }
44
+
45
+ raw.settings = PaymentMethod.encryptSettings(raw.settings);
46
+ raw.logo = getUrl('/methods/stripe.png');
47
+ raw.features = {
48
+ recurring: true,
49
+ refund: true,
50
+ dispute: true,
51
+ };
52
+ raw.confirmation = {
53
+ type: 'callback',
54
+ };
55
+
56
+ const method = await PaymentMethod.create(raw as TPaymentMethod);
57
+
58
+ // create default currency
59
+ // FIXME: make this configurable
60
+ const currency = await PaymentCurrency.create({
61
+ livemode: method.livemode,
62
+ active: method.active,
63
+ locked: false,
64
+ is_base_currency: false,
65
+ payment_method_id: method.id,
66
+
67
+ name: 'Dollar',
68
+ description: 'US Dollar',
69
+ logo: getUrl('/currencies/dollar.png'),
70
+ symbol: 'USD', // same currency code as stripe
71
+ decimal: 2,
72
+
73
+ minimum_payment_amount: '1', // cent
74
+ maximum_payment_amount: '100000000000', // billion
75
+
76
+ contract: '',
77
+ metadata: {},
78
+ });
79
+
80
+ await method.update({ default_currency_id: currency.id });
81
+
82
+ ensureWebhookRegistered().catch(console.error);
83
+
84
+ return res.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
85
+ }
86
+
87
+ // FIXME: support add more payment methods
88
+
89
+ return res.status(400).json({ error: 'payment method type is not supported' });
90
+ });
91
+
12
92
  router.get('/', auth, async (req, res) => {
13
93
  const { query } = req;
14
94
  const where: WhereOptions<InferAttributes<PaymentMethod>> = {};
@@ -28,6 +108,23 @@ router.get('/', auth, async (req, res) => {
28
108
  res.json(list);
29
109
  });
30
110
 
111
+ router.get('/types', auth, (_, res) => {
112
+ res.json([
113
+ {
114
+ type: 'arcblock',
115
+ name: 'ArcBlock',
116
+ description: 'Instant payment with ArcBlock chain',
117
+ logo: getUrl('/methods/arcblock.png'),
118
+ },
119
+ {
120
+ type: 'stripe',
121
+ name: 'Stripe',
122
+ description: 'Pay with credit card or bank account',
123
+ logo: getUrl('/methods/stripe.png'),
124
+ },
125
+ ]);
126
+ });
127
+
31
128
  router.get('/:id', auth, async (req, res) => {
32
129
  const doc = await PaymentMethod.findOne({
33
130
  where: { [Op.or]: [{ id: req.params.id }, { name: req.params.id }] },