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.
@@ -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 (recurring?.interval !== x.recurring?.interval || recurring?.interval_count !== x.recurring?.interval_count) {
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 { component } from '@blocklet/sdk';
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, ensurePassportRevoked } from '../integrations/blocklet/passport';
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, getSubscriptionCycleAmount, getSubscriptionCycleSetup } from '../libs/session';
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: Subscription,
25
- filter: (x: any) => boolean,
26
- status: string,
27
- reason: 'cycle' | 'cancel',
28
- start: number,
29
- end: number,
30
- offset: number
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
- const exist = await Invoice.findOne({
64
- where: {
65
- subscription_id: subscription.id,
66
- period_start: start,
67
- period_end: end,
68
- billing_reason: `subscription_${reason}`,
69
- },
70
- });
71
- if (exist) {
72
- logger.warn(`Invoice already created for subscription ${subscription.id} for ${reason}: ${exist.id}`);
73
- return null;
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
- if (x.price.transform_quantity) {
96
- if (x.price.transform_quantity.round === 'up') {
97
- x.quantity = Math.ceil(rawQuantity / x.price.transform_quantity.divide_by);
98
- } else {
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
- subscriptionQueue.push({
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
- subscriptionQueue.push({
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
- subscriptionQueue.push({
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
- const exist = await subscriptionQueue.get(x.id);
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 { subscriptionQueue } from '../queues/subscription';
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
- subscriptionQueue.push({
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