From 72b1edbe258a865adc20117e6a853318a8d51405 Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Sun, 17 Aug 2025 10:00:12 +0330 Subject: [PATCH] feat: implement recurring invoice system for subscription renewals - Add SUBSCRIPTION_RENEWAL_JOB_NAME and related constants for renewal jobs - Implement createSubscriptionRenewalInvoice in invoice processor - Add automatic renewal invoice creation 7 days before subscription expiry - Handle subscription renewals in payInvoice method with period extension - Add scheduleNextRenewalJob method for continuous renewal scheduling - Prevent duplicate renewal invoices and jobs - Skip renewal jobs for free plans - Send notifications for renewal opportunities - Fix duplicate job scheduling issue by removing from subscribeToPlan Features: - Automatic renewal invoice creation before expiry - Subscription period extension on renewal payment - Continuous renewal cycle scheduling - Comprehensive error handling and validation - Integration with existing notification system --- src/modules/invoices/constants/index.ts | 7 ++ .../invoices/providers/invoices.service.ts | 47 +++++++++- .../invoices/queue/invoice.processor.ts | 94 ++++++++++++++++++- .../enums/subscription-status.enum.ts | 3 +- 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/src/modules/invoices/constants/index.ts b/src/modules/invoices/constants/index.ts index 6c8c09c..89fcb69 100755 --- a/src/modules/invoices/constants/index.ts +++ b/src/modules/invoices/constants/index.ts @@ -6,6 +6,7 @@ export const INVOICE = Object.freeze({ EXTERNAL_CALLBACK_JOB_NAME: "externalInvoice.callback", REMINDER_JOB_NAME: "reminderInvoice", RECURRING_JOB_NAME: "recurringInvoice", + SUBSCRIPTION_RENEWAL_JOB_NAME: "subscriptionRenewal", SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME: "subscriptionAdminNotification", // EXTERNAL_JOB_PRIORITY: 1, // high priority @@ -25,6 +26,12 @@ export const INVOICE = Object.freeze({ RECURRING_JOB_BACKOFF: 5 * 1000, // retry after 5 second RECURRING_JOB_TIMEOUT: 10000, // timeout after 10 seconds // + SUBSCRIPTION_RENEWAL_JOB_PRIORITY: 1, // high priority + SUBSCRIPTION_RENEWAL_JOB_ATTEMPTS: 3, // retry 3 times + SUBSCRIPTION_RENEWAL_JOB_BACKOFF: 5 * 1000, // retry after 5 second + SUBSCRIPTION_RENEWAL_JOB_TIMEOUT: 10000, // timeout after 10 seconds + SUBSCRIPTION_RENEWAL_DAYS_BEFORE_EXPIRY: 7, // 7 days before subscription expires + // SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_DELAY: 1000, // delay after 1 second SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_PRIORITY: 1, // high priority diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index cc013ef..f219794 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -564,7 +564,19 @@ export class InvoicesService { if (invoice.items[0]?.subscriptionPlan && !invoice.isExternal) { const userSubscription = invoice.items[0].subscriptionPlan; - userSubscription.status = SubscriptionStatus.ACTIVE; + + const isRenewal = userSubscription.status === SubscriptionStatus.ACTIVE; + + if (isRenewal) { + const newEndDate = dayjs(userSubscription.endDate).add(userSubscription.plan.duration, "day").toDate(); + userSubscription.endDate = newEndDate; + + this.logger.log(`Subscription ${userSubscription.id} renewed until ${newEndDate}`); + await this.scheduleNextRenewalJob(userSubscription); + } else { + userSubscription.status = SubscriptionStatus.ACTIVE; + await this.scheduleNextRenewalJob(userSubscription); + } // await queryRunner.manager.save(UserSubscription, userSubscription); @@ -1023,4 +1035,37 @@ export class InvoicesService { this.logger.log(`Calculated recurring delay: ${delayMs}ms for period: ${recurringPeriod}`); return delayMs; } + + //*********************************** */ + private async scheduleNextRenewalJob(userSubscription: UserSubscription) { + if (userSubscription.plan.isFree) { + this.logger.debug(`Skipping renewal job for free plan subscription ${userSubscription.id}`); + return; + } + + const renewalJobDate = dayjs(userSubscription.endDate).subtract(INVOICE.SUBSCRIPTION_RENEWAL_DAYS_BEFORE_EXPIRY, "day"); + const delay = renewalJobDate.diff(dayjs()); + + if (delay <= 0) { + this.logger.warn(`Subscription ${userSubscription.id} expires too soon to schedule renewal job`); + return; + } + + this.logger.debug( + `Scheduling renewal job for subscription ${userSubscription.id} to run on ${renewalJobDate.format("YYYY-MM-DD HH:mm:ss")}`, + ); + + await this.invoiceQueue.add( + INVOICE.SUBSCRIPTION_RENEWAL_JOB_NAME, + { + userSubscriptionId: userSubscription.id, + }, + { + delay, + attempts: INVOICE.SUBSCRIPTION_RENEWAL_JOB_ATTEMPTS, + backoff: { type: "exponential", delay: INVOICE.SUBSCRIPTION_RENEWAL_JOB_BACKOFF }, + priority: INVOICE.SUBSCRIPTION_RENEWAL_JOB_PRIORITY, + }, + ); + } } diff --git a/src/modules/invoices/queue/invoice.processor.ts b/src/modules/invoices/queue/invoice.processor.ts index 0dd6dda..e219255 100755 --- a/src/modules/invoices/queue/invoice.processor.ts +++ b/src/modules/invoices/queue/invoice.processor.ts @@ -2,11 +2,12 @@ import { InjectQueue, Processor } from "@nestjs/bullmq"; import { Job, Queue } from "bullmq"; import dayjs from "dayjs"; import Decimal from "decimal.js"; -import { DataSource, QueryRunner } from "typeorm"; +import { DataSource, In, QueryRunner } from "typeorm"; import { WorkerProcessor } from "../../../common/queues/worker.processor"; import { LoggerService } from "../../logger/logger.service"; import { NotificationQueue } from "../../notifications/queue/notification.queue"; +import { UserSubscription } from "../../subscriptions/entities/user-subscription.entity"; import { SubscriptionStatus } from "../../subscriptions/enums/subscription-status.enum"; import { SupportPlan } from "../../support-plans/entities/support-plan.entity"; import { UserSupportPlan } from "../../support-plans/entities/user-support-plan.entity"; @@ -36,6 +37,8 @@ export class InvoiceProcessor extends WorkerProcessor { return this.sendBillInvoiceReminder(job, token); case INVOICE.RECURRING_JOB_NAME: return this.createRecurringInvoice(job, token); + case INVOICE.SUBSCRIPTION_RENEWAL_JOB_NAME: + return this.createSubscriptionRenewalInvoice(job, token); case INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME: return this.notifyAdminForSubscriptionInvoice(job); default: @@ -104,7 +107,6 @@ export class InvoiceProcessor extends WorkerProcessor { currentRecurringCycle: invoice.currentRecurringCycle + 1, }); - // Update the original invoice's current recurring cycle invoice.currentRecurringCycle += 1; await queryRunner.manager.save(Invoice, invoice); @@ -131,7 +133,6 @@ export class InvoiceProcessor extends WorkerProcessor { const { plan } = userSubscriptionPlan; const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); - // Update the original invoice's current recurring cycle invoice.currentRecurringCycle += 1; await queryRunner.manager.save(Invoice, invoice); @@ -139,6 +140,91 @@ export class InvoiceProcessor extends WorkerProcessor { } //********************************** */ + private async createSubscriptionRenewalInvoice(job: Job<{ userSubscriptionId: string }>, token?: string): Promise { + const { userSubscriptionId } = job.data; + this.logger.log(`Creating subscription renewal invoice for subscription: ${userSubscriptionId} ${token ? `with token: ${token}` : ""}`); + + const queryRunner = this.dataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + + const userSubscription = await queryRunner.manager.findOne(UserSubscription, { + where: { id: userSubscriptionId }, + relations: { + user: true, + plan: { service: true, directDiscount: true }, + }, + }); + + if (!userSubscription) { + throw new Error(`User subscription not found: ${userSubscriptionId}`); + } + + if (userSubscription.status !== SubscriptionStatus.ACTIVE) { + this.logger.warn(`Subscription ${userSubscriptionId} is not active, skipping renewal invoice creation`); + await queryRunner.commitTransaction(); + return; + } + + const daysUntilExpiry = dayjs(userSubscription.endDate).diff(dayjs(), "day"); + if (daysUntilExpiry > INVOICE.SUBSCRIPTION_RENEWAL_DAYS_BEFORE_EXPIRY) { + this.logger.warn( + `Subscription ${userSubscriptionId} is not close to expiry (${daysUntilExpiry} days left), skipping renewal invoice creation`, + ); + await queryRunner.commitTransaction(); + return; + } + + const existingRenewalInvoice = await queryRunner.manager.findOne(Invoice, { + where: { + user: { id: userSubscription.user.id }, + items: { subscriptionPlan: { id: userSubscriptionId } }, + status: In([InvoiceStatus.PENDING, InvoiceStatus.WAIT_PAYMENT, InvoiceStatus.OVERDUE]), + }, + relations: { items: { subscriptionPlan: true } }, + }); + + if (existingRenewalInvoice) { + this.logger.log(`Renewal invoice already exists for subscription ${userSubscriptionId}, skipping creation`); + await queryRunner.commitTransaction(); + return; + } + + const renewalDueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate(); + const renewalInvoice = await this.invoicesService.createInvoiceForSubscription( + userSubscription.user, + userSubscription.plan, + userSubscription, + renewalDueDate, + queryRunner, + ); + + this.logger.log(`Renewal invoice created successfully for subscription ${userSubscriptionId} with id ${renewalInvoice.id}`); + + // await this.notificationQueue.addInvoiceCreationNotification(userSubscription.user.id, { + // invoiceId: renewalInvoice.numericId.toString(), + // dueDate: renewalInvoice.dueDate, + // createDate: renewalInvoice.createdAt, + // price: new Decimal(renewalInvoice.totalPrice).toNumber(), + // userPhone: userSubscription.user.phone, + // userEmail: userSubscription.user.email, + // items: `${userSubscription.plan.service.name} - Renewal`, + // }); + + await queryRunner.commitTransaction(); + this.logger.log(`Subscription renewal invoice created successfully for subscription ${userSubscriptionId}`); + } catch (error) { + this.logger.error(`Failed to create subscription renewal invoice for ${userSubscriptionId}:`, error); + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + //********************************** */ + private async sendBillInvoiceReminder(job: Job<{ invoiceId: string }>, token?: string) { this.logger.log(`Sending bill invoice reminder: ${job.data.invoiceId} to user. ${token}`); @@ -202,7 +288,7 @@ export class InvoiceProcessor extends WorkerProcessor { `Invoice ${invoice.id} has been archived as it is more than 10 days overdue. Subscription Plan ${invoice.items[0].subscriptionPlan.id} has been cancelled.`, ); const userSubscription = invoice.items[0].subscriptionPlan; - userSubscription.status = SubscriptionStatus.INACTIVE; + userSubscription.status = SubscriptionStatus.CANCELED; await queryRunner.manager.save(userSubscription); diff --git a/src/modules/subscriptions/enums/subscription-status.enum.ts b/src/modules/subscriptions/enums/subscription-status.enum.ts index 6d86647..a47c5dd 100755 --- a/src/modules/subscriptions/enums/subscription-status.enum.ts +++ b/src/modules/subscriptions/enums/subscription-status.enum.ts @@ -1,6 +1,5 @@ export enum SubscriptionStatus { ACTIVE = "ACTIVE", INACTIVE = "INACTIVE", - // CANCELED = "CANCELED", - // EXPIRED = "EXPIRED", + CANCELED = "CANCELED", }