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:
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user