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
This commit is contained in:
mahyargdz
2025-08-17 10:00:12 +03:30
parent ca0510f2c2
commit 72b1edbe25
4 changed files with 144 additions and 7 deletions
+7
View File
@@ -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
@@ -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,
},
);
}
}
@@ -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<void> {
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);
@@ -1,6 +1,5 @@
export enum SubscriptionStatus {
ACTIVE = "ACTIVE",
INACTIVE = "INACTIVE",
// CANCELED = "CANCELED",
// EXPIRED = "EXPIRED",
CANCELED = "CANCELED",
}