payment-kit 1.15.16 → 1.15.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/api/src/integrations/stripe/handlers/invoice.ts +20 -0
  2. package/api/src/integrations/stripe/resource.ts +2 -2
  3. package/api/src/libs/audit.ts +1 -1
  4. package/api/src/libs/invoice.ts +81 -1
  5. package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
  6. package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
  7. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
  8. package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
  9. package/api/src/libs/notification/template/subscription-renewed.ts +11 -3
  10. package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
  11. package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
  12. package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
  13. package/api/src/libs/notification/template/subscription-trial-will-end.ts +17 -0
  14. package/api/src/libs/notification/template/subscription-upgraded.ts +51 -26
  15. package/api/src/libs/notification/template/subscription-will-canceled.ts +16 -0
  16. package/api/src/libs/notification/template/subscription-will-renew.ts +15 -3
  17. package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
  18. package/api/src/libs/queue/index.ts +69 -19
  19. package/api/src/libs/queue/store.ts +28 -5
  20. package/api/src/libs/subscription.ts +129 -19
  21. package/api/src/libs/util.ts +30 -0
  22. package/api/src/locales/en.ts +13 -0
  23. package/api/src/locales/zh.ts +13 -0
  24. package/api/src/queues/invoice.ts +58 -20
  25. package/api/src/queues/notification.ts +43 -1
  26. package/api/src/queues/payment.ts +5 -1
  27. package/api/src/queues/subscription.ts +64 -15
  28. package/api/src/routes/checkout-sessions.ts +26 -0
  29. package/api/src/routes/invoices.ts +11 -31
  30. package/api/src/routes/subscriptions.ts +43 -7
  31. package/api/src/store/models/checkout-session.ts +2 -0
  32. package/api/src/store/models/job.ts +4 -0
  33. package/api/src/store/models/types.ts +22 -4
  34. package/api/src/store/models/usage-record.ts +5 -1
  35. package/api/tests/libs/subscription.spec.ts +154 -0
  36. package/api/tests/libs/util.spec.ts +135 -0
  37. package/blocklet.yml +1 -1
  38. package/package.json +10 -10
  39. package/scripts/sdk.js +37 -3
  40. package/src/components/invoice/list.tsx +0 -1
  41. package/src/components/invoice/table.tsx +7 -2
  42. package/src/components/subscription/items/index.tsx +26 -7
  43. package/src/components/subscription/items/usage-records.tsx +21 -10
  44. package/src/components/subscription/portal/actions.tsx +16 -14
  45. package/src/libs/util.ts +51 -0
  46. package/src/locales/en.tsx +2 -0
  47. package/src/locales/zh.tsx +2 -0
  48. package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
  49. package/src/pages/customer/subscription/change-plan.tsx +1 -1
  50. package/src/pages/customer/subscription/embed.tsx +16 -14
  51. package/vite-server.config.ts +8 -0
@@ -5,9 +5,12 @@ import EventEmitter from 'events';
5
5
  import fastq from 'fastq';
6
6
  import { nanoid } from 'nanoid';
7
7
 
8
+ import { AsyncLocalStorage } from 'async_hooks';
8
9
  import logger from '../logger';
9
10
  import { sleep, tryWithTimeout } from '../util';
10
11
  import createQueueStore from './store';
12
+ import { Job } from '../../store/models/job';
13
+ import { sequelize } from '../../store/sequelize';
11
14
 
12
15
  const CANCELLED = '__CANCELLED__';
13
16
  const MIN_DELAY = process.env.NODE_ENV === 'test' ? 2 : 8;
@@ -50,26 +53,56 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
50
53
  const getJobId = (id: string | undefined, job: any) => id || (options.id ? options.id(job) : nanoid()) || nanoid();
51
54
 
52
55
  const queueEvents = new EventEmitter();
56
+ const asyncLocalStorage = new AsyncLocalStorage();
57
+ const runningJobs = new Set();
58
+ const cancelledJobs = new Set();
59
+
60
+ const ensureUniqueExecution = async (id: string, fn: () => Promise<any>) => {
61
+ if (runningJobs.has(id)) {
62
+ logger.warn('Job already running, skipping', { id });
63
+ return;
64
+ }
65
+
66
+ runningJobs.add(id);
67
+ try {
68
+ await asyncLocalStorage.run({ id }, fn);
69
+ } finally {
70
+ runningJobs.delete(id);
71
+ logger.info(`Running job deleted: ${id}`);
72
+ }
73
+ };
74
+
53
75
  const queue = fastq(async ({ job, id, persist }: { job: any; id: string; persist: boolean }, cb: Function) => {
54
- if (persist) {
55
- try {
56
- const cancelled = await store.isCancelled(id);
57
- if (cancelled) {
58
- cb(null, CANCELLED);
76
+ logger.info('execute job', { id, job, persist });
77
+ await ensureUniqueExecution(id, async () => {
78
+ if (persist) {
79
+ try {
80
+ const cancelled = await store.isCancelled(id);
81
+ if (cancelled) {
82
+ cb(null, CANCELLED);
83
+ return;
84
+ }
85
+ } catch (err) {
86
+ cb(err);
59
87
  return;
60
88
  }
61
- } catch (err) {
62
- cb(err);
89
+ }
90
+
91
+ if (cancelledJobs.has(id)) {
92
+ cb(null, CANCELLED);
93
+ logger.info(`Job skipped because it is cancelled: ${id}`);
94
+ cancelledJobs.delete(id);
63
95
  return;
64
96
  }
65
- }
66
97
 
67
- try {
68
- const result = await tryWithTimeout(() => onJob(job), maxTimeout);
69
- cb(null, result);
70
- } catch (err) {
71
- cb(err);
72
- }
98
+ try {
99
+ const result = await tryWithTimeout(() => onJob(job), maxTimeout);
100
+ logger.info('job finished', { id, result });
101
+ cb(null, result);
102
+ } catch (err) {
103
+ cb(err);
104
+ }
105
+ });
73
106
  // @ts-ignore
74
107
  }, concurrency);
75
108
 
@@ -109,7 +142,8 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
109
142
  store
110
143
  .addJob(jobId, job, attrs)
111
144
  .then(() => {
112
- emit('queued', { id: jobId, job, attrs });
145
+ emit('queued', { id: jobId, job, attrs, persist });
146
+ logger.info('delayed or scheduled job queued', { id: jobId, job, attrs, persist });
113
147
  })
114
148
  .catch((err) => {
115
149
  console.error(err);
@@ -157,7 +191,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
157
191
  logger.info('retry job', { id: jobId, count: doc.retry_count + 1 });
158
192
  setTimeout(() => {
159
193
  emit('retry', { id: jobId, job, doc });
160
- queue.unshift({ id: jobId, job }, onJobComplete);
194
+ queue.unshift({ id: jobId, job, persist }, onJobComplete);
161
195
  }, retryDelay);
162
196
  // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
163
197
  } catch (err: any) {
@@ -171,6 +205,9 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
171
205
  setImmediate(() => {
172
206
  emit('queued', { id: jobId, job, persist });
173
207
  logger.info('queue job', { id: jobId, job });
208
+ if (cancelledJobs.has(jobId)) {
209
+ cancelledJobs.delete(jobId);
210
+ }
174
211
  queue.push({ id: jobId, job, persist }, onJobComplete);
175
212
  });
176
213
 
@@ -208,6 +245,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
208
245
 
209
246
  const cancel = async (id: string) => {
210
247
  const doc = await store.updateJob(id, { cancelled: true });
248
+ cancelledJobs.add(id);
211
249
  return doc ? doc.job : null;
212
250
  };
213
251
 
@@ -219,18 +257,26 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
219
257
  const deleteJob = async (id: string): Promise<boolean> => {
220
258
  const exists = await getJob(id);
221
259
 
222
- if (exists) {
260
+ if (!exists) {
261
+ return false;
262
+ }
263
+ try {
223
264
  await cancel(id);
224
265
  await store.deleteJob(id);
266
+ logger.info(`Job deleted successfully: ${id}`);
225
267
  return true;
268
+ } catch (error) {
269
+ console.error(`Error during job deletion process for ${id}:`, error);
270
+ return false;
226
271
  }
227
-
228
- return false;
229
272
  };
230
273
 
231
274
  // Populate the queue on startup
232
275
  process.nextTick(async () => {
233
276
  try {
277
+ if (!Job.isInitialized()) {
278
+ Job.initialize(sequelize);
279
+ }
234
280
  const jobs = await store.getJobs();
235
281
  logger.info(`${name} jobs to populate`, { count: jobs.length });
236
282
  jobs.forEach((x) => {
@@ -260,6 +306,10 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
260
306
  if (x.job && x.id) {
261
307
  // fix: https://github.com/blocklet/payment-kit/issues/287
262
308
  await cancel(x.id);
309
+ logger.info('reschedule delayed or scheduled job', {
310
+ id: x.id,
311
+ job: x.job,
312
+ });
263
313
  push({ job: x.job, id: x.id, persist: false });
264
314
  } else {
265
315
  logger.info('skip invalid job from db', { job: x });
@@ -13,7 +13,11 @@ export default function createQueueStore(queue: string) {
13
13
  return Job.findOne({ where: { queue, id }, transaction: null });
14
14
  },
15
15
  getJobs(): Promise<TJob[]> {
16
- return Job.findAll({ where: { queue, delay: -1 }, order: [['created_at', 'ASC']], transaction: null });
16
+ return Job.findAll({
17
+ where: { queue, delay: -1, cancelled: false },
18
+ order: [['created_at', 'ASC']],
19
+ transaction: null,
20
+ });
17
21
  },
18
22
  getScheduledJobs(): Promise<TJob[]> {
19
23
  return Job.findAll({
@@ -31,12 +35,31 @@ export default function createQueueStore(queue: string) {
31
35
  return job.update(updates);
32
36
  },
33
37
  async addJob(id: string, job: any, attrs: Partial<TJob> = {}): Promise<TJob> {
34
- const exist = await Job.findOne({ where: { queue, id }, transaction: null });
35
- if (exist) {
36
- throw new CustomError('JOB_DUPLICATE', `Job ${queue}#${id} already exist`);
38
+ const existingJob = await Job.findOne({ where: { id } });
39
+
40
+ if (existingJob) {
41
+ throw new CustomError(
42
+ 'JOB_DUPLICATE',
43
+ `Job with id ${id} already exists ${existingJob.queue === queue ? 'in the same queue' : `in queue ${existingJob.queue}`}`
44
+ );
37
45
  }
38
46
 
39
- return Job.create({ id, job, queue, retry_count: 1, cancelled: false, ...attrs });
47
+ try {
48
+ const newJob = await Job.create({
49
+ id,
50
+ job,
51
+ queue,
52
+ retry_count: 1,
53
+ cancelled: false,
54
+ ...attrs,
55
+ });
56
+ return newJob;
57
+ } catch (error) {
58
+ if (error.name === 'SequelizeUniqueConstraintError') {
59
+ throw new CustomError('JOB_DUPLICATE', `Job with id ${id} was created concurrently`);
60
+ }
61
+ throw error;
62
+ }
40
63
  },
41
64
  async deleteJob(id: string): Promise<boolean> {
42
65
  const data = await Job.destroy({ where: { queue, id } });
@@ -49,6 +49,40 @@ export function getCustomerSubscriptionPageUrl({
49
49
  );
50
50
  }
51
51
 
52
+ export function getAdminSubscriptionPageUrl({
53
+ subscriptionId,
54
+ locale = 'en',
55
+ userDid,
56
+ }: {
57
+ subscriptionId: string;
58
+ locale: LiteralUnion<'en' | 'zh', string>;
59
+ userDid: string;
60
+ }) {
61
+ return component.getUrl(
62
+ withQuery(`admin/billing/${subscriptionId}`, {
63
+ locale,
64
+ ...getConnectQueryParam({ userDid }),
65
+ })
66
+ );
67
+ }
68
+
69
+ export function getAdminInvoicePageUrl({
70
+ invoiceId,
71
+ locale = 'en',
72
+ userDid,
73
+ }: {
74
+ invoiceId: string;
75
+ locale: LiteralUnion<'en' | 'zh', string>;
76
+ userDid: string;
77
+ }) {
78
+ return component.getUrl(
79
+ withQuery(`admin/billing/${invoiceId}`, {
80
+ locale,
81
+ ...getConnectQueryParam({ userDid }),
82
+ })
83
+ );
84
+ }
85
+
52
86
  export function parseIntegerConfig(alternatives: any[], defaultValue: number) {
53
87
  for (const raw of alternatives) {
54
88
  const days = parseInt(raw, 10);
@@ -473,7 +507,7 @@ export async function finalizeSubscriptionUpdate({
473
507
  }: {
474
508
  subscription: Subscription;
475
509
  customer: Customer;
476
- invoice: Invoice;
510
+ invoice: Invoice | null;
477
511
  paymentCurrency: PaymentCurrency;
478
512
  appliedCredit: string;
479
513
  newCredit: string;
@@ -522,27 +556,31 @@ export async function finalizeSubscriptionUpdate({
522
556
  // update customer credits
523
557
  if (appliedCredit !== '0') {
524
558
  const creditResult = await customer.decreaseTokenBalance(paymentCurrency.id, appliedCredit);
525
- await invoice.update({
526
- starting_token_balance: creditResult.starting,
527
- ending_token_balance: creditResult.ending,
528
- });
529
- logger.info('customer credit applied to invoice after proration', {
530
- subscription: subscription.id,
531
- appliedCredit,
532
- creditResult,
533
- });
559
+ if (invoice) {
560
+ await invoice.update({
561
+ starting_token_balance: creditResult.starting,
562
+ ending_token_balance: creditResult.ending,
563
+ });
564
+ logger.info('customer credit applied to invoice after proration', {
565
+ subscription: subscription.id,
566
+ appliedCredit,
567
+ creditResult,
568
+ });
569
+ }
534
570
  }
535
571
  if (newCredit !== '0') {
536
572
  const creditResult = await customer.increaseTokenBalance(paymentCurrency.id, newCredit);
537
- await invoice.update({
538
- starting_token_balance: creditResult.starting,
539
- ending_token_balance: creditResult.ending,
540
- });
541
- logger.info('subscription proration credit applied to customer', {
542
- subscription: subscription.id,
543
- newCredit,
544
- creditResult,
545
- });
573
+ if (invoice) {
574
+ await invoice.update({
575
+ starting_token_balance: creditResult.starting,
576
+ ending_token_balance: creditResult.ending,
577
+ });
578
+ logger.info('subscription proration credit applied to customer', {
579
+ subscription: subscription.id,
580
+ newCredit,
581
+ creditResult,
582
+ });
583
+ }
546
584
  }
547
585
 
548
586
  // lock for next update
@@ -866,3 +904,75 @@ export async function getSubscriptionStakeCancellation(
866
904
  }
867
905
  return cancellation;
868
906
  }
907
+
908
+ export async function getSubscriptionStakeAmountSetup(subscription: Subscription, paymentMethod: PaymentMethod) {
909
+ if (paymentMethod.type !== 'arcblock') {
910
+ return null;
911
+ }
912
+ const txHash = subscription?.payment_details?.arcblock?.staking?.tx_hash;
913
+ if (!txHash) {
914
+ return null;
915
+ }
916
+ const client = paymentMethod.getOcapClient();
917
+ const { info } = await client.getTx({ hash: txHash });
918
+ if (!info) {
919
+ return null;
920
+ }
921
+ // @ts-ignore
922
+ const inputs = info?.tx?.itxJson?.inputs || [];
923
+ if (!inputs || inputs.length === 0) {
924
+ logger.info('getSubscriptionStakeAmountSetup failed, no inputs', { txHash, info });
925
+ return null;
926
+ }
927
+ const amountRes: { [key: string]: BN } = {};
928
+ // calculate stake amount for each address
929
+ inputs.forEach((input: any) => {
930
+ const { tokens } = input;
931
+ tokens.forEach((token: any) => {
932
+ const { address, value } = token;
933
+ if (amountRes[address]) {
934
+ amountRes[address] = amountRes[address].add(new BN(value));
935
+ } else {
936
+ amountRes[address] = new BN(value);
937
+ }
938
+ });
939
+ });
940
+ Object.keys(amountRes).forEach((address) => {
941
+ amountRes[address] = amountRes[address].toString();
942
+ });
943
+ logger.info('get subscription stake amount setup success', { txHash, amountRes });
944
+ return amountRes;
945
+ }
946
+
947
+ // check if usage report is empty
948
+ export async function checkUsageReportEmpty(
949
+ subscription: Subscription,
950
+ usageReportStart: number,
951
+ usageReportEnd: number
952
+ ) {
953
+ const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
954
+ const expandedItems = await Price.expand(
955
+ subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
956
+ { product: true }
957
+ );
958
+ const meteredItems = expandedItems.filter((x: any) => x?.price?.recurring?.usage_type === 'metered');
959
+ if (meteredItems.length === 0) {
960
+ return false;
961
+ }
962
+ const usageReportEmpty = await Promise.all(
963
+ meteredItems.map(async (x: any) => {
964
+ const usageRecords = await UsageRecord.findAll({
965
+ where: {
966
+ subscription_item_id: x.id,
967
+ billed: false,
968
+ timestamp: {
969
+ [Op.gt]: usageReportStart,
970
+ [Op.lte]: usageReportEnd,
971
+ },
972
+ },
973
+ });
974
+ return usageRecords.length === 0;
975
+ })
976
+ );
977
+ return usageReportEmpty.every(Boolean);
978
+ }
@@ -10,6 +10,8 @@ import { joinURL, withQuery } from 'ufo';
10
10
 
11
11
  import dayjs from './dayjs';
12
12
  import { blocklet, wallet } from './auth';
13
+ import type { Subscription } from '../store/models';
14
+ import logger from './logger';
13
15
 
14
16
  export const OCAP_PAYMENT_TX_TYPE = 'fg:t:transfer_v2';
15
17
 
@@ -289,3 +291,31 @@ export function getCustomerProfileUrl({
289
291
  })
290
292
  );
291
293
  }
294
+
295
+ export async function getOwnerDid() {
296
+ try {
297
+ const { user } = await blocklet.getOwner();
298
+ return user?.did;
299
+ } catch (error) {
300
+ logger.error('getOwnerDid error', error);
301
+ return undefined;
302
+ }
303
+ }
304
+
305
+ export function getSubscriptionNotificationCustomActions(
306
+ subscription: Subscription,
307
+ eventType: string,
308
+ locale: string
309
+ ) {
310
+ if (!subscription || !subscription?.service_actions || !subscription?.service_actions?.length) {
311
+ return [];
312
+ }
313
+ const actions = subscription.service_actions?.filter(
314
+ (x: any) => x?.type === 'notification' && x?.triggerEvents?.includes(eventType)
315
+ );
316
+ return actions?.map((x: any) => ({
317
+ name: x?.name || x?.text?.[locale],
318
+ title: x?.text?.[locale],
319
+ link: x?.link,
320
+ }));
321
+ }
@@ -45,6 +45,14 @@ export default flat({
45
45
  qty: '{count} unit',
46
46
  failReason: 'Failure reason',
47
47
  balanceReminder: 'Balance reminder',
48
+ subscriptionId: 'Subscription ID',
49
+ shouldPayAmount: 'Should pay amount',
50
+ billedAmount: 'Billed amount',
51
+ },
52
+
53
+ billingDiscrepancy: {
54
+ title: '{productName} billing discrepancy',
55
+ body: 'Detected billing discrepancy for {productName}, please check.',
48
56
  },
49
57
 
50
58
  sendTo: 'Sent to',
@@ -53,6 +61,11 @@ export default flat({
53
61
  message: 'A new {collection} NFT is minted and sent to your wallet, please check it out.',
54
62
  },
55
63
 
64
+ usageReportEmpty: {
65
+ title: 'No usage report for {productName}',
66
+ body: 'No usage report for {productName} detected, please check.',
67
+ },
68
+
56
69
  subscriptionTrialStart: {
57
70
  title: 'Welcome to the start of your {productName} trial',
58
71
  body: 'Congratulations on your {productName} trial! The length of the trial is {trialDuration} and will end at {subscriptionTrialEnd}. Have fun with {productName}!',
@@ -45,6 +45,9 @@ export default flat({
45
45
  qty: '{count} 件',
46
46
  failReason: '失败原因',
47
47
  balanceReminder: '余额提醒',
48
+ subscriptionId: '订阅 ID',
49
+ shouldPayAmount: '应收金额',
50
+ billedAmount: '实缴金额',
48
51
  },
49
52
 
50
53
  sendTo: '发送给',
@@ -53,6 +56,16 @@ export default flat({
53
56
  message: '{collection} NFT 已经铸造完成并发送到你的钱包,请查收',
54
57
  },
55
58
 
59
+ usageReportEmpty: {
60
+ title: '{productName} 未上报用量',
61
+ body: '检测到 {productName} 未上报用量,请留意。',
62
+ },
63
+
64
+ billingDiscrepancy: {
65
+ title: '{productName} 账单金额核算不一致',
66
+ body: '检测到 {productName} 账单金额核算不一致,请留意。',
67
+ },
68
+
56
69
  subscriptionTrialStart: {
57
70
  title: '欢迎开始您的 {productName} 试用之旅',
58
71
  body: '恭喜您获得了 {productName} 的试用资格!试用期时长为 {trialDuration},将于 {subscriptionTrialEnd} 结束。祝您使用愉快!',
@@ -1,17 +1,20 @@
1
1
  import { Op } from 'sequelize';
2
2
 
3
+ import { getInvoiceShouldPayTotal } from '@api/libs/invoice';
3
4
  import { batchHandleStripeInvoices } from '../integrations/stripe/resource';
4
5
  import { createEvent } from '../libs/audit';
5
6
  import dayjs from '../libs/dayjs';
6
7
  import logger from '../libs/logger';
7
8
  import createQueue from '../libs/queue';
8
- import { PaymentMethod } from '../store/models';
9
+ import { PaymentMethod, Invoice } from '../store/models';
9
10
  import { CheckoutSession } from '../store/models/checkout-session';
10
- import { Invoice } from '../store/models/invoice';
11
11
  import { PaymentIntent } from '../store/models/payment-intent';
12
12
  import { Subscription } from '../store/models/subscription';
13
13
  import { paymentQueue } from './payment';
14
14
 
15
+ import { getLock } from '../libs/lock';
16
+ import { events } from '../libs/event';
17
+
15
18
  type InvoiceJob = {
16
19
  invoiceId: string;
17
20
  justCreate?: boolean;
@@ -195,28 +198,63 @@ export const invoiceQueue = createQueue<InvoiceJob>({
195
198
  });
196
199
 
197
200
  export const startInvoiceQueue = async () => {
198
- const invoices = await Invoice.findAll({
199
- where: {
200
- status: 'open',
201
- collection_method: 'charge_automatically',
202
- amount_remaining: { [Op.gt]: '0' },
203
- },
204
- });
205
-
206
- invoices.forEach(async (x) => {
207
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
208
- if (supportAutoCharge === false) {
209
- return;
210
- }
211
- const exist = await invoiceQueue.get(x.id);
212
- if (!exist) {
213
- invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
201
+ const lock = getLock('startInvoiceQueue');
202
+ if (lock.locked) {
203
+ return;
204
+ }
205
+
206
+ try {
207
+ await lock.acquire();
208
+ const invoices = await Invoice.findAll({
209
+ where: {
210
+ status: 'open',
211
+ collection_method: 'charge_automatically',
212
+ amount_remaining: { [Op.gt]: '0' },
213
+ },
214
+ });
215
+
216
+ const results = await Promise.allSettled(
217
+ invoices.map(async (x) => {
218
+ const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
219
+ if (supportAutoCharge === false) {
220
+ return;
221
+ }
222
+ const exist = await invoiceQueue.get(x.id);
223
+ if (!exist) {
224
+ await invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
225
+ }
226
+ })
227
+ );
228
+
229
+ const failed = results.filter((r) => r.status === 'rejected').length;
230
+ if (failed > 0) {
231
+ logger.warn(`Failed to process ${failed} invoices in startInvoiceQueue`);
214
232
  }
215
- });
216
233
 
217
- await batchHandleStripeInvoices();
234
+ await batchHandleStripeInvoices();
235
+ } catch (error) {
236
+ logger.error('Error in startInvoiceQueue:', error);
237
+ } finally {
238
+ lock.release();
239
+ }
218
240
  };
219
241
 
220
242
  invoiceQueue.on('failed', ({ id, job, error }) => {
221
243
  logger.error('Invoice job failed', { id, job, error });
222
244
  });
245
+
246
+ events.on('invoice.paid', async ({ id: invoiceId }) => {
247
+ const invoice = await Invoice.findByPk(invoiceId);
248
+ if (!invoice) {
249
+ logger.error('Invoice not found', { invoiceId });
250
+ return;
251
+ }
252
+ const checkBillingReason = ['subscription_cycle', 'subscription_cancel'];
253
+ if (checkBillingReason.includes(invoice.billing_reason)) {
254
+ const shouldPayTotal = await getInvoiceShouldPayTotal(invoice);
255
+ if (shouldPayTotal !== invoice.total) {
256
+ createEvent('Invoice', 'billing.discrepancy', invoice);
257
+ logger.info('create billing discrepancy event', { invoiceId, shouldPayTotal, invoiceTotal: invoice.total });
258
+ }
259
+ }
260
+ });
@@ -57,6 +57,14 @@ import {
57
57
  } from '../libs/notification/template/subscription-stake-slash-succeeded';
58
58
  import createQueue from '../libs/queue';
59
59
  import { CheckoutSession, EventType, Invoice, PaymentLink, Refund, Subscription } from '../store/models';
60
+ import {
61
+ UsageReportEmptyEmailTemplate,
62
+ UsageReportEmptyEmailTemplateOptions,
63
+ } from '../libs/notification/template/usage-report-empty';
64
+ import {
65
+ BillingDiscrepancyEmailTemplate,
66
+ BillingDiscrepancyEmailTemplateOptions,
67
+ } from '../libs/notification/template/billing-discrepancy';
60
68
 
61
69
  export type NotificationQueueJobOptions = any;
62
70
 
@@ -65,7 +73,9 @@ export type NotificationQueueJobType =
65
73
  | 'customer.subscription.will_renew'
66
74
  | 'customer.subscription.trial_will_end'
67
75
  | 'customer.subscription.will_canceled'
68
- | 'customer.reward.succeeded';
76
+ | 'customer.reward.succeeded'
77
+ | 'usage.report.empty'
78
+ | 'billing.discrepancy';
69
79
 
70
80
  export type NotificationQueueJob = {
71
81
  type: NotificationQueueJobType;
@@ -73,6 +83,12 @@ export type NotificationQueueJob = {
73
83
  };
74
84
 
75
85
  function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
86
+ if (job.type === 'usage.report.empty') {
87
+ return new UsageReportEmptyEmailTemplate(job.options as UsageReportEmptyEmailTemplateOptions);
88
+ }
89
+ if (job.type === 'billing.discrepancy') {
90
+ return new BillingDiscrepancyEmailTemplate(job.options as BillingDiscrepancyEmailTemplateOptions);
91
+ }
76
92
  if (job.type === 'customer.subscription.started') {
77
93
  return new SubscriptionSucceededEmailTemplate(job.options as SubscriptionSucceededEmailTemplateOptions);
78
94
  }
@@ -275,4 +291,30 @@ export async function startNotificationQueue() {
275
291
  },
276
292
  });
277
293
  });
294
+
295
+ events.on('usage.report.empty', (subscription: Subscription, { usageReportStart, usageReportEnd }) => {
296
+ notificationQueue.push({
297
+ id: `usage.report.empty.${subscription.id}`,
298
+ job: {
299
+ type: 'usage.report.empty',
300
+ options: {
301
+ subscriptionId: subscription.id,
302
+ usageReportStart,
303
+ usageReportEnd,
304
+ },
305
+ },
306
+ });
307
+ });
308
+
309
+ events.on('billing.discrepancy', (invoice: Invoice) => {
310
+ notificationQueue.push({
311
+ id: `billing.discrepancy.${invoice.id}`,
312
+ job: {
313
+ type: 'billing.discrepancy',
314
+ options: {
315
+ invoiceId: invoice.id,
316
+ },
317
+ },
318
+ });
319
+ });
278
320
  }
@@ -210,7 +210,7 @@ export const handlePaymentSucceed = async (
210
210
  }
211
211
  }
212
212
 
213
- if (triggerRenew) {
213
+ if (triggerRenew && invoice.billing_reason !== 'subscription_update') {
214
214
  if (invoice.billing_reason === 'subscription_cycle' || paymentIntent.capture_method === 'manual') {
215
215
  createEvent('Subscription', 'customer.subscription.renewed', subscription).catch(console.error);
216
216
  }
@@ -323,6 +323,10 @@ export const handlePaymentFailed = async (
323
323
  return updates.terminate;
324
324
  }
325
325
 
326
+ if (subscription.isImmutable()) {
327
+ logger.info('Subscription is immutable, no need to check due', { subscription: subscription.id });
328
+ return updates.terminate;
329
+ }
326
330
  // check days until cancel
327
331
  const dueUnit = getDueUnit(interval);
328
332
  const daysUntilCancel = getDaysUntilCancel(subscription);