payment-kit 1.13.73 → 1.13.75

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 (65) hide show
  1. package/api/src/{schedule → crons}/base.ts +1 -1
  2. package/api/src/index.ts +7 -7
  3. package/api/src/integrations/stripe/handlers/customer.ts +24 -0
  4. package/api/src/integrations/stripe/handlers/index.ts +4 -0
  5. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -1
  6. package/api/src/integrations/stripe/resource.ts +1 -1
  7. package/api/src/libs/audit.ts +34 -28
  8. package/api/src/libs/payment.ts +26 -0
  9. package/api/src/libs/queue/index.ts +18 -1
  10. package/api/src/libs/queue/store.ts +6 -5
  11. package/api/src/libs/session.ts +13 -12
  12. package/api/src/libs/subscription.ts +26 -0
  13. package/api/src/libs/util.ts +5 -1
  14. package/api/src/{jobs → queues}/checkout-session.ts +11 -0
  15. package/api/src/{jobs → queues}/invoice.ts +15 -6
  16. package/api/src/{jobs → queues}/payment.ts +182 -30
  17. package/api/src/{jobs → queues}/subscription.ts +36 -104
  18. package/api/src/{jobs → queues}/webhook.ts +2 -0
  19. package/api/src/routes/checkout-sessions.ts +68 -19
  20. package/api/src/routes/connect/collect.ts +2 -2
  21. package/api/src/routes/connect/pay.ts +1 -1
  22. package/api/src/routes/connect/setup.ts +2 -2
  23. package/api/src/routes/connect/shared.ts +94 -45
  24. package/api/src/routes/connect/subscribe.ts +3 -3
  25. package/api/src/routes/pricing-table.ts +2 -0
  26. package/api/src/routes/subscription-items.ts +1 -1
  27. package/api/src/routes/subscriptions.ts +439 -13
  28. package/api/src/store/migrate.ts +0 -1
  29. package/api/src/store/migrations/20231204-subupdate.ts +50 -0
  30. package/api/src/store/models/checkout-session.ts +4 -0
  31. package/api/src/store/models/customer.ts +52 -15
  32. package/api/src/store/models/invoice-item.ts +6 -1
  33. package/api/src/store/models/invoice.ts +41 -22
  34. package/api/src/store/models/payment-intent.ts +4 -0
  35. package/api/src/store/models/setup-intent.ts +4 -0
  36. package/api/src/store/models/subscription-item.ts +0 -4
  37. package/api/src/store/models/subscription.ts +77 -44
  38. package/api/src/store/models/types.ts +1 -0
  39. package/api/src/store/sequelize.ts +6 -0
  40. package/api/third.d.ts +2 -0
  41. package/blocklet.yml +1 -1
  42. package/jest.config.js +14 -0
  43. package/package.json +24 -19
  44. package/src/components/blockchain/tx.tsx +20 -11
  45. package/src/components/checkout/form/index.tsx +1 -1
  46. package/src/components/invoice/table.tsx +58 -19
  47. package/src/components/layout/admin.tsx +17 -5
  48. package/src/components/portal/invoice/list.tsx +12 -8
  49. package/src/components/portal/subscription/list.tsx +114 -77
  50. package/src/components/subscription/status.tsx +21 -19
  51. package/src/global.css +4 -0
  52. package/src/locales/en.tsx +14 -1
  53. package/src/locales/zh.tsx +14 -0
  54. package/src/pages/admin/customers/customers/detail.tsx +47 -3
  55. package/src/pages/admin/overview.tsx +21 -1
  56. package/src/pages/admin/payments/intents/detail.tsx +12 -3
  57. package/src/pages/customer/invoice.tsx +15 -1
  58. package/src/pages/customer/subscription/index.tsx +9 -2
  59. package/tests/api/libs/subscription.spec.ts +45 -0
  60. /package/api/src/{schedule → crons}/index.ts +0 -0
  61. /package/api/src/{schedule → crons}/interface/diff.ts +0 -0
  62. /package/api/src/{schedule → crons}/subscription-trail-will-end.ts +0 -0
  63. /package/api/src/{schedule → crons}/subscription-will-renew.ts +0 -0
  64. /package/api/src/{jobs → queues}/event.ts +0 -0
  65. /package/api/src/{jobs → queues}/notification.ts +0 -0
@@ -2,10 +2,10 @@ import dayjs from 'dayjs';
2
2
  import { clone } from 'lodash';
3
3
  import pAll from 'p-all';
4
4
 
5
- import { NotificationQueueJob, notificationQueue } from '../jobs/notification';
6
5
  import { notificationCronConcurrency } from '../libs/env';
7
6
  import logger from '../libs/logger';
8
7
  import type { SubscriptionTrialWillEndEmailTemplateOptions } from '../libs/notification/template/subscription-trial-will-end';
8
+ import { NotificationQueueJob, notificationQueue } from '../queues/notification';
9
9
  import type { Subscription } from '../store/models';
10
10
  import type { Diff } from './interface/diff';
11
11
 
package/api/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import 'express-async-errors';
2
2
 
3
- import './schedule';
3
+ import './crons';
4
4
 
5
5
  import path from 'path';
6
6
 
@@ -13,15 +13,15 @@ import morgan from 'morgan';
13
13
 
14
14
  import { ensureStakedForGas } from './integrations/blockchain/stake';
15
15
  import { ensureWebhookRegistered } from './integrations/stripe/setup';
16
- import { startCheckoutSessionQueue } from './jobs/checkout-session';
17
- import { startEventQueue } from './jobs/event';
18
- import { startInvoiceQueue } from './jobs/invoice';
19
- import { startNotificationQueue } from './jobs/notification';
20
- import { startPaymentQueue } from './jobs/payment';
21
- import { startSubscriptionQueue } from './jobs/subscription';
22
16
  import { handlers } from './libs/auth';
23
17
  import logger, { accessLogStream } from './libs/logger';
24
18
  import { ensureI18n } from './libs/middleware';
19
+ import { startCheckoutSessionQueue } from './queues/checkout-session';
20
+ import { startEventQueue } from './queues/event';
21
+ import { startInvoiceQueue } from './queues/invoice';
22
+ import { startNotificationQueue } from './queues/notification';
23
+ import { startPaymentQueue } from './queues/payment';
24
+ import { startSubscriptionQueue } from './queues/subscription';
25
25
  import routes from './routes';
26
26
  import collectHandlers from './routes/connect/collect';
27
27
  import payHandlers from './routes/connect/pay';
@@ -0,0 +1,24 @@
1
+ import logger from '../../../libs/logger';
2
+ import { Customer, TEventExpanded } from '../../../store/models';
3
+
4
+ export async function handleCustomerEvent(event: TEventExpanded) {
5
+ const localId = event.data.object.metadata?.id;
6
+ if (!localId) {
7
+ logger.warn('local customer id not found in strip event', { id: event.id, type: event.type });
8
+ return;
9
+ }
10
+
11
+ const customer = await Customer.findByPk(localId);
12
+ if (!customer) {
13
+ logger.warn('local customer not found', { localId });
14
+ return;
15
+ }
16
+
17
+ logger.info('received customer event', { id: event.id, type: event.type, localId });
18
+
19
+ if (event.type === 'customer.updated') {
20
+ if (event.data.object.balance !== customer.balance) {
21
+ await customer.update({ balance: event.data.object.balance });
22
+ }
23
+ }
24
+ }
@@ -1,5 +1,6 @@
1
1
  import type Stripe from 'stripe';
2
2
 
3
+ import { handleCustomerEvent } from './customer';
3
4
  import { handleInvoiceEvent } from './invoice';
4
5
  import { handlePaymentIntentEvent } from './payment-intent';
5
6
  import { handleSetupIntentEvent } from './setup-intent';
@@ -32,6 +33,9 @@ export default function handleStripeEvent(event: any, client: Stripe) {
32
33
  case 'customer.subscription.updated':
33
34
  return handleSubscriptionEvent(event, client);
34
35
 
36
+ case 'customer.updated':
37
+ return handleCustomerEvent(event);
38
+
35
39
  case 'invoice.created':
36
40
  case 'invoice.deleted':
37
41
  case 'invoice.finalization_failed':
@@ -4,9 +4,9 @@ import pick from 'lodash/pick';
4
4
  import pWaitFor from 'p-wait-for';
5
5
  import type Stripe from 'stripe';
6
6
 
7
- import { handlePaymentSucceed } from '../../../jobs/payment';
8
7
  import dayjs from '../../../libs/dayjs';
9
8
  import logger from '../../../libs/logger';
9
+ import { handlePaymentSucceed } from '../../../queues/payment';
10
10
  import { Invoice, PaymentIntent, PaymentMethod, TEventExpanded } from '../../../store/models';
11
11
  import { handleStripeInvoiceCreated } from './invoice';
12
12
 
@@ -90,7 +90,7 @@ export async function ensureStripePrice(internal: Price, method: PaymentMethod,
90
90
  if (internal.custom_unit_amount) {
91
91
  attrs.custom_unit_amount = internal.custom_unit_amount;
92
92
  } else {
93
- attrs.unit_amount = Number(getPriceUintAmountByCurrency(internal, currency));
93
+ attrs.unit_amount = Number(getPriceUintAmountByCurrency(internal, currency.id));
94
94
  }
95
95
 
96
96
  // create stripe price
@@ -16,21 +16,24 @@ export async function createEvent(scope: string, type: LiteralUnion<EventType, s
16
16
  data.previous_attributes = pick(model._previousDataValues, options.fields);
17
17
  }
18
18
 
19
- const event = await Event.create({
20
- type,
21
- api_version: API_VERSION,
22
- livemode: !!model.livemode,
23
- object_id: model.id,
24
- object_type: scope,
25
- data,
26
- request: {
27
- // FIXME:
28
- id: '',
29
- idempotency_key: '',
19
+ const event = await Event.create(
20
+ {
21
+ type,
22
+ api_version: API_VERSION,
23
+ livemode: !!model.livemode,
24
+ object_id: model.id,
25
+ object_type: scope,
26
+ data,
27
+ request: {
28
+ // FIXME:
29
+ id: '',
30
+ idempotency_key: '',
31
+ },
32
+ metadata: {},
33
+ pending_webhooks: 99, // force all events goto the event queue
30
34
  },
31
- metadata: {},
32
- pending_webhooks: 99, // force all events goto the event queue
33
- });
35
+ { transaction: null }
36
+ );
34
37
 
35
38
  events.emit('event.created', { id: event.id });
36
39
  events.emit(event.type, data.object);
@@ -58,21 +61,24 @@ export async function createStatusEvent(
58
61
  }
59
62
 
60
63
  const suffix = config[data.object.status];
61
- const event = await Event.create({
62
- type: [prefix, suffix].join('.'),
63
- api_version: API_VERSION,
64
- livemode: !!model.livemode,
65
- object_id: model.id,
66
- object_type: scope,
67
- data,
68
- request: {
69
- // FIXME:
70
- id: '',
71
- idempotency_key: '',
64
+ const event = await Event.create(
65
+ {
66
+ type: [prefix, suffix].join('.'),
67
+ api_version: API_VERSION,
68
+ livemode: !!model.livemode,
69
+ object_id: model.id,
70
+ object_type: scope,
71
+ data,
72
+ request: {
73
+ // FIXME:
74
+ id: '',
75
+ idempotency_key: '',
76
+ },
77
+ metadata: {},
78
+ pending_webhooks: 99, // force all events goto the event queue
72
79
  },
73
- metadata: {},
74
- pending_webhooks: 99, // force all events goto the event queue
75
- });
80
+ { transaction: null }
81
+ );
76
82
 
77
83
  events.emit('event.created', { id: event.id });
78
84
  events.emit(event.type, data.object);
@@ -14,6 +14,7 @@ import {
14
14
  PaymentCurrency,
15
15
  PaymentIntent,
16
16
  PaymentMethod,
17
+ TCustomer,
17
18
  TLineItemExpanded,
18
19
  } from '../store/models';
19
20
  import type { TPaymentCurrency } from '../store/models/payment-currency';
@@ -112,6 +113,31 @@ export async function isDelegationSufficientForPayment(args: {
112
113
  throw new Error(`Payment method ${paymentMethod.type} not supported`);
113
114
  }
114
115
 
116
+ export function isBalanceSufficientForPayment(args: {
117
+ paymentMethod: PaymentMethod;
118
+ paymentCurrency: TPaymentCurrency;
119
+ customer: TCustomer;
120
+ amount: string;
121
+ }): { sufficient: boolean; balance: string; reason?: string } {
122
+ const { paymentMethod, paymentCurrency, customer, amount } = args;
123
+ if (paymentMethod.type === 'stripe') {
124
+ return { sufficient: false, balance: customer.balance || '0', reason: 'NOT_SUPPORTED' };
125
+ }
126
+
127
+ const tokens = customer.token_balance || {};
128
+ const balance = tokens[paymentCurrency.id] || '0';
129
+
130
+ if (amount === '0') {
131
+ return { sufficient: false, balance, reason: 'NO_REQUIREMENT' };
132
+ }
133
+
134
+ if (new BN(balance).lt(new BN(amount))) {
135
+ return { sufficient: false, balance, reason: 'NO_ENOUGH_TOKEN' };
136
+ }
137
+
138
+ return { sufficient: true, balance };
139
+ }
140
+
115
141
  export function getGasPayerExtra(txBuffer: Buffer) {
116
142
  const txHash = toTxHash(txBuffer);
117
143
  return {
@@ -125,7 +125,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
125
125
 
126
126
  const onJobComplete = async (err: any, result: any) => {
127
127
  if (result === CANCELLED) {
128
- emit('cancelled', { id: jobId, job, result });
128
+ emit('cancelled', { id: jobId, job });
129
129
  clearJob(jobId);
130
130
  return;
131
131
  }
@@ -188,6 +188,22 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
188
188
  return jobEvents;
189
189
  };
190
190
 
191
+ const pushAndWait = (params: PushParams<T>) =>
192
+ new Promise((resolve, reject) => {
193
+ try {
194
+ if (params.runAt || params.delay) {
195
+ console.warn('You may have to wait for a long time to get the result of the delayed job');
196
+ }
197
+
198
+ const job = push(params);
199
+ job.on('finished', (data: { id: string; job: T; result: any }) => resolve(data));
200
+ job.on('canceled', (data: { id: string; job: T }) => resolve(data));
201
+ job.on('failed', (data: { id: string; job: T; error: Error }) => reject(data));
202
+ } catch (err) {
203
+ reject(err);
204
+ }
205
+ });
206
+
191
207
  const cancel = async (id: string) => {
192
208
  const doc = await store.updateJob(id, { cancelled: true });
193
209
  return doc ? doc.job : null;
@@ -258,6 +274,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
258
274
  return Object.assign(queueEvents, {
259
275
  store,
260
276
  push,
277
+ pushAndWait,
261
278
  drain: (cb: any) => (queue.drain = cb),
262
279
  empty: (cb: any) => (queue.empty = cb),
263
280
  saturated: (cb: any) => (queue.saturated = cb),
@@ -6,23 +6,24 @@ import CustomError from '../error';
6
6
  export default function createQueueStore(queue: string) {
7
7
  return {
8
8
  async isCancelled(id: string): Promise<boolean> {
9
- const job = await Job.findOne({ where: { queue, id } });
9
+ const job = await Job.findOne({ where: { queue, id }, transaction: null });
10
10
  return !!job && !!job.cancelled;
11
11
  },
12
12
  getJob(id: string): Promise<TJob | null> {
13
- return Job.findOne({ where: { queue, id } });
13
+ return Job.findOne({ where: { queue, id }, transaction: null });
14
14
  },
15
15
  getJobs(): Promise<TJob[]> {
16
- return Job.findAll({ where: { queue, delay: -1 }, order: [['created_at', 'ASC']] });
16
+ return Job.findAll({ where: { queue, delay: -1 }, order: [['created_at', 'ASC']], transaction: null });
17
17
  },
18
18
  getScheduledJobs(): Promise<TJob[]> {
19
19
  return Job.findAll({
20
20
  where: { queue, delay: { [Op.not]: -1 }, will_run_at: { [Op.lte]: Date.now() } },
21
21
  order: [['created_at', 'ASC']],
22
+ transaction: null,
22
23
  });
23
24
  },
24
25
  async updateJob(id: string, updates: Partial<TJob>): Promise<TJob> {
25
- const job = await Job.findOne({ where: { queue, id } });
26
+ const job = await Job.findOne({ where: { queue, id }, transaction: null });
26
27
  if (!job) {
27
28
  throw new CustomError('JOB_NOT_FOUND', `Job ${id} does not exist`);
28
29
  }
@@ -30,7 +31,7 @@ export default function createQueueStore(queue: string) {
30
31
  return job.update(updates);
31
32
  },
32
33
  async addJob(id: string, job: any, attrs: Partial<TJob> = {}): Promise<TJob> {
33
- const exist = await Job.findOne({ where: { queue, id } });
34
+ const exist = await Job.findOne({ where: { queue, id }, transaction: null });
34
35
  if (exist) {
35
36
  throw new CustomError('JOB_DUPLICATE', `Job ${queue}#${id} already exist`);
36
37
  }
@@ -36,9 +36,9 @@ export function getCheckoutMode(items: TLineItemExpanded[] = []) {
36
36
  return 'payment';
37
37
  }
38
38
 
39
- export function getPriceUintAmountByCurrency(price: TPrice, currency: TPaymentCurrency) {
39
+ export function getPriceUintAmountByCurrency(price: TPrice, currencyId: string) {
40
40
  const options = getPriceCurrencyOptions(price);
41
- const option = options.find((x) => x.currency_id === currency.id);
41
+ const option = options.find((x) => x.currency_id === currencyId);
42
42
  if (option) {
43
43
  return option.unit_amount;
44
44
  }
@@ -55,14 +55,14 @@ export function getPriceCurrencyOptions(price: TPrice): PriceCurrency[] {
55
55
  }
56
56
 
57
57
  // FIXME: apply coupon for discounts
58
- export function getCheckoutAmount(items: TLineItemExpanded[], currency: TPaymentCurrency, includeFreeTrial = false) {
58
+ export function getCheckoutAmount(items: TLineItemExpanded[], currencyId: string, includeFreeTrial = false) {
59
59
  let renew = new BN(0);
60
60
 
61
61
  const total = items
62
62
  .reduce((acc, x) => {
63
63
  const price = x.upsell_price || x.price;
64
64
  if (price.type === 'recurring') {
65
- renew = renew.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
65
+ renew = renew.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
66
66
 
67
67
  if (includeFreeTrial) {
68
68
  return acc;
@@ -71,7 +71,7 @@ export function getCheckoutAmount(items: TLineItemExpanded[], currency: TPayment
71
71
  return acc;
72
72
  }
73
73
  }
74
- return acc.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
74
+ return acc.add(new BN(getPriceUintAmountByCurrency(price, currencyId)).mul(new BN(x.quantity)));
75
75
  }, new BN(0))
76
76
  .toString();
77
77
 
@@ -99,16 +99,17 @@ export function getRecurringPeriod(recurring: PriceRecurring) {
99
99
  }
100
100
  }
101
101
 
102
- export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currency: TPaymentCurrency, trialInDays = 0) {
102
+ export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currencyId: string, trialInDays = 0) {
103
103
  let setup = new BN(0);
104
104
  let subscription = new BN(0);
105
105
 
106
106
  items.forEach((x) => {
107
107
  const price = x.upsell_price || x.price;
108
- setup = setup.add(new BN(price.unit_amount).mul(new BN(x.quantity)));
108
+ const unit = getPriceUintAmountByCurrency(price, currencyId);
109
+ setup = setup.add(new BN(unit).mul(new BN(x.quantity)));
109
110
  if (price.type === 'recurring') {
110
111
  if (trialInDays === 0) {
111
- subscription = setup.add(new BN(getPriceUintAmountByCurrency(price, currency)).mul(new BN(x.quantity)));
112
+ subscription = setup.add(new BN(unit).mul(new BN(x.quantity)));
112
113
  }
113
114
  }
114
115
  });
@@ -154,11 +155,11 @@ export function getSubscriptionCycleSetup(recurring: PriceRecurring, previousPer
154
155
  };
155
156
  }
156
157
 
157
- export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currency: TPaymentCurrency) {
158
+ export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyId: string) {
158
159
  let amount = new BN(0);
159
160
 
160
161
  items.forEach((x) => {
161
- amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
162
+ amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price, currencyId)).mul(new BN(x.quantity)));
162
163
  });
163
164
 
164
165
  return {
@@ -287,7 +288,7 @@ export function canUpsell(from: TPrice, to: TPrice) {
287
288
  export function getFastCheckoutAmount(
288
289
  items: TLineItemExpanded[],
289
290
  mode: string,
290
- currency: TPaymentCurrency,
291
+ currencyId: string,
291
292
  includeFreeTrial = false,
292
293
  minimumCycle = 1
293
294
  ) {
@@ -296,7 +297,7 @@ export function getFastCheckoutAmount(
296
297
  minimumCycle = 1;
297
298
  }
298
299
 
299
- const { total, renew } = getCheckoutAmount(items, currency, includeFreeTrial);
300
+ const { total, renew } = getCheckoutAmount(items, currencyId, includeFreeTrial);
300
301
  if (mode === 'payment') {
301
302
  return total;
302
303
  }
@@ -3,3 +3,29 @@ import { component } from '@blocklet/sdk';
3
3
  export function getCustomerSubscriptionPageUrl(subscriptionId: string, locale: string = 'en') {
4
4
  return component.getUrl(`customer/subscription/${subscriptionId}?locale=${locale}`);
5
5
  }
6
+
7
+ // FIXME: make this configurable from preferences
8
+ export function getDaysUntilDue(query: Record<string, any> = {}): number | null {
9
+ const raw = query.days_until_due || process.env.PAYMENT_DAYS_UNTIL_DUE;
10
+ if (raw) {
11
+ const days = parseInt(raw, 10);
12
+ // eslint-disable-next-line no-restricted-globals
13
+ if (isNaN(days) === false) {
14
+ return days;
15
+ }
16
+ }
17
+
18
+ return null;
19
+ }
20
+
21
+ export const getDueUnit = (interval: string) => {
22
+ if (interval === 'hour') {
23
+ return 60;
24
+ }
25
+
26
+ if (interval === 'day') {
27
+ return 60 * 60;
28
+ }
29
+
30
+ return 60 * 60 * 24;
31
+ };
@@ -9,8 +9,11 @@ import dayjs from './dayjs';
9
9
 
10
10
  export const OCAP_PAYMENT_TX_TYPE = 'fg:t:transfer_v2';
11
11
 
12
- export const MAX_RETRY_COUNT = 18; // 2^18 seconds ~~ 3 days, total retry time: 6 days
12
+ export const MAX_SUBSCRIPTION_ITEM_COUNT = 20;
13
+
14
+ export const MAX_RETRY_COUNT = 20; // 2^20 seconds ~~ 12 days, total retry time: 24 days
13
15
  export const MIN_RETRY_MAIL = 13; // total retry time before sending first mail: 6 hours
16
+
14
17
  export const STRIPE_API_VERSION = '2023-08-16';
15
18
  export const STRIPE_ENDPOINT: string = getUrl('/api/integrations/stripe/webhook');
16
19
  export const STRIPE_EVENTS: any[] = [
@@ -27,6 +30,7 @@ export const STRIPE_EVENTS: any[] = [
27
30
  'customer.subscription.resumed',
28
31
  'customer.subscription.trial_will_end',
29
32
  'customer.subscription.updated',
33
+ 'customer.updated',
30
34
 
31
35
  'invoice.created',
32
36
  'invoice.deleted',
@@ -3,6 +3,7 @@ import { ensurePassportIssued, ensurePassportRevoked } from '../integrations/blo
3
3
  import { events } from '../libs/event';
4
4
  import logger from '../libs/logger';
5
5
  import { CheckoutSession, Price, Subscription } from '../store/models';
6
+ import { subscriptionQueue } from './subscription';
6
7
 
7
8
  // eslint-disable-next-line require-await
8
9
  export async function startCheckoutSessionQueue() {
@@ -30,5 +31,15 @@ export async function startCheckoutSessionQueue() {
30
31
  ensurePassportRevoked(subscription).catch((err) => {
31
32
  logger.error('ensurePassportRevoked failed', { error: err, subscription: subscription.id });
32
33
  });
34
+
35
+ // FIXME: ensure invoices that are open or uncollectible are voided
36
+ });
37
+
38
+ events.on('customer.subscription.past_due', (subscription: Subscription) => {
39
+ subscriptionQueue.push({
40
+ id: `cancel-${subscription.id}`,
41
+ job: { subscriptionId: subscription.id, action: 'cancel' },
42
+ runAt: subscription.current_period_end,
43
+ });
33
44
  });
34
45
  }
@@ -13,10 +13,10 @@ import { paymentQueue } from './payment';
13
13
  type InvoiceJob = {
14
14
  invoiceId: string;
15
15
  retryOnError?: boolean;
16
+ waitForPayment?: boolean;
16
17
  };
17
18
 
18
19
  // handle invoice payment
19
- // TODO: send invoice to user with email
20
20
  export const handleInvoice = async (job: InvoiceJob) => {
21
21
  logger.info('handle invoice', job);
22
22
 
@@ -79,14 +79,16 @@ export const handleInvoice = async (job: InvoiceJob) => {
79
79
  if (invoice.payment_intent_id) {
80
80
  logger.warn(`PaymentIntent exist: ${invoice.payment_intent_id} for invoice ${job.invoiceId}`);
81
81
  paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
82
- if (paymentIntent && ['succeeded', 'canceled'].includes(paymentIntent.status) === false) {
82
+ if (paymentIntent && paymentIntent.isImmutable() === false) {
83
83
  await paymentIntent.update({ status: 'requires_capture' });
84
84
  }
85
85
  } else {
86
86
  const descriptionMap: any = {
87
87
  subscription_create: 'Subscription creation',
88
88
  subscription_cycle: 'Subscription cycle',
89
+ subscription_update: 'Subscription update',
89
90
  };
91
+ // TODO: support partial payment from user balance
90
92
  paymentIntent = await PaymentIntent.create({
91
93
  livemode: !!invoice.livemode,
92
94
  amount: invoice.total,
@@ -120,10 +122,17 @@ export const handleInvoice = async (job: InvoiceJob) => {
120
122
  }
121
123
  if (paymentIntent) {
122
124
  logger.info(`Payment job created: ${paymentIntent.id}`);
123
- paymentQueue.push({
124
- id: paymentIntent.id,
125
- job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
126
- });
125
+ if (job.waitForPayment) {
126
+ await paymentQueue.pushAndWait({
127
+ id: paymentIntent.id,
128
+ job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
129
+ });
130
+ } else {
131
+ paymentQueue.push({
132
+ id: paymentIntent.id,
133
+ job: { paymentIntentId: paymentIntent.id, retryOnError: job.retryOnError },
134
+ });
135
+ }
127
136
  }
128
137
  };
129
138