payment-kit 1.13.91 → 1.13.93
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/crons/index.ts +8 -1
- package/api/src/index.ts +2 -0
- package/api/src/libs/audit.ts +28 -34
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/payment.ts +2 -11
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/util.ts +8 -5
- package/api/src/queues/subscription.ts +166 -112
- package/api/src/routes/checkout-sessions.ts +41 -39
- package/api/src/routes/connect/collect.ts +12 -12
- package/api/src/routes/connect/setup.ts +8 -11
- package/api/src/routes/connect/shared.ts +81 -20
- package/api/src/routes/connect/subscribe.ts +8 -11
- package/api/src/routes/connect/update.ts +134 -0
- package/api/src/routes/pricing-table.ts +9 -121
- package/api/src/routes/subscriptions.ts +416 -141
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/invoice.ts +2 -0
- package/api/src/store/models/pricing-table.ts +125 -1
- package/api/src/store/models/subscription.ts +4 -0
- package/api/src/store/models/types.ts +8 -0
- package/api/tests/libs/util.spec.ts +6 -6
- package/blocklet.yml +3 -1
- package/package.json +7 -7
- package/src/app.tsx +12 -4
- package/src/components/checkout/form/address.tsx +41 -34
- package/src/components/checkout/form/index.tsx +1 -1
- package/src/components/checkout/pricing-table.tsx +205 -0
- package/src/components/payment-link/product-select.tsx +13 -3
- package/src/components/portal/invoice/list.tsx +1 -1
- package/src/components/portal/subscription/actions.tsx +153 -0
- package/src/components/portal/subscription/list.tsx +21 -150
- package/src/components/subscription/metrics.tsx +46 -0
- package/src/contexts/products.tsx +2 -1
- package/src/libs/util.ts +43 -0
- package/src/locales/en.tsx +15 -1
- package/src/locales/zh.tsx +16 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -34
- package/src/pages/checkout/pricing-table.tsx +9 -158
- package/src/pages/customer/subscription/{index.tsx → detail.tsx} +6 -36
- package/src/pages/customer/subscription/update.tsx +281 -0
package/api/src/crons/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import Cron from '@abtnode/cron';
|
|
2
2
|
|
|
3
|
-
import { notificationCronTime } from '../libs/env';
|
|
3
|
+
import { notificationCronTime, subscriptionCronTime } from '../libs/env';
|
|
4
4
|
import logger from '../libs/logger';
|
|
5
|
+
import { startSubscriptionQueue } from '../queues/subscription';
|
|
5
6
|
import { SubscriptionTrailWillEndSchedule } from './subscription-trail-will-end';
|
|
6
7
|
import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
|
|
7
8
|
|
|
@@ -21,6 +22,12 @@ function init() {
|
|
|
21
22
|
fn: () => new SubscriptionTrailWillEndSchedule().run(),
|
|
22
23
|
options: { runOnInit: true },
|
|
23
24
|
},
|
|
25
|
+
{
|
|
26
|
+
name: 'subscription.schedule.retry',
|
|
27
|
+
time: subscriptionCronTime,
|
|
28
|
+
fn: startSubscriptionQueue,
|
|
29
|
+
options: { runOnInit: false },
|
|
30
|
+
},
|
|
24
31
|
],
|
|
25
32
|
onError: (error: Error, name: string) => {
|
|
26
33
|
logger.error('run job failed', { name, error: error.message, stack: error.stack });
|
package/api/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import collectHandlers from './routes/connect/collect';
|
|
|
26
26
|
import payHandlers from './routes/connect/pay';
|
|
27
27
|
import setupHandlers from './routes/connect/setup';
|
|
28
28
|
import subscribeHandlers from './routes/connect/subscribe';
|
|
29
|
+
import updateHandlers from './routes/connect/update';
|
|
29
30
|
import { initialize } from './store/models';
|
|
30
31
|
import { sequelize } from './store/sequelize';
|
|
31
32
|
|
|
@@ -53,6 +54,7 @@ handlers.attach(Object.assign({ app: router }, collectHandlers));
|
|
|
53
54
|
handlers.attach(Object.assign({ app: router }, payHandlers));
|
|
54
55
|
handlers.attach(Object.assign({ app: router }, setupHandlers));
|
|
55
56
|
handlers.attach(Object.assign({ app: router }, subscribeHandlers));
|
|
57
|
+
handlers.attach(Object.assign({ app: router }, updateHandlers));
|
|
56
58
|
|
|
57
59
|
router.use('/api', routes);
|
|
58
60
|
|
package/api/src/libs/audit.ts
CHANGED
|
@@ -16,24 +16,21 @@ 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
idempotency_key: '',
|
|
31
|
-
},
|
|
32
|
-
metadata: {},
|
|
33
|
-
pending_webhooks: 99, // force all events goto the event queue
|
|
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: '',
|
|
34
30
|
},
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
metadata: {},
|
|
32
|
+
pending_webhooks: 99, // force all events goto the event queue
|
|
33
|
+
});
|
|
37
34
|
|
|
38
35
|
events.emit('event.created', { id: event.id });
|
|
39
36
|
events.emit(event.type, data.object);
|
|
@@ -61,24 +58,21 @@ export async function createStatusEvent(
|
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
const suffix = config[data.object.status];
|
|
64
|
-
const event = await Event.create(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
idempotency_key: '',
|
|
76
|
-
},
|
|
77
|
-
metadata: {},
|
|
78
|
-
pending_webhooks: 99, // force all events goto the event queue
|
|
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: '',
|
|
79
72
|
},
|
|
80
|
-
|
|
81
|
-
|
|
73
|
+
metadata: {},
|
|
74
|
+
pending_webhooks: 99, // force all events goto the event queue
|
|
75
|
+
});
|
|
82
76
|
|
|
83
77
|
events.emit('event.created', { id: event.id });
|
|
84
78
|
events.emit(event.type, data.object);
|
package/api/src/libs/env.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import env from '@blocklet/sdk/lib/env';
|
|
2
2
|
|
|
3
|
+
export const subscriptionCronTime: string = process.env.SUBSCRIPTION_CRON_TIME || '0 */30 * * * *'; // 默认每 30 min 行一次
|
|
3
4
|
export const notificationCronTime: string = process.env.NOTIFICATION_CRON_TIME || '0 5 */6 * * *'; // 默认每6个小时执行一次
|
|
4
5
|
export const notificationCronConcurrency: number = Number(process.env.NOTIFICATION_CRON_CONCURRENCY) || 8; // 默认并发数为 8
|
|
5
6
|
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -8,15 +8,7 @@ import { BN, fromUnitToToken } from '@ocap/util';
|
|
|
8
8
|
import cloneDeep from 'lodash/cloneDeep';
|
|
9
9
|
import type { LiteralUnion } from 'type-fest';
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
CheckoutSession,
|
|
13
|
-
Invoice,
|
|
14
|
-
PaymentCurrency,
|
|
15
|
-
PaymentIntent,
|
|
16
|
-
PaymentMethod,
|
|
17
|
-
TCustomer,
|
|
18
|
-
TLineItemExpanded,
|
|
19
|
-
} from '../store/models';
|
|
11
|
+
import { Invoice, PaymentCurrency, PaymentIntent, PaymentMethod, TCustomer, TLineItemExpanded } from '../store/models';
|
|
20
12
|
import type { TPaymentCurrency } from '../store/models/payment-currency';
|
|
21
13
|
import { blocklet, wallet } from './auth';
|
|
22
14
|
import { OCAP_PAYMENT_TX_TYPE } from './util';
|
|
@@ -209,7 +201,7 @@ export async function getPaymentDetail(userDid: string, invoice: Invoice): Promi
|
|
|
209
201
|
}
|
|
210
202
|
|
|
211
203
|
export async function getTokenLimitsForDelegation(
|
|
212
|
-
|
|
204
|
+
items: TLineItemExpanded[],
|
|
213
205
|
paymentMethod: PaymentMethod,
|
|
214
206
|
paymentCurrency: PaymentCurrency,
|
|
215
207
|
address: string,
|
|
@@ -218,7 +210,6 @@ export async function getTokenLimitsForDelegation(
|
|
|
218
210
|
const client = paymentMethod.getOcapClient();
|
|
219
211
|
const { state } = await client.getDelegateState({ address });
|
|
220
212
|
|
|
221
|
-
const items = checkoutSession.line_items as TLineItemExpanded[];
|
|
222
213
|
const hasMetered = items.some((x) => x.price.recurring?.usage_type === 'metered');
|
|
223
214
|
const allowance = hasMetered ? '0' : amount;
|
|
224
215
|
|
package/api/src/libs/session.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currencyI
|
|
|
109
109
|
setup = setup.add(new BN(unit).mul(new BN(x.quantity)));
|
|
110
110
|
if (price.type === 'recurring') {
|
|
111
111
|
if (trialInDays === 0) {
|
|
112
|
-
subscription =
|
|
112
|
+
subscription = subscription.add(new BN(unit).mul(new BN(x.quantity)));
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
});
|
package/api/src/libs/util.ts
CHANGED
|
@@ -152,15 +152,18 @@ export function getTxMetadata(extra: Record<string, any> = {}): any {
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
export function
|
|
156
|
-
|
|
155
|
+
export function getDataObjectFromQuery(
|
|
156
|
+
query: Record<string, any> = {},
|
|
157
|
+
prefix: string = 'metadata'
|
|
158
|
+
): Record<string, any> {
|
|
159
|
+
const result: Record<string, any> = {};
|
|
157
160
|
Object.keys(query).forEach((key) => {
|
|
158
|
-
if (key.startsWith(
|
|
159
|
-
|
|
161
|
+
if (key.startsWith(`${prefix}.`) && query[key]) {
|
|
162
|
+
result[key.replace(`${prefix}.`, '')] = query[key];
|
|
160
163
|
}
|
|
161
164
|
});
|
|
162
165
|
|
|
163
|
-
return
|
|
166
|
+
return result;
|
|
164
167
|
}
|
|
165
168
|
|
|
166
169
|
// @FIXME: 这个应该封装在某个通用类库里面 @jianchao @wangshijun
|
|
@@ -20,116 +20,41 @@ type SubscriptionJob = {
|
|
|
20
20
|
|
|
21
21
|
const EXPECTED_SUBSCRIPTION_STATUS = ['trialing', 'active', 'paused', 'past_due'];
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (EXPECTED_SUBSCRIPTION_STATUS.includes(subscription.status) === false) {
|
|
33
|
-
logger.warn(`Subscription status not expected: ${job.subscriptionId}`);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const supportAutoCharge = await PaymentMethod.supportAutoCharge(subscription.default_payment_method_id);
|
|
37
|
-
if (supportAutoCharge === false) {
|
|
38
|
-
logger.warn(`Subscription does not support auto charge: ${job.subscriptionId}`);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const now = dayjs().unix();
|
|
43
|
-
|
|
44
|
-
// Do we need to cancel the subscription
|
|
45
|
-
if (subscription.isImmutable() === false) {
|
|
46
|
-
if (subscription.cancel_at_period_end) {
|
|
47
|
-
await subscription.update({ status: 'canceled', canceled_at: now });
|
|
48
|
-
logger.warn(`Subscription canceled on period end: ${job.subscriptionId}`);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (subscription.cancel_at && subscription.cancel_at <= now) {
|
|
52
|
-
await subscription.update({ status: 'canceled', canceled_at: now });
|
|
53
|
-
logger.warn(`Subscription canceled on schedule: ${job.subscriptionId}`);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Do we need to resume the subscription
|
|
59
|
-
if (subscription.pause_collection?.resumes_at && subscription.pause_collection?.resumes_at <= now) {
|
|
60
|
-
await subscription.update({ status: 'active', pause_collection: undefined });
|
|
61
|
-
logger.warn(`Subscription resumed as scheduled: ${job.subscriptionId}`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// can we create new invoice for this subscription?
|
|
65
|
-
if (subscription.status === 'trialing' && subscription.trail_end && subscription.trail_end > now) {
|
|
66
|
-
logger.warn(`Subscription trialing period not ended: ${job.subscriptionId}`);
|
|
67
|
-
subscriptionQueue.push({
|
|
68
|
-
id: subscription.id,
|
|
69
|
-
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
70
|
-
runAt: subscription.trail_end,
|
|
71
|
-
});
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
if (subscription.status === 'active' && subscription.current_period_end > now) {
|
|
75
|
-
logger.warn(`Subscription current period not ended: ${job.subscriptionId}`);
|
|
76
|
-
subscriptionQueue.push({
|
|
77
|
-
id: subscription.id,
|
|
78
|
-
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
79
|
-
runAt: subscription.current_period_end,
|
|
80
|
-
});
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (subscription.isActive() === false) {
|
|
85
|
-
logger.warn(`Subscription not active: ${job.subscriptionId}, so new invoice is skipped`);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
23
|
+
const handleSubscriptionInvoice = async (
|
|
24
|
+
subscription: Subscription,
|
|
25
|
+
filter: (x: any) => boolean,
|
|
26
|
+
status: string,
|
|
27
|
+
reason: 'cycle' | 'cancel',
|
|
28
|
+
start: number,
|
|
29
|
+
end: number,
|
|
30
|
+
offset: number
|
|
31
|
+
) => {
|
|
89
32
|
// Do we still have the customer
|
|
90
33
|
const customer = await Customer.findByPk(subscription.customer_id);
|
|
91
34
|
if (!customer) {
|
|
92
35
|
logger.warn(`Customer ${subscription.customer_id} not found for subscription: ${subscription.id}`);
|
|
93
|
-
return;
|
|
36
|
+
return null;
|
|
94
37
|
}
|
|
95
38
|
|
|
96
39
|
// Do we still have the currency
|
|
97
40
|
const currency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
98
41
|
if (!currency) {
|
|
99
42
|
logger.warn(`Currency ${subscription.currency_id} not found for subscription: ${subscription.id}`);
|
|
100
|
-
return;
|
|
43
|
+
return null;
|
|
101
44
|
}
|
|
102
45
|
|
|
103
|
-
// get setup for next subscription period
|
|
104
|
-
const previousPeriodEnd =
|
|
105
|
-
subscription.status === 'trialing' ? subscription.trail_end : subscription.current_period_end;
|
|
106
|
-
const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
|
|
107
|
-
|
|
108
46
|
// check if invoice already created
|
|
109
47
|
const exist = await Invoice.findOne({
|
|
110
48
|
where: {
|
|
111
49
|
subscription_id: subscription.id,
|
|
112
|
-
period_start:
|
|
113
|
-
period_end:
|
|
50
|
+
period_start: start,
|
|
51
|
+
period_end: end,
|
|
52
|
+
billing_reason: `subscription_${reason}`,
|
|
114
53
|
},
|
|
115
54
|
});
|
|
116
55
|
if (exist) {
|
|
117
|
-
logger.warn(`Invoice already created for subscription ${subscription.id} for
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// set invoice status if subscription paused
|
|
122
|
-
let status = 'open';
|
|
123
|
-
if (subscription.pause_collection) {
|
|
124
|
-
if (subscription.pause_collection.behavior === 'mark_uncollectible') {
|
|
125
|
-
status = 'uncollectible';
|
|
126
|
-
}
|
|
127
|
-
if (subscription.pause_collection.behavior === 'void') {
|
|
128
|
-
status = 'void';
|
|
129
|
-
}
|
|
130
|
-
if (subscription.pause_collection.behavior === 'keep_as_draft') {
|
|
131
|
-
status = 'draft';
|
|
132
|
-
}
|
|
56
|
+
logger.warn(`Invoice already created for subscription ${subscription.id} for ${reason}: ${exist.id}`);
|
|
57
|
+
return null;
|
|
133
58
|
}
|
|
134
59
|
|
|
135
60
|
// expand subscription items
|
|
@@ -141,16 +66,14 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
141
66
|
|
|
142
67
|
// get usage summaries for this billing cycle
|
|
143
68
|
expandedItems = await Promise.all(
|
|
144
|
-
expandedItems.map(async (x: any) => {
|
|
69
|
+
expandedItems.filter(filter).map(async (x: any) => {
|
|
145
70
|
// For metered billing, we need to get usage summary for this billing cycle
|
|
146
71
|
// @link https://stripe.com/docs/products-prices/pricing-models#usage-types
|
|
147
72
|
if (x.price.recurring?.usage_type === 'metered') {
|
|
148
|
-
const duration = setup.cycle / 1000;
|
|
149
73
|
const rawQuantity = await UsageRecord.getSummary(
|
|
150
74
|
x.id,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
setup.period.end - duration,
|
|
75
|
+
start - offset,
|
|
76
|
+
end - offset,
|
|
154
77
|
x.price.recurring?.aggregate_usage
|
|
155
78
|
);
|
|
156
79
|
if (x.price.transform_quantity) {
|
|
@@ -169,8 +92,8 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
169
92
|
transformQuantity: x.price.transform_quantity,
|
|
170
93
|
rawQuantity,
|
|
171
94
|
quantity: x.quantity,
|
|
172
|
-
start:
|
|
173
|
-
end:
|
|
95
|
+
start: start - offset,
|
|
96
|
+
end: end - offset,
|
|
174
97
|
usage: x.price.recurring?.aggregate_usage,
|
|
175
98
|
});
|
|
176
99
|
|
|
@@ -182,6 +105,9 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
182
105
|
return x;
|
|
183
106
|
})
|
|
184
107
|
);
|
|
108
|
+
if (expandedItems.length === 0) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
185
111
|
|
|
186
112
|
const amount = getSubscriptionCycleAmount(expandedItems, currency.id);
|
|
187
113
|
|
|
@@ -193,13 +119,13 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
193
119
|
lineItems: expandedItems,
|
|
194
120
|
props: {
|
|
195
121
|
livemode: subscription.livemode,
|
|
196
|
-
description:
|
|
122
|
+
description: `Subscription ${reason}`,
|
|
197
123
|
statement_descriptor: getStatementDescriptor(expandedItems),
|
|
198
|
-
period_start:
|
|
199
|
-
period_end:
|
|
124
|
+
period_start: start,
|
|
125
|
+
period_end: end,
|
|
200
126
|
auto_advance: true,
|
|
201
127
|
status,
|
|
202
|
-
billing_reason:
|
|
128
|
+
billing_reason: `subscription_${reason}`,
|
|
203
129
|
currency_id: subscription.currency_id,
|
|
204
130
|
total: amount.total,
|
|
205
131
|
payment_settings: subscription.payment_settings,
|
|
@@ -208,17 +134,74 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
208
134
|
} as Invoice,
|
|
209
135
|
});
|
|
210
136
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
137
|
+
return invoice;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleSubscriptionBeforeCancel = async (subscription: Subscription) => {
|
|
141
|
+
const invoice = await handleSubscriptionInvoice(
|
|
142
|
+
subscription,
|
|
143
|
+
(x) => x.price.recurring?.usage_type === 'metered', // include only metered items
|
|
144
|
+
'open',
|
|
145
|
+
'cancel',
|
|
146
|
+
subscription.current_period_start as number,
|
|
147
|
+
subscription.current_period_end as number,
|
|
148
|
+
0
|
|
149
|
+
);
|
|
214
150
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
151
|
+
if (invoice) {
|
|
152
|
+
// schedule invoice job
|
|
153
|
+
invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
|
|
154
|
+
logger.info(`Invoice job scheduled before cancel: ${invoice.id}`);
|
|
155
|
+
|
|
156
|
+
// persist invoice id
|
|
157
|
+
await subscription.update({ latest_invoice_id: invoice.id });
|
|
158
|
+
logger.info(`Subscription updated before cancel: ${subscription.id}`);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handleSubscriptionWhenActive = async (subscription: Subscription) => {
|
|
163
|
+
// get setup for next subscription period
|
|
164
|
+
const previousPeriodEnd =
|
|
165
|
+
subscription.status === 'trialing' ? subscription.trail_end : subscription.current_period_end;
|
|
166
|
+
const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
|
|
167
|
+
|
|
168
|
+
// set invoice status if subscription paused
|
|
169
|
+
let status = 'open';
|
|
170
|
+
if (subscription.pause_collection) {
|
|
171
|
+
if (subscription.pause_collection.behavior === 'mark_uncollectible') {
|
|
172
|
+
status = 'uncollectible';
|
|
173
|
+
}
|
|
174
|
+
if (subscription.pause_collection.behavior === 'void') {
|
|
175
|
+
status = 'void';
|
|
176
|
+
}
|
|
177
|
+
if (subscription.pause_collection.behavior === 'keep_as_draft') {
|
|
178
|
+
status = 'draft';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const invoice = await handleSubscriptionInvoice(
|
|
183
|
+
subscription,
|
|
184
|
+
() => true, // include all items
|
|
185
|
+
status,
|
|
186
|
+
'cycle',
|
|
187
|
+
setup.period.start,
|
|
188
|
+
setup.period.end,
|
|
189
|
+
setup.cycle / 1000
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (invoice) {
|
|
193
|
+
// schedule invoice job
|
|
194
|
+
invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
|
|
195
|
+
logger.info(`Invoice job scheduled for new billing cycle: ${invoice.id}`);
|
|
196
|
+
|
|
197
|
+
// persist invoice id
|
|
198
|
+
await subscription.update({
|
|
199
|
+
latest_invoice_id: invoice.id,
|
|
200
|
+
current_period_start: setup.period.start,
|
|
201
|
+
current_period_end: setup.period.end,
|
|
202
|
+
});
|
|
203
|
+
logger.info(`Subscription updated for new billing cycle: ${subscription.id}`);
|
|
204
|
+
}
|
|
222
205
|
|
|
223
206
|
// schedule next billing cycle if we are not in terminal state
|
|
224
207
|
if (subscription.isActive()) {
|
|
@@ -231,6 +214,77 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
231
214
|
}
|
|
232
215
|
};
|
|
233
216
|
|
|
217
|
+
// generate invoice for subscription periodically
|
|
218
|
+
export const handleSubscription = async (job: SubscriptionJob) => {
|
|
219
|
+
logger.info('handle subscription', job);
|
|
220
|
+
|
|
221
|
+
const subscription = await Subscription.findByPk(job.subscriptionId);
|
|
222
|
+
if (!subscription) {
|
|
223
|
+
logger.warn(`Subscription not found: ${job.subscriptionId}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (EXPECTED_SUBSCRIPTION_STATUS.includes(subscription.status) === false) {
|
|
227
|
+
logger.warn(`Subscription status not expected: ${job.subscriptionId}`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(subscription.default_payment_method_id);
|
|
231
|
+
if (supportAutoCharge === false) {
|
|
232
|
+
logger.warn(`Subscription does not support auto charge: ${job.subscriptionId}`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const now = dayjs().unix();
|
|
237
|
+
|
|
238
|
+
// Do we need to cancel the subscription
|
|
239
|
+
if (subscription.isImmutable() === false) {
|
|
240
|
+
if (subscription.cancel_at_period_end) {
|
|
241
|
+
await handleSubscriptionBeforeCancel(subscription);
|
|
242
|
+
await subscription.update({ status: 'canceled', canceled_at: now });
|
|
243
|
+
logger.warn(`Subscription canceled on period end: ${job.subscriptionId}`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (subscription.cancel_at && subscription.cancel_at <= now) {
|
|
247
|
+
await handleSubscriptionBeforeCancel(subscription);
|
|
248
|
+
await subscription.update({ status: 'canceled', canceled_at: now });
|
|
249
|
+
logger.warn(`Subscription canceled on schedule: ${job.subscriptionId}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Do we need to resume the subscription
|
|
255
|
+
if (subscription.pause_collection?.resumes_at && subscription.pause_collection?.resumes_at <= now) {
|
|
256
|
+
await subscription.update({ status: 'active', pause_collection: undefined });
|
|
257
|
+
logger.warn(`Subscription resumed as scheduled: ${job.subscriptionId}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// can we create new invoice for this subscription?
|
|
261
|
+
if (subscription.status === 'trialing' && subscription.trail_end && subscription.trail_end > now) {
|
|
262
|
+
logger.warn(`Subscription trialing period not ended: ${job.subscriptionId}`);
|
|
263
|
+
subscriptionQueue.push({
|
|
264
|
+
id: subscription.id,
|
|
265
|
+
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
266
|
+
runAt: subscription.trail_end,
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (subscription.status === 'active' && subscription.current_period_end > now) {
|
|
271
|
+
logger.warn(`Subscription current period not ended: ${job.subscriptionId}`);
|
|
272
|
+
subscriptionQueue.push({
|
|
273
|
+
id: subscription.id,
|
|
274
|
+
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
275
|
+
runAt: subscription.current_period_end,
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (subscription.isActive() === false) {
|
|
281
|
+
logger.warn(`Subscription not active: ${job.subscriptionId}, so new invoice is skipped`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await handleSubscriptionWhenActive(subscription);
|
|
286
|
+
};
|
|
287
|
+
|
|
234
288
|
export const subscriptionQueue = createQueue<SubscriptionJob>({
|
|
235
289
|
name: 'subscription',
|
|
236
290
|
onJob: handleSubscription,
|