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.
Files changed (48) hide show
  1. package/README.md +4 -0
  2. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  3. package/api/src/integrations/stripe/handlers/payment-intent.ts +2 -2
  4. package/api/src/integrations/stripe/handlers/setup-intent.ts +1 -1
  5. package/api/src/integrations/stripe/handlers/subscription.ts +2 -2
  6. package/api/src/jobs/event.ts +10 -4
  7. package/api/src/jobs/webhook.ts +17 -8
  8. package/api/src/libs/audit.ts +3 -3
  9. package/api/src/libs/event.ts +3 -0
  10. package/api/src/libs/util.ts +5 -0
  11. package/api/src/routes/checkout-sessions.ts +3 -3
  12. package/api/src/routes/connect/pay.ts +1 -1
  13. package/api/src/routes/index.ts +2 -0
  14. package/api/src/routes/payment-links.ts +0 -1
  15. package/api/src/routes/pricing-table.ts +342 -0
  16. package/api/src/routes/subscriptions.ts +15 -0
  17. package/api/src/store/migrations/20231017-pricing-table.ts +10 -0
  18. package/api/src/store/models/index.ts +14 -1
  19. package/api/src/store/models/pricing-table.ts +107 -0
  20. package/api/src/store/models/types.ts +53 -0
  21. package/blocklet.yml +2 -2
  22. package/package.json +4 -3
  23. package/src/app.tsx +1 -1
  24. package/src/components/blockchain/tx.tsx +8 -0
  25. package/src/components/payment-link/actions.tsx +20 -9
  26. package/src/components/payment-link/chrome.tsx +5 -3
  27. package/src/components/payment-link/preview.tsx +8 -5
  28. package/src/components/payment-link/rename.tsx +3 -3
  29. package/src/components/price/form.tsx +4 -1
  30. package/src/components/pricing-table/actions.tsx +126 -0
  31. package/src/components/pricing-table/customer-settings.tsx +17 -0
  32. package/src/components/pricing-table/payment-settings.tsx +179 -0
  33. package/src/components/pricing-table/preview.tsx +34 -0
  34. package/src/components/pricing-table/price-item.tsx +64 -0
  35. package/src/components/pricing-table/product-item.tsx +86 -0
  36. package/src/components/pricing-table/product-settings.tsx +195 -0
  37. package/src/components/pricing-table/rename.tsx +67 -0
  38. package/src/libs/util.ts +54 -5
  39. package/src/locales/en.tsx +28 -0
  40. package/src/pages/admin/payments/links/create.tsx +1 -1
  41. package/src/pages/admin/products/index.tsx +8 -13
  42. package/src/pages/admin/products/pricing-tables/create.tsx +140 -0
  43. package/src/pages/admin/products/pricing-tables/detail.tsx +237 -0
  44. package/src/pages/admin/products/pricing-tables/index.tsx +154 -0
  45. package/src/pages/admin/products/products/create.tsx +8 -4
  46. package/src/pages/checkout/index.tsx +2 -1
  47. package/src/pages/checkout/pricing-table.tsx +195 -0
  48. 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', { localInvoiceId, error: err });
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', { localInvoiceId });
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', { localIntentId, error: err });
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', { localIntentId });
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', { localSubscriptionId });
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
 
@@ -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 { getJobId, webhookQueue } from './webhook';
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: getJobId(event.id, webhook.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 events = await Event.findAll({
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
- events.forEach(async (x) => {
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
+ });
@@ -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, md5 } from '../libs/util';
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: event.toJSON(),
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(event.toJSON()),
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: getJobId(event.id, webhook.id),
99
+ id: getWebhookJobId(event.id, webhook.id),
91
100
  job: { eventId: event.id, webhookId: webhook.id },
92
101
  runAt: getNextRetry(retryCount),
93
102
  });
@@ -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
- eventQueue.push({ id: event.id, job: { eventId: event.id } });
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
- eventQueue.push({ id: event.id, job: { eventId: event.id } });
72
+ events.emit('event.created', { id: event.id });
73
73
  }
@@ -0,0 +1,3 @@
1
+ import EventEmitter from 'events';
2
+
3
+ export const events = new EventEmitter();
@@ -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 formatBeforeSave = async (payload: any, throwOnEmptyItems = true) => {
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 formatBeforeSave(req.body);
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 formatBeforeSave(link, false);
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: 'pay',
11
+ action: 'payment',
12
12
  authPrincipal: false,
13
13
  claims: {
14
14
  authPrincipal: async ({ extraParams }: CallbackArgs) => {
@@ -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
+ };