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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user