payment-kit 1.13.157 → 1.13.159

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 (40) hide show
  1. package/api/src/libs/lock.ts +53 -0
  2. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +2 -2
  3. package/api/src/libs/notification/template/subscription-cacceled.ts +2 -2
  4. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +2 -2
  5. package/api/src/libs/notification/template/subscription-renew-failed.ts +2 -2
  6. package/api/src/libs/notification/template/subscription-renewed.ts +2 -2
  7. package/api/src/libs/notification/template/subscription-succeeded.ts +2 -2
  8. package/api/src/libs/notification/template/subscription-trial-start.ts +2 -2
  9. package/api/src/libs/notification/template/subscription-trial-will-end.ts +3 -3
  10. package/api/src/libs/notification/template/subscription-upgraded.ts +2 -2
  11. package/api/src/libs/notification/template/subscription-will-canceled.ts +1 -1
  12. package/api/src/libs/notification/template/subscription-will-renew.ts +3 -3
  13. package/api/src/libs/queue/index.ts +2 -1
  14. package/api/src/libs/subscription.ts +36 -7
  15. package/api/src/queues/payment.ts +62 -15
  16. package/api/src/queues/subscription.ts +59 -9
  17. package/api/src/routes/checkout-sessions.ts +15 -3
  18. package/api/src/routes/connect/collect.ts +24 -4
  19. package/api/src/routes/connect/pay.ts +1 -1
  20. package/api/src/routes/connect/setup.ts +19 -43
  21. package/api/src/routes/connect/shared.ts +106 -41
  22. package/api/src/routes/connect/subscribe.ts +14 -40
  23. package/api/src/routes/connect/update.ts +14 -38
  24. package/api/src/routes/pricing-table.ts +2 -1
  25. package/api/src/routes/refunds.ts +16 -1
  26. package/api/src/routes/subscriptions.ts +20 -1
  27. package/api/src/store/migrations/20240226-days-until-cancel.ts +22 -0
  28. package/api/src/store/migrations/20240228-service-actions.ts +23 -0
  29. package/api/src/store/models/customer.ts +5 -0
  30. package/api/src/store/models/invoice.ts +28 -13
  31. package/api/src/store/models/subscription.ts +16 -2
  32. package/api/src/store/models/types.ts +9 -0
  33. package/api/tests/libs/lock.spec.ts +31 -0
  34. package/api/tests/libs/subscription.spec.ts +92 -2
  35. package/blocklet.yml +1 -1
  36. package/package.json +4 -4
  37. package/src/components/subscription/metrics.tsx +1 -1
  38. package/src/components/subscription/portal/actions.tsx +18 -3
  39. package/src/pages/admin/billing/subscriptions/detail.tsx +2 -4
  40. package/src/pages/customer/invoice/detail.tsx +16 -5
@@ -0,0 +1,53 @@
1
+ const { EventEmitter } = require('events');
2
+
3
+ export class Lock {
4
+ name: string;
5
+ locked: boolean;
6
+ events: typeof EventEmitter;
7
+
8
+ constructor(name: string) {
9
+ this.name = name;
10
+ this.locked = false;
11
+ this.events = new EventEmitter();
12
+ }
13
+
14
+ acquire() {
15
+ return new Promise((resolve) => {
16
+ // If somebody has the lock, wait until he/she releases the lock and try again
17
+ if (this.locked) {
18
+ const tryAcquire = () => {
19
+ if (!this.locked) {
20
+ this.locked = true;
21
+ this.events.removeListener('release', tryAcquire);
22
+ resolve(true);
23
+ }
24
+ };
25
+
26
+ this.events.on('release', tryAcquire);
27
+ } else {
28
+ // Otherwise, take the lock and resolve immediately
29
+ this.locked = true;
30
+ resolve(true);
31
+ }
32
+ });
33
+ }
34
+
35
+ release() {
36
+ // Release the lock immediately
37
+ this.locked = false;
38
+ setImmediate(() => this.events.emit('release'));
39
+ }
40
+ }
41
+
42
+ const locks = new Map<string, Lock>();
43
+ export function getLock(name: string): Lock {
44
+ const exist = locks.get(name);
45
+ if (exist instanceof Lock) {
46
+ return exist;
47
+ }
48
+
49
+ const lock = new Lock(name);
50
+ locks.set(name, lock);
51
+
52
+ return lock;
53
+ }
@@ -138,11 +138,11 @@ export class OneTimePaymentSucceededEmailTemplate
138
138
 
139
139
  const template: BaseEmailTemplateType = {
140
140
  title: `${translate('notification.oneTimePaymentSucceeded.title', locale, {
141
- productName: `(${productName})`,
141
+ productName,
142
142
  })}`,
143
143
  body: `${translate('notification.oneTimePaymentSucceeded.body', locale, {
144
144
  at,
145
- productName: `(${productName})`,
145
+ productName,
146
146
  })}`,
147
147
  // @ts-expect-error
148
148
  attachments: [
@@ -137,11 +137,11 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
137
137
 
138
138
  const template: BaseEmailTemplateType = {
139
139
  title: `${translate('notification.subscriptionCanceled.title', locale, {
140
- productName: `(${productName})`,
140
+ productName,
141
141
  })}`,
142
142
  body: `${translate('notification.subscriptionCanceled.body', locale, {
143
143
  at,
144
- productName: `(${productName})`,
144
+ productName,
145
145
  })}`,
146
146
  // @ts-expect-error
147
147
  attachments: [
@@ -161,11 +161,11 @@ export class SubscriptionRefundSucceededEmailTemplate
161
161
 
162
162
  const template: BaseEmailTemplateType = {
163
163
  title: `${translate('notification.subscriptionRefundSucceeded.title', locale, {
164
- productName: `(${productName})`,
164
+ productName,
165
165
  })}`,
166
166
  body: `${translate('notification.subscriptionRefundSucceeded.body', locale, {
167
167
  at,
168
- productName: `(${productName})`,
168
+ productName,
169
169
  refundInfo,
170
170
  })}`,
171
171
  // @ts-expect-error
@@ -176,11 +176,11 @@ export class SubscriptionRenewFailedEmailTemplate
176
176
 
177
177
  const template: BaseEmailTemplateType = {
178
178
  title: `${translate('notification.subscriptionRenewFailed.title', locale, {
179
- productName: `(${productName})`,
179
+ productName,
180
180
  })}`,
181
181
  body: `${translate('notification.subscriptionRenewFailed.body', locale, {
182
182
  at,
183
- productName: `(${productName})`,
183
+ productName,
184
184
  reason: `${reason}`,
185
185
  })}`,
186
186
  // @ts-expect-error
@@ -165,11 +165,11 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
165
165
 
166
166
  const template: BaseEmailTemplateType = {
167
167
  title: `${translate('notification.subscriptionRenewed.title', locale, {
168
- productName: `(${productName})`,
168
+ productName,
169
169
  })}`,
170
170
  body: `${translate('notification.subscriptionRenewed.body', locale, {
171
171
  at,
172
- productName: `(${productName})`,
172
+ productName,
173
173
  })}`,
174
174
  // @ts-expect-error
175
175
  attachments: [
@@ -185,11 +185,11 @@ export class SubscriptionSucceededEmailTemplate
185
185
 
186
186
  const template: BaseEmailTemplateType = {
187
187
  title: `${translate('notification.subscriptionSucceed.title', locale, {
188
- productName: `(${productName})`,
188
+ productName,
189
189
  })}`,
190
190
  body: `${translate('notification.subscriptionSucceed.body', locale, {
191
191
  at,
192
- productName: `(${productName})`,
192
+ productName,
193
193
  })}`,
194
194
  // @ts-expect-error
195
195
  attachments: [
@@ -161,11 +161,11 @@ export class SubscriptionTrailStartEmailTemplate
161
161
 
162
162
  const template: BaseEmailTemplateType = {
163
163
  title: `${translate('notification.subscriptionTrialStart.title', locale, {
164
- productName: `(${productName})`,
164
+ productName,
165
165
  })}`,
166
166
  body: `${translate('notification.subscriptionTrialStart.body', locale, {
167
167
  subscriptionTrialEnd,
168
- productName: `(${productName})`,
168
+ productName,
169
169
  trialDuration: duration,
170
170
  })}`,
171
171
  // @ts-expect-error
@@ -161,18 +161,18 @@ export class SubscriptionTrailWilEndEmailTemplate
161
161
 
162
162
  const template: BaseEmailTemplateType = {
163
163
  title: `${translate('notification.subscriptionTrialWillEnd.title', locale, {
164
- productName: `(${productName})`,
164
+ productName,
165
165
  willRenewDuration,
166
166
  })}`,
167
167
  body: canPay
168
168
  ? `${translate('notification.subscriptionTrialWillEnd.body', locale, {
169
169
  at,
170
- productName: `(${productName})`,
170
+ productName,
171
171
  willRenewDuration,
172
172
  })}`
173
173
  : `${translate('notification.subscriptionTrialWillEnd.unableToPayBody', locale, {
174
174
  at,
175
- productName: `(${productName})`,
175
+ productName,
176
176
  willRenewDuration,
177
177
  balance: `${paymentDetail.balance} ${paymentDetail.symbol}`,
178
178
  price: `${paymentDetail.price} ${paymentDetail.symbol}`,
@@ -154,11 +154,11 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
154
154
 
155
155
  const template: BaseEmailTemplateType = {
156
156
  title: `${translate('notification.subscriptionUpgraded.title', locale, {
157
- productName: `(${productName})`,
157
+ productName,
158
158
  })}`,
159
159
  body: `${translate('notification.subscriptionUpgraded.body', locale, {
160
160
  at,
161
- productName: `(${productName})`,
161
+ productName,
162
162
  })}`,
163
163
  // @ts-expect-error
164
164
  attachments: [
@@ -145,7 +145,7 @@ export class SubscriptionWillCanceledEmailTemplate
145
145
 
146
146
  const template: BaseEmailTemplateType = {
147
147
  title: `${translate('notification.subscriptWillCanceled.title', locale, {
148
- productName: `(${productName})`,
148
+ productName,
149
149
  })}`,
150
150
  body: translate('notification.subscriptWillCanceled.body', locale, {
151
151
  productName,
@@ -181,18 +181,18 @@ export class SubscriptionWillRenewEmailTemplate
181
181
 
182
182
  const template: BaseEmailTemplateType = {
183
183
  title: `${translate('notification.subscriptionWillRenew.title', locale, {
184
- productName: `(${productName})`,
184
+ productName,
185
185
  willRenewDuration,
186
186
  })}`,
187
187
  body: canPay
188
188
  ? `${translate('notification.subscriptionWillRenew.body', locale, {
189
189
  at,
190
- productName: `(${productName})`,
190
+ productName,
191
191
  willRenewDuration,
192
192
  })}`
193
193
  : `${translate('notification.subscriptionWillRenew.unableToPayBody', locale, {
194
194
  at,
195
- productName: `(${productName})`,
195
+ productName,
196
196
  willRenewDuration,
197
197
  balance: `${paymentDetail.balance} ${paymentDetail.symbol}`,
198
198
  price: `${paymentDetail.price} ${paymentDetail.symbol}`,
@@ -112,7 +112,8 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
112
112
  emit('queued', { id: jobId, job, attrs });
113
113
  })
114
114
  .catch((err) => {
115
- logger.error('Can not add scheduled job to store', { error: err });
115
+ console.error(err);
116
+ logger.error('Can not add scheduled job to store', { jobId, job, attrs, error: err });
116
117
  });
117
118
 
118
119
  // @ts-ignore
@@ -35,18 +35,23 @@ export function getCustomerSubscriptionPageUrl({
35
35
  );
36
36
  }
37
37
 
38
- // FIXME: make this configurable from preferences
39
- export function getDaysUntilDue(query: Record<string, any> = {}): number | null {
40
- const raw = query.days_until_due || process.env.PAYMENT_DAYS_UNTIL_DUE;
41
- if (raw) {
38
+ export function parseIntegerConfig(alternatives: any[], defaultValue: number) {
39
+ for (const raw of alternatives) {
42
40
  const days = parseInt(raw, 10);
43
- // eslint-disable-next-line no-restricted-globals
44
- if (isNaN(days) === false) {
41
+ if (typeof days === 'number' && days >= 0) {
45
42
  return days;
46
43
  }
47
44
  }
48
45
 
49
- return 6;
46
+ return defaultValue;
47
+ }
48
+
49
+ export function getDaysUntilDue(query: Record<string, any> = {}) {
50
+ return parseIntegerConfig([query.days_until_due, process.env.PAYMENT_DAYS_UNTIL_DUE], 6);
51
+ }
52
+
53
+ export function getDaysUntilCancel(query: Record<string, any> = {}) {
54
+ return parseIntegerConfig([query.days_until_cancel, process.env.PAYMENT_DAYS_UNTIL_CANCEL], 0);
50
55
  }
51
56
 
52
57
  export const getDueUnit = (interval: string) => {
@@ -286,3 +291,27 @@ export async function getSubscriptionRefundSetup(subscription: Subscription, anc
286
291
  const setup = getSubscriptionCreateSetup(expanded, subscription.currency_id, 0);
287
292
  return createProration(subscription, setup, anchor);
288
293
  }
294
+
295
+ export function shouldCancelSubscription(
296
+ subscription: Pick<Subscription, 'status' | 'current_period_end' | 'cancel_at'>
297
+ ) {
298
+ if (['past_due', 'active', 'trialing'].includes(subscription.status)) {
299
+ const now = dayjs().unix();
300
+ if (subscription.cancel_at) {
301
+ if (subscription.cancel_at <= now) {
302
+ return true;
303
+ }
304
+ } else if (subscription.current_period_end <= now) {
305
+ return true;
306
+ }
307
+ }
308
+
309
+ return false;
310
+ }
311
+
312
+ export function formatSubscriptionProduct(items: TLineItemExpanded[], maxLength = 3) {
313
+ const names = items.map((x) => x.price?.product?.name).filter(Boolean);
314
+ return (
315
+ names.slice(0, maxLength).join(', ') + (names.length > maxLength ? ` and ${names.length - maxLength} more` : '')
316
+ );
317
+ }
@@ -1,3 +1,5 @@
1
+ import isEmpty from 'lodash/isEmpty';
2
+
1
3
  import { ensureStakedForGas } from '../integrations/blockchain/stake';
2
4
  import { createEvent } from '../libs/audit';
3
5
  import { wallet } from '../libs/auth';
@@ -7,7 +9,15 @@ import { events } from '../libs/event';
7
9
  import logger from '../libs/logger';
8
10
  import { getGasPayerExtra, isDelegationSufficientForPayment } from '../libs/payment';
9
11
  import createQueue from '../libs/queue';
10
- import { getDaysUntilDue, getDueUnit, getMaxRetryCount, getMinRetryMail } from '../libs/subscription';
12
+ import {
13
+ getDaysUntilCancel,
14
+ getDaysUntilDue,
15
+ getDueUnit,
16
+ getMaxRetryCount,
17
+ getMinRetryMail,
18
+ getSubscriptionCreateSetup,
19
+ shouldCancelSubscription,
20
+ } from '../libs/subscription';
11
21
  import { MAX_RETRY_COUNT, MIN_RETRY_MAIL, getNextRetry } from '../libs/util';
12
22
  import { CheckoutSession } from '../store/models/checkout-session';
13
23
  import { Customer } from '../store/models/customer';
@@ -15,7 +25,9 @@ import { Invoice } from '../store/models/invoice';
15
25
  import { PaymentCurrency } from '../store/models/payment-currency';
16
26
  import { PaymentIntent } from '../store/models/payment-intent';
17
27
  import { PaymentMethod } from '../store/models/payment-method';
28
+ import { Price } from '../store/models/price';
18
29
  import { Subscription } from '../store/models/subscription';
30
+ import { SubscriptionItem } from '../store/models/subscription-item';
19
31
  import type { PaymentError, PaymentSettings } from '../store/models/types';
20
32
 
21
33
  type PaymentJob = {
@@ -59,18 +71,44 @@ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
59
71
  const subscription = await Subscription.findByPk(invoice.subscription_id);
60
72
 
61
73
  // We only update subscription status when the invoice is the latest one
62
- if (subscription && invoice.id === subscription.latest_invoice_id) {
63
- if (subscription.status === 'incomplete') {
74
+ if (subscription) {
75
+ if (subscription.status === 'incomplete' && invoice.id === subscription.latest_invoice_id) {
64
76
  await subscription.start();
65
77
  logger.info(`Subscription ${subscription.id} updated on payment done ${invoice.id}`);
66
- } else if (subscription.status === 'past_due') {
67
- if (subscription.cancel_at_period_end && subscription.cancelation_details?.reason === 'payment_failed') {
68
- // @ts-ignore
69
- await subscription.update({ status: 'active', cancel_at_period_end: false, cancelation_details: null });
70
- logger.info(`Subscription ${subscription.id} moved to active after payment done ${paymentIntent.id}`);
71
- } else {
72
- await subscription.update({ status: 'active' });
73
- logger.info(`Subscription ${subscription.id} moved to active after payment done ${paymentIntent.id}`);
78
+ } else if (subscription.status === 'past_due' && subscription.cancelation_details?.reason === 'payment_failed') {
79
+ // ensure no uncollectible amount before recovering from payment failed
80
+ const result = await Invoice.getUncollectibleAmountBySubscription(subscription.id);
81
+ if (isEmpty(result)) {
82
+ // reset billing cycle anchor and cancel_* if we are recovering from payment failed
83
+ if (subscription.cancel_at && subscription.cancel_at !== subscription.current_period_end) {
84
+ const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
85
+ const lineItems = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
86
+ const setup = getSubscriptionCreateSetup(lineItems, subscription.currency_id, 0);
87
+ await subscription.update({
88
+ status: 'active',
89
+ pending_invoice_item_interval: setup.recurring,
90
+ current_period_start: setup.period.start,
91
+ current_period_end: setup.period.end,
92
+ billing_cycle_anchor: setup.cycle.anchor,
93
+ cancel_at: 0,
94
+ cancel_at_period_end: false,
95
+ // @ts-ignore
96
+ cancelation_details: null,
97
+ });
98
+
99
+ createEvent('Subscription', 'customer.subscription.recovered', subscription).catch(console.error);
100
+ logger.info(
101
+ `Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel and billing cycle reset`
102
+ );
103
+ } else if (subscription.cancel_at_period_end) {
104
+ // reset cancel_at_period_end if we are recovering from payment failed
105
+ // @ts-ignore
106
+ await subscription.update({ status: 'active', cancel_at_period_end: false, cancelation_details: null });
107
+ logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel reset`);
108
+ } else {
109
+ await subscription.update({ status: 'active' });
110
+ logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}`);
111
+ }
74
112
  }
75
113
  }
76
114
 
@@ -155,7 +193,7 @@ export const handlePaymentFailed = async (
155
193
  }
156
194
 
157
195
  // check current period
158
- if (subscription.current_period_end <= now) {
196
+ if (shouldCancelSubscription(subscription)) {
159
197
  await subscription.update({
160
198
  status: 'canceled',
161
199
  canceled_at: now,
@@ -174,17 +212,26 @@ export const handlePaymentFailed = async (
174
212
  return updates.terminate;
175
213
  }
176
214
 
215
+ // check days until cancel
216
+ const dueUnit = getDueUnit(interval);
217
+ const daysUntilCancel = getDaysUntilCancel(subscription);
218
+ const cancelUpdates: { [key: string]: any } = {};
219
+ if (daysUntilCancel > 0) {
220
+ cancelUpdates.cancel_at = subscription.current_period_start + daysUntilCancel * dueUnit;
221
+ } else {
222
+ cancelUpdates.cancel_at_period_end = true;
223
+ }
224
+
177
225
  // check days until due
178
226
  const daysUntilDue = getDaysUntilDue(subscription);
179
227
  if (typeof daysUntilDue === 'number') {
180
- const dueUnit = getDueUnit(interval);
181
228
  const gracePeriodStart = subscription.current_period_start;
182
229
  const graceDuration = daysUntilDue ? daysUntilDue * dueUnit : 0;
183
230
  logger.debug('handlePaymentFailed.checkDue', { now, daysUntilDue, dueUnit, gracePeriodStart, graceDuration });
184
231
  if (gracePeriodStart + graceDuration <= now) {
185
232
  await subscription.update({
186
233
  status: 'past_due',
187
- cancel_at_period_end: true,
234
+ ...cancelUpdates,
188
235
  cancelation_details: {
189
236
  comment: 'past_due',
190
237
  feedback: 'other',
@@ -207,7 +254,7 @@ export const handlePaymentFailed = async (
207
254
  if (invoice.attempt_count > maxRetry) {
208
255
  await subscription.update({
209
256
  status: 'past_due',
210
- cancel_at_period_end: true,
257
+ ...cancelUpdates,
211
258
  cancelation_details: {
212
259
  comment: 'exceed_max_retry',
213
260
  feedback: 'other',
@@ -3,6 +3,7 @@ import type { LiteralUnion } from 'type-fest';
3
3
  import { ensurePassportRevoked } from '../integrations/blocklet/passport';
4
4
  import dayjs from '../libs/dayjs';
5
5
  import { events } from '../libs/event';
6
+ import { getLock } from '../libs/lock';
6
7
  import logger from '../libs/logger';
7
8
  import createQueue from '../libs/queue';
8
9
  import { getStatementDescriptor } from '../libs/session';
@@ -23,7 +24,7 @@ type SubscriptionJob = {
23
24
 
24
25
  const EXPECTED_SUBSCRIPTION_STATUS = ['trialing', 'active', 'paused', 'past_due'];
25
26
 
26
- export const handleSubscriptionInvoice = async ({
27
+ const doHandleSubscriptionInvoice = async ({
27
28
  subscription,
28
29
  filter,
29
30
  status,
@@ -38,7 +39,7 @@ export const handleSubscriptionInvoice = async ({
38
39
  subscription: Subscription;
39
40
  filter: (x: any) => boolean;
40
41
  status: string;
41
- reason: 'cycle' | 'cancel' | 'threshold';
42
+ reason: 'cycle' | 'cancel' | 'threshold' | 'recover';
42
43
  start: number;
43
44
  end: number;
44
45
  offset: number;
@@ -77,7 +78,7 @@ export const handleSubscriptionInvoice = async ({
77
78
  }
78
79
 
79
80
  // check if invoice already created for this reason
80
- if (['cycle', 'cancel'].includes(reason)) {
81
+ if (['cycle', 'cancel', 'recover'].includes(reason)) {
81
82
  const exist = await Invoice.findOne({
82
83
  where: {
83
84
  subscription_id: subscription.id,
@@ -168,6 +169,14 @@ export const handleSubscriptionInvoice = async ({
168
169
  return invoice;
169
170
  };
170
171
 
172
+ export async function handleSubscriptionInvoice(args: Parameters<typeof doHandleSubscriptionInvoice>[0]) {
173
+ const lock = getLock(`${args.subscription.id}-invoice`);
174
+ await lock.acquire();
175
+ const result = await doHandleSubscriptionInvoice(args);
176
+ lock.release();
177
+ return result;
178
+ }
179
+
171
180
  const handleSubscriptionBeforeCancel = async (subscription: Subscription) => {
172
181
  const invoice = await handleSubscriptionInvoice({
173
182
  subscription,
@@ -241,6 +250,32 @@ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
241
250
  }
242
251
  };
243
252
 
253
+ const handleSubscriptionAfterRecover = async (subscription: Subscription) => {
254
+ const invoice = await handleSubscriptionInvoice({
255
+ subscription,
256
+ filter: () => true, // include all items
257
+ status: 'open',
258
+ reason: 'recover',
259
+ start: subscription.current_period_start,
260
+ end: subscription.current_period_end,
261
+ offset: 0,
262
+ });
263
+
264
+ if (invoice) {
265
+ // schedule invoice job
266
+ invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
267
+ logger.info(`Invoice job scheduled for initial billing cycle after recover: ${invoice.id}`);
268
+
269
+ // persist invoice id
270
+ await subscription.update({ latest_invoice_id: invoice.id });
271
+ logger.info(`Subscription updated for initial billing cycle after recover: ${subscription.id}`);
272
+ }
273
+
274
+ // schedule next billing cycle
275
+ await addSubscriptionJob(subscription, 'cycle', false, subscription.current_period_end);
276
+ logger.info(`Subscription job scheduled for next billing cycle after recover: ${subscription.id}`);
277
+ };
278
+
244
279
  // generate invoice for subscription periodically
245
280
  export const handleSubscription = async (job: SubscriptionJob) => {
246
281
  logger.info('handle subscription', job);
@@ -315,18 +350,33 @@ export const subscriptionQueue = createQueue<SubscriptionJob>({
315
350
  });
316
351
 
317
352
  export const startSubscriptionQueue = async () => {
318
- events.on('customer.subscription.deleted', (subscription: Subscription) => {
353
+ const pastDueHandler = async (subscription: Subscription) => {
354
+ await addSubscriptionJob(subscription, 'cancel', true, subscription.cancel_at || subscription.current_period_end);
355
+ logger.info('subscription cancel job scheduled after past_due', {
356
+ subscription: subscription.id,
357
+ runAt: subscription.cancel_at || subscription.current_period_end,
358
+ });
359
+ };
360
+
361
+ const recoverHandler = async (subscription: Subscription) => {
362
+ logger.info('subscription cancel job replaced after recover', { subscription: subscription.id });
363
+ await subscriptionQueue.delete(`cancel-${subscription.id}`);
364
+ await subscriptionQueue.delete(subscription.id);
365
+ const doc = await Subscription.findByPk(subscription.id);
366
+ await handleSubscriptionAfterRecover(doc!);
367
+ };
368
+
369
+ const cancelHandler = (subscription: Subscription) => {
319
370
  ensurePassportRevoked(subscription).catch((err) => {
320
371
  logger.error('ensurePassportRevoked failed', { error: err, subscription: subscription.id });
321
372
  });
322
373
 
323
374
  // FIXME: ensure invoices that are open or uncollectible are voided
324
- });
375
+ };
325
376
 
326
- events.on('customer.subscription.past_due', async (subscription: Subscription) => {
327
- await addSubscriptionJob(subscription, 'cancel', true);
328
- logger.info('subscription cancel job scheduled after past_due');
329
- });
377
+ events.addListener('customer.subscription.past_due', pastDueHandler);
378
+ events.addListener('customer.subscription.recovered', recoverHandler);
379
+ events.addListener('customer.subscription.deleted', cancelHandler);
330
380
 
331
381
  const subscriptions = await Subscription.findAll({
332
382
  where: {
@@ -32,7 +32,12 @@ import {
32
32
  getSupportedPaymentMethods,
33
33
  isLineItemAligned,
34
34
  } from '../libs/session';
35
- import { getDaysUntilDue, getSubscriptionCreateSetup } from '../libs/subscription';
35
+ import {
36
+ formatSubscriptionProduct,
37
+ getDaysUntilCancel,
38
+ getDaysUntilDue,
39
+ getSubscriptionCreateSetup,
40
+ } from '../libs/subscription';
36
41
  import { CHECKOUT_SESSION_TTL, createCodeGenerator, formatMetadata, getDataObjectFromQuery } from '../libs/util';
37
42
  import { invoiceQueue } from '../queues/invoice';
38
43
  import { paymentQueue } from '../queues/payment';
@@ -346,6 +351,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
346
351
  ...link.metadata,
347
352
  ...getDataObjectFromQuery(req.query),
348
353
  days_until_due: getDaysUntilDue(req.query),
354
+ days_until_cancel: getDaysUntilCancel(req.query),
349
355
  passport: await checkPassportForPaymentLink(link),
350
356
  preview: '1',
351
357
  };
@@ -355,6 +361,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
355
361
  ...link.metadata,
356
362
  ...getDataObjectFromQuery(req.query),
357
363
  days_until_due: getDaysUntilDue(req.query),
364
+ days_until_cancel: getDaysUntilCancel(req.query),
358
365
  passport: await checkPassportForPaymentLink(link),
359
366
  };
360
367
  }
@@ -612,7 +619,9 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
612
619
  setupIntent = await SetupIntent.create({
613
620
  livemode: !!checkoutSession.livemode,
614
621
  customer_id: customer.id,
615
- description: checkoutSession.payment_intent_data?.description || '',
622
+ description:
623
+ checkoutSession.payment_intent_data?.description ||
624
+ formatSubscriptionProduct(lineItems.filter((x) => x.price.type === 'recurring')),
616
625
  currency_id: paymentCurrency.id,
617
626
  payment_method_id: paymentMethod.id,
618
627
  status: 'requires_payment_method',
@@ -676,10 +685,13 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
676
685
  default_payment_method_id: paymentMethod.id,
677
686
  cancel_at_period_end: false,
678
687
  collection_method: 'charge_automatically',
679
- description: checkoutSession.subscription_data?.description || '',
688
+ description:
689
+ checkoutSession.subscription_data?.description ||
690
+ formatSubscriptionProduct(lineItems.filter((x) => x.price.type === 'recurring')),
680
691
  proration_behavior: checkoutSession.subscription_data?.proration_behavior || 'none',
681
692
  payment_behavior: 'default_incomplete',
682
693
  days_until_due: checkoutSession.metadata?.days_until_due,
694
+ days_until_cancel: checkoutSession.metadata?.days_until_cancel,
683
695
  metadata: checkoutSession.metadata as any,
684
696
  });
685
697