payment-kit 1.13.128 → 1.13.129
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/session.ts +4 -65
- package/api/src/libs/subscription.ts +69 -1
- package/api/src/queues/checkout-session.ts +1 -18
- package/api/src/queues/subscription.ts +116 -78
- package/api/src/queues/usage-record.ts +155 -0
- package/api/src/routes/checkout-sessions.ts +7 -8
- package/api/src/routes/connect/setup.ts +2 -9
- package/api/src/routes/connect/subscribe.ts +2 -7
- package/api/src/routes/connect/update.ts +2 -7
- package/api/src/routes/subscriptions.ts +12 -30
- package/api/src/routes/usage-records.ts +33 -20
- package/api/src/store/migrations/20240202-usage-billed.ts +22 -0
- package/api/src/store/models/checkout-session.ts +1 -0
- package/api/src/store/models/price.ts +10 -0
- package/api/src/store/models/usage-record.ts +41 -17
- package/api/tests/libs/session.spec.ts +0 -77
- package/api/tests/libs/subscription.spec.ts +83 -1
- package/blocklet.yml +1 -1
- package/package.json +4 -4
- package/src/libs/util.ts +4 -1
package/api/src/libs/session.ts
CHANGED
|
@@ -7,7 +7,6 @@ import type { TLineItemExpanded, TPaymentCurrency, TPaymentMethodExpanded } from
|
|
|
7
7
|
import type { Price, TPrice } from '../store/models/price';
|
|
8
8
|
import type { Product } from '../store/models/product';
|
|
9
9
|
import type { PriceCurrency, PriceRecurring } from '../store/models/types';
|
|
10
|
-
import dayjs from './dayjs';
|
|
11
10
|
|
|
12
11
|
export function getStatementDescriptor(items: any[]) {
|
|
13
12
|
for (const item of items) {
|
|
@@ -99,69 +98,6 @@ export function getRecurringPeriod(recurring: PriceRecurring) {
|
|
|
99
98
|
}
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currencyId: string, trialInDays = 0) {
|
|
103
|
-
let setup = new BN(0);
|
|
104
|
-
|
|
105
|
-
items.forEach((x) => {
|
|
106
|
-
const price = x.upsell_price || x.price;
|
|
107
|
-
const unit = getPriceUintAmountByCurrency(price, currencyId);
|
|
108
|
-
if (price.type === 'one_time' || (price.type === 'recurring' && price.recurring?.usage_type === 'licensed')) {
|
|
109
|
-
setup = setup.add(new BN(unit).mul(new BN(x.quantity)));
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const item = items.find((x) => x.price.type === 'recurring');
|
|
114
|
-
const recurring = (item?.upsell_price || item?.price)?.recurring as PriceRecurring;
|
|
115
|
-
const cycle = getRecurringPeriod(recurring);
|
|
116
|
-
const trial = trialInDays ? trialInDays * 24 * 60 * 60 * 1000 : 0;
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
recurring,
|
|
120
|
-
cycle: {
|
|
121
|
-
duration: cycle,
|
|
122
|
-
anchor: trialInDays ? dayjs().add(trial, 'millisecond').unix() : dayjs().unix(),
|
|
123
|
-
},
|
|
124
|
-
trail: {
|
|
125
|
-
start: trialInDays ? dayjs().unix() : 0,
|
|
126
|
-
end: trialInDays ? dayjs().add(trial, 'millisecond').unix() : 0,
|
|
127
|
-
},
|
|
128
|
-
period: {
|
|
129
|
-
start: dayjs().unix(),
|
|
130
|
-
end: dayjs()
|
|
131
|
-
.add(trialInDays ? trial : cycle, 'millisecond')
|
|
132
|
-
.unix(),
|
|
133
|
-
},
|
|
134
|
-
amount: {
|
|
135
|
-
setup: setup.toString(),
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function getSubscriptionCycleSetup(recurring: PriceRecurring, previousPeriodEnd: number) {
|
|
141
|
-
const cycle = getRecurringPeriod(recurring);
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
recurring,
|
|
145
|
-
cycle,
|
|
146
|
-
period: {
|
|
147
|
-
start: previousPeriodEnd,
|
|
148
|
-
end: dayjs.unix(previousPeriodEnd).add(cycle, 'millisecond').unix(),
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyId: string) {
|
|
154
|
-
let amount = new BN(0);
|
|
155
|
-
|
|
156
|
-
items.forEach((x) => {
|
|
157
|
-
amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price, currencyId)).mul(new BN(x.quantity)));
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
total: amount.toString(),
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
101
|
export function expandLineItems(items: any[], products: Product[], prices: Price[]) {
|
|
166
102
|
items.forEach((item) => {
|
|
167
103
|
item.price = prices.find((x) => x.id === item.price_id);
|
|
@@ -230,7 +166,10 @@ export function isLineItemRecurringAligned(list: TLineItemExpanded[], index: num
|
|
|
230
166
|
}
|
|
231
167
|
|
|
232
168
|
// If the interval and interval_count are different, the recurring is not aligned
|
|
233
|
-
if (
|
|
169
|
+
if (
|
|
170
|
+
String(recurring?.interval) !== String(x.recurring?.interval) ||
|
|
171
|
+
Number(recurring?.interval_count) !== Number(x.recurring?.interval_count)
|
|
172
|
+
) {
|
|
234
173
|
return false;
|
|
235
174
|
}
|
|
236
175
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import component from '@blocklet/sdk/lib/component';
|
|
2
|
+
import { BN } from '@ocap/util';
|
|
3
|
+
|
|
4
|
+
import type { PriceRecurring, TLineItemExpanded } from '../store/models';
|
|
5
|
+
import dayjs from './dayjs';
|
|
6
|
+
import { getPriceUintAmountByCurrency, getRecurringPeriod } from './session';
|
|
2
7
|
|
|
3
8
|
export function getCustomerSubscriptionPageUrl(subscriptionId: string, locale: string = 'en') {
|
|
4
9
|
return component.getUrl(`customer/subscription/${subscriptionId}?locale=${locale}`);
|
|
@@ -61,3 +66,66 @@ export const getMinRetryMail = (interval: string) => {
|
|
|
61
66
|
|
|
62
67
|
return 15; // 18 hours
|
|
63
68
|
};
|
|
69
|
+
|
|
70
|
+
export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currencyId: string, trialInDays = 0) {
|
|
71
|
+
let setup = new BN(0);
|
|
72
|
+
|
|
73
|
+
items.forEach((x) => {
|
|
74
|
+
const price = x.upsell_price || x.price;
|
|
75
|
+
const unit = getPriceUintAmountByCurrency(price, currencyId);
|
|
76
|
+
if (price.type === 'one_time' || (price.type === 'recurring' && price.recurring?.usage_type === 'licensed')) {
|
|
77
|
+
setup = setup.add(new BN(unit).mul(new BN(x.quantity)));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const item = items.find((x) => x.price.type === 'recurring');
|
|
82
|
+
const recurring = (item?.upsell_price || item?.price)?.recurring as PriceRecurring;
|
|
83
|
+
const cycle = getRecurringPeriod(recurring);
|
|
84
|
+
const trial = trialInDays ? trialInDays * 24 * 60 * 60 * 1000 : 0;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
recurring,
|
|
88
|
+
cycle: {
|
|
89
|
+
duration: cycle,
|
|
90
|
+
anchor: trialInDays ? dayjs().add(trial, 'millisecond').unix() : dayjs().unix(),
|
|
91
|
+
},
|
|
92
|
+
trail: {
|
|
93
|
+
start: trialInDays ? dayjs().unix() : 0,
|
|
94
|
+
end: trialInDays ? dayjs().add(trial, 'millisecond').unix() : 0,
|
|
95
|
+
},
|
|
96
|
+
period: {
|
|
97
|
+
start: dayjs().unix(),
|
|
98
|
+
end: dayjs()
|
|
99
|
+
.add(trialInDays ? trial : cycle, 'millisecond')
|
|
100
|
+
.unix(),
|
|
101
|
+
},
|
|
102
|
+
amount: {
|
|
103
|
+
setup: setup.toString(),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getSubscriptionCycleSetup(recurring: PriceRecurring, previousPeriodEnd: number) {
|
|
109
|
+
const cycle = getRecurringPeriod(recurring);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
recurring,
|
|
113
|
+
cycle,
|
|
114
|
+
period: {
|
|
115
|
+
start: previousPeriodEnd,
|
|
116
|
+
end: dayjs.unix(previousPeriodEnd).add(cycle, 'millisecond').unix(),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyId: string) {
|
|
122
|
+
let amount = new BN(0);
|
|
123
|
+
|
|
124
|
+
items.forEach((x) => {
|
|
125
|
+
amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price, currencyId)).mul(new BN(x.quantity)));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
total: amount.toString(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Op } from 'sequelize';
|
|
3
3
|
|
|
4
4
|
import { mintNftForCheckoutSession } from '../integrations/blockchain/nft';
|
|
5
|
-
import { ensurePassportIssued
|
|
5
|
+
import { ensurePassportIssued } from '../integrations/blocklet/passport';
|
|
6
6
|
import dayjs from '../libs/dayjs';
|
|
7
7
|
import { events } from '../libs/event';
|
|
8
8
|
import logger from '../libs/logger';
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
Subscription,
|
|
18
18
|
SubscriptionItem,
|
|
19
19
|
} from '../store/models';
|
|
20
|
-
import { subscriptionQueue } from './subscription';
|
|
21
20
|
|
|
22
21
|
type CheckoutSessionJob = {
|
|
23
22
|
id: string;
|
|
@@ -88,22 +87,6 @@ export async function startCheckoutSessionQueue() {
|
|
|
88
87
|
});
|
|
89
88
|
});
|
|
90
89
|
|
|
91
|
-
events.on('customer.subscription.deleted', (subscription: Subscription) => {
|
|
92
|
-
ensurePassportRevoked(subscription).catch((err) => {
|
|
93
|
-
logger.error('ensurePassportRevoked failed', { error: err, subscription: subscription.id });
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// FIXME: ensure invoices that are open or uncollectible are voided
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
events.on('customer.subscription.past_due', (subscription: Subscription) => {
|
|
100
|
-
subscriptionQueue.push({
|
|
101
|
-
id: `cancel-${subscription.id}`,
|
|
102
|
-
job: { subscriptionId: subscription.id, action: 'cancel' },
|
|
103
|
-
runAt: subscription.current_period_end,
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
90
|
events.on('checkout.session.created', (checkoutSession: CheckoutSession) => {
|
|
108
91
|
if (checkoutSession.expires_at) {
|
|
109
92
|
checkoutSessionQueue.push({
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { LiteralUnion } from 'type-fest';
|
|
2
2
|
|
|
3
|
+
import { ensurePassportRevoked } from '../integrations/blocklet/passport';
|
|
3
4
|
import dayjs from '../libs/dayjs';
|
|
5
|
+
import { events } from '../libs/event';
|
|
4
6
|
import logger from '../libs/logger';
|
|
5
7
|
import createQueue from '../libs/queue';
|
|
6
|
-
import { getStatementDescriptor
|
|
8
|
+
import { getStatementDescriptor } from '../libs/session';
|
|
9
|
+
import { getSubscriptionCycleAmount, getSubscriptionCycleSetup } from '../libs/subscription';
|
|
7
10
|
import { ensureInvoiceAndItems } from '../routes/connect/shared';
|
|
8
11
|
import { PaymentCurrency, PaymentMethod, UsageRecord } from '../store/models';
|
|
9
12
|
import { Customer } from '../store/models/customer';
|
|
@@ -20,15 +23,29 @@ type SubscriptionJob = {
|
|
|
20
23
|
|
|
21
24
|
const EXPECTED_SUBSCRIPTION_STATUS = ['trialing', 'active', 'paused', 'past_due'];
|
|
22
25
|
|
|
23
|
-
const handleSubscriptionInvoice = async (
|
|
24
|
-
subscription
|
|
25
|
-
filter
|
|
26
|
-
status
|
|
27
|
-
reason
|
|
28
|
-
start
|
|
29
|
-
end
|
|
30
|
-
offset
|
|
31
|
-
|
|
26
|
+
export const handleSubscriptionInvoice = async ({
|
|
27
|
+
subscription,
|
|
28
|
+
filter,
|
|
29
|
+
status,
|
|
30
|
+
reason,
|
|
31
|
+
start,
|
|
32
|
+
end,
|
|
33
|
+
offset,
|
|
34
|
+
usageStart,
|
|
35
|
+
usageEnd,
|
|
36
|
+
metadata,
|
|
37
|
+
}: {
|
|
38
|
+
subscription: Subscription;
|
|
39
|
+
filter: (x: any) => boolean;
|
|
40
|
+
status: string;
|
|
41
|
+
reason: 'cycle' | 'cancel' | 'threshold';
|
|
42
|
+
start: number;
|
|
43
|
+
end: number;
|
|
44
|
+
offset: number;
|
|
45
|
+
usageStart?: number;
|
|
46
|
+
usageEnd?: number;
|
|
47
|
+
metadata?: Record<string, any>;
|
|
48
|
+
}) => {
|
|
32
49
|
// Do we still have the customer
|
|
33
50
|
const customer = await Customer.findByPk(subscription.customer_id);
|
|
34
51
|
if (!customer) {
|
|
@@ -59,18 +76,20 @@ const handleSubscriptionInvoice = async (
|
|
|
59
76
|
}
|
|
60
77
|
}
|
|
61
78
|
|
|
62
|
-
// check if invoice already created
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
// check if invoice already created for this reason
|
|
80
|
+
if (['cycle', 'cancel'].includes(reason)) {
|
|
81
|
+
const exist = await Invoice.findOne({
|
|
82
|
+
where: {
|
|
83
|
+
subscription_id: subscription.id,
|
|
84
|
+
period_start: start,
|
|
85
|
+
period_end: end,
|
|
86
|
+
billing_reason: `subscription_${reason}`,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
if (exist) {
|
|
90
|
+
logger.warn(`Invoice already created for subscription ${subscription.id} for ${reason}: ${exist.id}`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
// expand subscription items
|
|
@@ -86,21 +105,16 @@ const handleSubscriptionInvoice = async (
|
|
|
86
105
|
// For metered billing, we need to get usage summary for this billing cycle
|
|
87
106
|
// @link https://stripe.com/docs/products-prices/pricing-models#usage-types
|
|
88
107
|
if (x.price.recurring?.usage_type === 'metered') {
|
|
89
|
-
const rawQuantity = await UsageRecord.getSummary(
|
|
90
|
-
x.id,
|
|
91
|
-
start - offset,
|
|
92
|
-
end - offset,
|
|
93
|
-
x.price.recurring?.aggregate_usage
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
x.quantity = Math.floor(rawQuantity / x.price.transform_quantity.divide_by);
|
|
100
|
-
}
|
|
101
|
-
} else {
|
|
102
|
-
x.quantity = rawQuantity;
|
|
103
|
-
}
|
|
108
|
+
const rawQuantity = await UsageRecord.getSummary({
|
|
109
|
+
id: x.id,
|
|
110
|
+
start: (usageStart || start) - offset,
|
|
111
|
+
end: (usageEnd || end) - offset,
|
|
112
|
+
method: x.price.recurring?.aggregate_usage,
|
|
113
|
+
dryRun: false,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
x.quantity = x.price.transformQuantity(rawQuantity);
|
|
117
|
+
|
|
104
118
|
logger.info('Invoice.usageRecordSummary', {
|
|
105
119
|
subscriptionId: subscription.id,
|
|
106
120
|
subscriptionItemId: x.id,
|
|
@@ -108,8 +122,8 @@ const handleSubscriptionInvoice = async (
|
|
|
108
122
|
transformQuantity: x.price.transform_quantity,
|
|
109
123
|
rawQuantity,
|
|
110
124
|
quantity: x.quantity,
|
|
111
|
-
start: start - offset,
|
|
112
|
-
end: end - offset,
|
|
125
|
+
start: (usageStart || start) - offset,
|
|
126
|
+
end: (usageEnd || end) - offset,
|
|
113
127
|
usage: x.price.recurring?.aggregate_usage,
|
|
114
128
|
});
|
|
115
129
|
|
|
@@ -147,7 +161,7 @@ const handleSubscriptionInvoice = async (
|
|
|
147
161
|
total: amount.total,
|
|
148
162
|
payment_settings: subscription.payment_settings,
|
|
149
163
|
default_payment_method_id: subscription.default_payment_method_id,
|
|
150
|
-
metadata
|
|
164
|
+
metadata,
|
|
151
165
|
} as Invoice,
|
|
152
166
|
});
|
|
153
167
|
|
|
@@ -155,15 +169,15 @@ const handleSubscriptionInvoice = async (
|
|
|
155
169
|
};
|
|
156
170
|
|
|
157
171
|
const handleSubscriptionBeforeCancel = async (subscription: Subscription) => {
|
|
158
|
-
const invoice = await handleSubscriptionInvoice(
|
|
172
|
+
const invoice = await handleSubscriptionInvoice({
|
|
159
173
|
subscription,
|
|
160
|
-
(x) => x.price.recurring?.usage_type === 'metered', // include only metered items
|
|
161
|
-
'open',
|
|
162
|
-
'cancel',
|
|
163
|
-
subscription.current_period_start as number,
|
|
164
|
-
subscription.current_period_end as number,
|
|
165
|
-
0
|
|
166
|
-
);
|
|
174
|
+
filter: (x) => x.price.recurring?.usage_type === 'metered', // include only metered items
|
|
175
|
+
status: 'open',
|
|
176
|
+
reason: 'cancel',
|
|
177
|
+
start: subscription.current_period_start as number,
|
|
178
|
+
end: subscription.current_period_end as number,
|
|
179
|
+
offset: 0,
|
|
180
|
+
});
|
|
167
181
|
|
|
168
182
|
if (invoice) {
|
|
169
183
|
// schedule invoice job
|
|
@@ -196,15 +210,15 @@ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
|
|
|
196
210
|
}
|
|
197
211
|
}
|
|
198
212
|
|
|
199
|
-
const invoice = await handleSubscriptionInvoice(
|
|
213
|
+
const invoice = await handleSubscriptionInvoice({
|
|
200
214
|
subscription,
|
|
201
|
-
() => true, // include all items
|
|
215
|
+
filter: () => true, // include all items
|
|
202
216
|
status,
|
|
203
|
-
'cycle',
|
|
204
|
-
setup.period.start,
|
|
205
|
-
setup.period.end,
|
|
206
|
-
setup.cycle / 1000
|
|
207
|
-
);
|
|
217
|
+
reason: 'cycle',
|
|
218
|
+
start: setup.period.start,
|
|
219
|
+
end: setup.period.end,
|
|
220
|
+
offset: setup.cycle / 1000,
|
|
221
|
+
});
|
|
208
222
|
|
|
209
223
|
if (invoice) {
|
|
210
224
|
// schedule invoice job
|
|
@@ -222,11 +236,7 @@ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
|
|
|
222
236
|
|
|
223
237
|
// schedule next billing cycle if we are not in terminal state
|
|
224
238
|
if (subscription.isActive()) {
|
|
225
|
-
|
|
226
|
-
id: subscription.id,
|
|
227
|
-
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
228
|
-
runAt: setup.period.end,
|
|
229
|
-
});
|
|
239
|
+
await addSubscriptionJob(subscription, 'cycle', false, setup.period.end);
|
|
230
240
|
logger.info(`Subscription job scheduled for next billing cycle: ${subscription.id}`);
|
|
231
241
|
}
|
|
232
242
|
};
|
|
@@ -277,20 +287,12 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
277
287
|
// can we create new invoice for this subscription?
|
|
278
288
|
if (subscription.status === 'trialing' && subscription.trail_end && subscription.trail_end > now) {
|
|
279
289
|
logger.warn(`Subscription trialing period not ended: ${job.subscriptionId}`);
|
|
280
|
-
|
|
281
|
-
id: subscription.id,
|
|
282
|
-
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
283
|
-
runAt: subscription.trail_end,
|
|
284
|
-
});
|
|
290
|
+
await addSubscriptionJob(subscription, 'cycle', false, subscription.trail_end);
|
|
285
291
|
return;
|
|
286
292
|
}
|
|
287
293
|
if (subscription.status === 'active' && subscription.current_period_end > now) {
|
|
288
294
|
logger.warn(`Subscription current period not ended: ${job.subscriptionId}`);
|
|
289
|
-
|
|
290
|
-
id: subscription.id,
|
|
291
|
-
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
292
|
-
runAt: subscription.current_period_end,
|
|
293
|
-
});
|
|
295
|
+
await addSubscriptionJob(subscription, 'cycle');
|
|
294
296
|
return;
|
|
295
297
|
}
|
|
296
298
|
|
|
@@ -313,6 +315,19 @@ export const subscriptionQueue = createQueue<SubscriptionJob>({
|
|
|
313
315
|
});
|
|
314
316
|
|
|
315
317
|
export const startSubscriptionQueue = async () => {
|
|
318
|
+
events.on('customer.subscription.deleted', (subscription: Subscription) => {
|
|
319
|
+
ensurePassportRevoked(subscription).catch((err) => {
|
|
320
|
+
logger.error('ensurePassportRevoked failed', { error: err, subscription: subscription.id });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// FIXME: ensure invoices that are open or uncollectible are voided
|
|
324
|
+
});
|
|
325
|
+
|
|
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
|
+
});
|
|
330
|
+
|
|
316
331
|
const subscriptions = await Subscription.findAll({
|
|
317
332
|
where: {
|
|
318
333
|
status: EXPECTED_SUBSCRIPTION_STATUS,
|
|
@@ -324,17 +339,40 @@ export const startSubscriptionQueue = async () => {
|
|
|
324
339
|
if (supportAutoCharge === false) {
|
|
325
340
|
return;
|
|
326
341
|
}
|
|
327
|
-
|
|
328
|
-
if (!exist) {
|
|
329
|
-
subscriptionQueue.push({
|
|
330
|
-
id: x.id,
|
|
331
|
-
job: { subscriptionId: x.id, action: 'cycle' },
|
|
332
|
-
runAt: x.current_period_end,
|
|
333
|
-
});
|
|
334
|
-
}
|
|
342
|
+
await addSubscriptionJob(x, 'cycle');
|
|
335
343
|
});
|
|
336
344
|
};
|
|
337
345
|
|
|
346
|
+
export async function addSubscriptionJob(
|
|
347
|
+
subscription: Subscription,
|
|
348
|
+
action: 'cycle' | 'cancel' | 'resume',
|
|
349
|
+
replace?: boolean,
|
|
350
|
+
runAt?: number,
|
|
351
|
+
sync?: boolean
|
|
352
|
+
) {
|
|
353
|
+
const fn = sync ? 'pushAndWait' : 'push';
|
|
354
|
+
const cycleJob = await subscriptionQueue.get(subscription.id);
|
|
355
|
+
if (replace && cycleJob) {
|
|
356
|
+
await subscriptionQueue.delete(subscription.id);
|
|
357
|
+
logger.info(`subscription cycle job replaced with ${action} job`, { subscription: subscription.id });
|
|
358
|
+
}
|
|
359
|
+
if (action === 'cycle') {
|
|
360
|
+
if (!cycleJob) {
|
|
361
|
+
await subscriptionQueue[fn]({
|
|
362
|
+
id: subscription.id,
|
|
363
|
+
job: { subscriptionId: subscription.id, action },
|
|
364
|
+
runAt: runAt || subscription.current_period_end,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
await subscriptionQueue[fn]({
|
|
369
|
+
id: `${action}-${subscription.id}`,
|
|
370
|
+
job: { subscriptionId: subscription.id, action },
|
|
371
|
+
runAt: runAt || subscription.current_period_end,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
338
376
|
subscriptionQueue.on('failed', ({ id, job, error }) => {
|
|
339
377
|
logger.error('Subscription job failed', { id, job, error });
|
|
340
378
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
2
|
+
|
|
3
|
+
import dayjs from '../libs/dayjs';
|
|
4
|
+
import logger from '../libs/logger';
|
|
5
|
+
import createQueue from '../libs/queue';
|
|
6
|
+
import { getPriceUintAmountByCurrency } from '../libs/session';
|
|
7
|
+
import {
|
|
8
|
+
Invoice,
|
|
9
|
+
PaymentCurrency,
|
|
10
|
+
Price,
|
|
11
|
+
SubscriptionItem,
|
|
12
|
+
TLineItemExpanded,
|
|
13
|
+
TPrice,
|
|
14
|
+
UsageRecord,
|
|
15
|
+
} from '../store/models';
|
|
16
|
+
import { Subscription } from '../store/models/subscription';
|
|
17
|
+
import { invoiceQueue } from './invoice';
|
|
18
|
+
import { handleSubscriptionInvoice } from './subscription';
|
|
19
|
+
|
|
20
|
+
type UsageRecordJob = {
|
|
21
|
+
subscriptionId: string;
|
|
22
|
+
subscriptionItemId: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// FIXME: support subscription item level billing_thresholds
|
|
26
|
+
// generate invoice for metered billing
|
|
27
|
+
export const handleUsageRecord = async (job: UsageRecordJob) => {
|
|
28
|
+
logger.info('handle usage record', job);
|
|
29
|
+
|
|
30
|
+
const subscription = await Subscription.findByPk(job.subscriptionId);
|
|
31
|
+
if (!subscription) {
|
|
32
|
+
logger.warn('Subscription not found', job);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (subscription.isActive() === false) {
|
|
36
|
+
logger.warn('Subscription not active, so usage check is skipped', job);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!subscription.billing_thresholds?.amount_gte) {
|
|
40
|
+
logger.warn('Subscription billing_threshold not set', job);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const currency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
44
|
+
if (!currency) {
|
|
45
|
+
logger.warn('Subscription currency not found', job);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const item = await SubscriptionItem.findByPk(job.subscriptionItemId);
|
|
50
|
+
if (!item) {
|
|
51
|
+
logger.warn('SubscriptionItem not found', job);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// @ts-ignore
|
|
55
|
+
const lines = await Price.expand([{ id: item.id, price_id: item.price_id, quantity: item.quantity }], {
|
|
56
|
+
product: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const filter = (x: TLineItemExpanded) => x.price.recurring?.usage_type === 'metered';
|
|
60
|
+
const metered = lines.filter(filter);
|
|
61
|
+
if (!metered.length) {
|
|
62
|
+
logger.warn('SubscriptionItem not metered', job);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const [expanded] = metered;
|
|
66
|
+
|
|
67
|
+
const start = subscription.current_period_start as number;
|
|
68
|
+
const end = subscription.current_period_end as number;
|
|
69
|
+
const latestThresholdInvoice = await Invoice.findOne({
|
|
70
|
+
where: {
|
|
71
|
+
subscription_id: subscription.id,
|
|
72
|
+
billing_reason: 'subscription_threshold',
|
|
73
|
+
period_start: start,
|
|
74
|
+
period_end: end,
|
|
75
|
+
},
|
|
76
|
+
order: [['created_at', 'DESC']],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let usageStart = start;
|
|
80
|
+
const usageEnd = dayjs().unix();
|
|
81
|
+
if (latestThresholdInvoice && latestThresholdInvoice.metadata) {
|
|
82
|
+
usageStart = latestThresholdInvoice.metadata.usage_end as number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rawQuantity = await UsageRecord.getSummary({
|
|
86
|
+
// @ts-ignore
|
|
87
|
+
id: expanded?.id as string,
|
|
88
|
+
start: usageStart,
|
|
89
|
+
end: usageEnd,
|
|
90
|
+
method: expanded?.price.recurring?.aggregate_usage as any,
|
|
91
|
+
dryRun: true,
|
|
92
|
+
});
|
|
93
|
+
// @ts-ignore
|
|
94
|
+
const quantity = expanded?.price.transformQuantity(rawQuantity);
|
|
95
|
+
const unitAmount = getPriceUintAmountByCurrency(expanded?.price as TPrice, subscription.currency_id);
|
|
96
|
+
const totalAmount = new BN(quantity).mul(new BN(unitAmount));
|
|
97
|
+
const threshold = fromTokenToUnit(subscription.billing_thresholds.amount_gte, currency.decimal);
|
|
98
|
+
logger.info('SubscriptionItem Usage check', {
|
|
99
|
+
subscriptionId: subscription.id,
|
|
100
|
+
subscriptionItemId: item.id,
|
|
101
|
+
start: dayjs.unix(start).toISOString(),
|
|
102
|
+
end: dayjs.unix(end).toISOString(),
|
|
103
|
+
usageStart: dayjs.unix(usageStart).toISOString(),
|
|
104
|
+
usageEnd: dayjs.unix(usageEnd).toISOString(),
|
|
105
|
+
rawQuantity,
|
|
106
|
+
quantity,
|
|
107
|
+
unitAmount: fromUnitToToken(unitAmount, currency.decimal),
|
|
108
|
+
totalAmount: fromUnitToToken(totalAmount.toString(), currency.decimal),
|
|
109
|
+
threshold: fromUnitToToken(threshold.toString(), currency.decimal),
|
|
110
|
+
});
|
|
111
|
+
if (totalAmount.lt(threshold)) {
|
|
112
|
+
logger.info('SubscriptionItem usage below threshold', job);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
logger.info('SubscriptionItem usage exceeds threshold', job);
|
|
117
|
+
const invoice = await handleSubscriptionInvoice({
|
|
118
|
+
subscription,
|
|
119
|
+
filter, // include only metered items
|
|
120
|
+
status: 'open',
|
|
121
|
+
reason: 'threshold',
|
|
122
|
+
start,
|
|
123
|
+
end,
|
|
124
|
+
offset: 0,
|
|
125
|
+
usageStart,
|
|
126
|
+
usageEnd,
|
|
127
|
+
metadata: {
|
|
128
|
+
usage_start: usageStart,
|
|
129
|
+
usage_end: usageEnd,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (invoice) {
|
|
134
|
+
// schedule invoice job
|
|
135
|
+
invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
|
|
136
|
+
logger.info(`Invoice job scheduled on threshold: ${invoice.id}`);
|
|
137
|
+
|
|
138
|
+
// persist invoice id
|
|
139
|
+
await subscription.update({ latest_invoice_id: invoice.id });
|
|
140
|
+
logger.info(`Subscription updated on threshold: ${subscription.id}`);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const usageRecordQueue = createQueue<UsageRecordJob>({
|
|
145
|
+
name: 'usage-record',
|
|
146
|
+
onJob: handleUsageRecord,
|
|
147
|
+
options: {
|
|
148
|
+
concurrency: 5,
|
|
149
|
+
maxRetries: 3,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
usageRecordQueue.on('failed', ({ id, job, error }) => {
|
|
154
|
+
logger.error('Usage job failed', { id, job, error });
|
|
155
|
+
});
|
|
@@ -27,16 +27,15 @@ import {
|
|
|
27
27
|
getCheckoutMode,
|
|
28
28
|
getFastCheckoutAmount,
|
|
29
29
|
getStatementDescriptor,
|
|
30
|
-
getSubscriptionCreateSetup,
|
|
31
30
|
getSupportedPaymentCurrencies,
|
|
32
31
|
getSupportedPaymentMethods,
|
|
33
32
|
isLineItemAligned,
|
|
34
33
|
} from '../libs/session';
|
|
35
|
-
import { getDaysUntilDue } from '../libs/subscription';
|
|
34
|
+
import { getDaysUntilDue, getSubscriptionCreateSetup } from '../libs/subscription';
|
|
36
35
|
import { CHECKOUT_SESSION_TTL, createCodeGenerator, formatMetadata, getDataObjectFromQuery } from '../libs/util';
|
|
37
36
|
import { invoiceQueue } from '../queues/invoice';
|
|
38
37
|
import { paymentQueue } from '../queues/payment';
|
|
39
|
-
import {
|
|
38
|
+
import { addSubscriptionJob } from '../queues/subscription';
|
|
40
39
|
import type { TPriceExpanded, TProductExpanded } from '../store/models';
|
|
41
40
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
42
41
|
import { Customer } from '../store/models/customer';
|
|
@@ -654,6 +653,10 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
654
653
|
missing_payment_method: 'create_invoice',
|
|
655
654
|
},
|
|
656
655
|
},
|
|
656
|
+
// @ts-ignore
|
|
657
|
+
billing_thresholds: checkoutSession.subscription_data?.billing_threshold_amount
|
|
658
|
+
? { amount_gte: +checkoutSession.subscription_data.billing_threshold_amount, reset_billing_cycle_anchor: false } // prettier-ignore
|
|
659
|
+
: null,
|
|
657
660
|
pending_invoice_item_interval: setup.recurring,
|
|
658
661
|
pending_setup_intent: setupIntent?.id,
|
|
659
662
|
default_payment_method_id: paymentMethod.id,
|
|
@@ -759,11 +762,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
759
762
|
if (invoice) {
|
|
760
763
|
invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
|
|
761
764
|
}
|
|
762
|
-
|
|
763
|
-
id: subscription.id,
|
|
764
|
-
job: { subscriptionId: subscription.id, action: 'cycle' },
|
|
765
|
-
runAt: subscription.trail_end || subscription.current_period_end,
|
|
766
|
-
});
|
|
765
|
+
addSubscriptionJob(subscription, 'cycle', false, subscription.trail_end);
|
|
767
766
|
}
|
|
768
767
|
}
|
|
769
768
|
|