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.
- package/api/src/libs/lock.ts +53 -0
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +2 -2
- package/api/src/libs/notification/template/subscription-cacceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +2 -2
- package/api/src/libs/notification/template/subscription-renew-failed.ts +2 -2
- package/api/src/libs/notification/template/subscription-renewed.ts +2 -2
- package/api/src/libs/notification/template/subscription-succeeded.ts +2 -2
- package/api/src/libs/notification/template/subscription-trial-start.ts +2 -2
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +3 -3
- package/api/src/libs/notification/template/subscription-upgraded.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-canceled.ts +1 -1
- package/api/src/libs/notification/template/subscription-will-renew.ts +3 -3
- package/api/src/libs/queue/index.ts +2 -1
- package/api/src/libs/subscription.ts +36 -7
- package/api/src/queues/payment.ts +62 -15
- package/api/src/queues/subscription.ts +59 -9
- package/api/src/routes/checkout-sessions.ts +15 -3
- package/api/src/routes/connect/collect.ts +24 -4
- package/api/src/routes/connect/pay.ts +1 -1
- package/api/src/routes/connect/setup.ts +19 -43
- package/api/src/routes/connect/shared.ts +106 -41
- package/api/src/routes/connect/subscribe.ts +14 -40
- package/api/src/routes/connect/update.ts +14 -38
- package/api/src/routes/pricing-table.ts +2 -1
- package/api/src/routes/refunds.ts +16 -1
- package/api/src/routes/subscriptions.ts +20 -1
- package/api/src/store/migrations/20240226-days-until-cancel.ts +22 -0
- package/api/src/store/migrations/20240228-service-actions.ts +23 -0
- package/api/src/store/models/customer.ts +5 -0
- package/api/src/store/models/invoice.ts +28 -13
- package/api/src/store/models/subscription.ts +16 -2
- package/api/src/store/models/types.ts +9 -0
- package/api/tests/libs/lock.spec.ts +31 -0
- package/api/tests/libs/subscription.spec.ts +92 -2
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/src/components/subscription/metrics.tsx +1 -1
- package/src/components/subscription/portal/actions.tsx +18 -3
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -4
- 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
|
|
141
|
+
productName,
|
|
142
142
|
})}`,
|
|
143
143
|
body: `${translate('notification.oneTimePaymentSucceeded.body', locale, {
|
|
144
144
|
at,
|
|
145
|
-
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
|
|
140
|
+
productName,
|
|
141
141
|
})}`,
|
|
142
142
|
body: `${translate('notification.subscriptionCanceled.body', locale, {
|
|
143
143
|
at,
|
|
144
|
-
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
|
|
164
|
+
productName,
|
|
165
165
|
})}`,
|
|
166
166
|
body: `${translate('notification.subscriptionRefundSucceeded.body', locale, {
|
|
167
167
|
at,
|
|
168
|
-
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
|
|
179
|
+
productName,
|
|
180
180
|
})}`,
|
|
181
181
|
body: `${translate('notification.subscriptionRenewFailed.body', locale, {
|
|
182
182
|
at,
|
|
183
|
-
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
|
|
168
|
+
productName,
|
|
169
169
|
})}`,
|
|
170
170
|
body: `${translate('notification.subscriptionRenewed.body', locale, {
|
|
171
171
|
at,
|
|
172
|
-
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
|
|
188
|
+
productName,
|
|
189
189
|
})}`,
|
|
190
190
|
body: `${translate('notification.subscriptionSucceed.body', locale, {
|
|
191
191
|
at,
|
|
192
|
-
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
|
|
164
|
+
productName,
|
|
165
165
|
})}`,
|
|
166
166
|
body: `${translate('notification.subscriptionTrialStart.body', locale, {
|
|
167
167
|
subscriptionTrialEnd,
|
|
168
|
-
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
|
|
164
|
+
productName,
|
|
165
165
|
willRenewDuration,
|
|
166
166
|
})}`,
|
|
167
167
|
body: canPay
|
|
168
168
|
? `${translate('notification.subscriptionTrialWillEnd.body', locale, {
|
|
169
169
|
at,
|
|
170
|
-
productName
|
|
170
|
+
productName,
|
|
171
171
|
willRenewDuration,
|
|
172
172
|
})}`
|
|
173
173
|
: `${translate('notification.subscriptionTrialWillEnd.unableToPayBody', locale, {
|
|
174
174
|
at,
|
|
175
|
-
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
|
|
157
|
+
productName,
|
|
158
158
|
})}`,
|
|
159
159
|
body: `${translate('notification.subscriptionUpgraded.body', locale, {
|
|
160
160
|
at,
|
|
161
|
-
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
|
|
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
|
|
184
|
+
productName,
|
|
185
185
|
willRenewDuration,
|
|
186
186
|
})}`,
|
|
187
187
|
body: canPay
|
|
188
188
|
? `${translate('notification.subscriptionWillRenew.body', locale, {
|
|
189
189
|
at,
|
|
190
|
-
productName
|
|
190
|
+
productName,
|
|
191
191
|
willRenewDuration,
|
|
192
192
|
})}`
|
|
193
193
|
: `${translate('notification.subscriptionWillRenew.unableToPayBody', locale, {
|
|
194
194
|
at,
|
|
195
|
-
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
if (isNaN(days) === false) {
|
|
41
|
+
if (typeof days === 'number' && days >= 0) {
|
|
45
42
|
return days;
|
|
46
43
|
}
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
return
|
|
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 {
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
327
|
-
|
|
328
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
|