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",
|
EXTERNAL_CALLBACK_JOB_NAME: "externalInvoice.callback",
|
||||||
REMINDER_JOB_NAME: "reminderInvoice",
|
REMINDER_JOB_NAME: "reminderInvoice",
|
||||||
RECURRING_JOB_NAME: "recurringInvoice",
|
RECURRING_JOB_NAME: "recurringInvoice",
|
||||||
|
SUBSCRIPTION_RENEWAL_JOB_NAME: "subscriptionRenewal",
|
||||||
SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME: "subscriptionAdminNotification",
|
SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME: "subscriptionAdminNotification",
|
||||||
//
|
//
|
||||||
EXTERNAL_JOB_PRIORITY: 1, // high priority
|
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_BACKOFF: 5 * 1000, // retry after 5 second
|
||||||
RECURRING_JOB_TIMEOUT: 10000, // timeout after 10 seconds
|
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_DELAY: 1000, // delay after 1 second
|
||||||
SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_PRIORITY: 1, // high priority
|
SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_PRIORITY: 1, // high priority
|
||||||
|
|||||||
@@ -564,7 +564,19 @@ export class InvoicesService {
|
|||||||
|
|
||||||
if (invoice.items[0]?.subscriptionPlan && !invoice.isExternal) {
|
if (invoice.items[0]?.subscriptionPlan && !invoice.isExternal) {
|
||||||
const userSubscription = invoice.items[0].subscriptionPlan;
|
const userSubscription = invoice.items[0].subscriptionPlan;
|
||||||
|
|
||||||
|
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;
|
userSubscription.status = SubscriptionStatus.ACTIVE;
|
||||||
|
await this.scheduleNextRenewalJob(userSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
await queryRunner.manager.save(UserSubscription, 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}`);
|
this.logger.log(`Calculated recurring delay: ${delayMs}ms for period: ${recurringPeriod}`);
|
||||||
return delayMs;
|
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 { Job, Queue } from "bullmq";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import Decimal from "decimal.js";
|
import Decimal from "decimal.js";
|
||||||
import { DataSource, QueryRunner } from "typeorm";
|
import { DataSource, In, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
import { WorkerProcessor } from "../../../common/queues/worker.processor";
|
import { WorkerProcessor } from "../../../common/queues/worker.processor";
|
||||||
import { LoggerService } from "../../logger/logger.service";
|
import { LoggerService } from "../../logger/logger.service";
|
||||||
import { NotificationQueue } from "../../notifications/queue/notification.queue";
|
import { NotificationQueue } from "../../notifications/queue/notification.queue";
|
||||||
|
import { UserSubscription } from "../../subscriptions/entities/user-subscription.entity";
|
||||||
import { SubscriptionStatus } from "../../subscriptions/enums/subscription-status.enum";
|
import { SubscriptionStatus } from "../../subscriptions/enums/subscription-status.enum";
|
||||||
import { SupportPlan } from "../../support-plans/entities/support-plan.entity";
|
import { SupportPlan } from "../../support-plans/entities/support-plan.entity";
|
||||||
import { UserSupportPlan } from "../../support-plans/entities/user-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);
|
return this.sendBillInvoiceReminder(job, token);
|
||||||
case INVOICE.RECURRING_JOB_NAME:
|
case INVOICE.RECURRING_JOB_NAME:
|
||||||
return this.createRecurringInvoice(job, token);
|
return this.createRecurringInvoice(job, token);
|
||||||
|
case INVOICE.SUBSCRIPTION_RENEWAL_JOB_NAME:
|
||||||
|
return this.createSubscriptionRenewalInvoice(job, token);
|
||||||
case INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME:
|
case INVOICE.SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME:
|
||||||
return this.notifyAdminForSubscriptionInvoice(job);
|
return this.notifyAdminForSubscriptionInvoice(job);
|
||||||
default:
|
default:
|
||||||
@@ -104,7 +107,6 @@ export class InvoiceProcessor extends WorkerProcessor {
|
|||||||
currentRecurringCycle: invoice.currentRecurringCycle + 1,
|
currentRecurringCycle: invoice.currentRecurringCycle + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the original invoice's current recurring cycle
|
|
||||||
invoice.currentRecurringCycle += 1;
|
invoice.currentRecurringCycle += 1;
|
||||||
await queryRunner.manager.save(Invoice, invoice);
|
await queryRunner.manager.save(Invoice, invoice);
|
||||||
|
|
||||||
@@ -131,7 +133,6 @@ export class InvoiceProcessor extends WorkerProcessor {
|
|||||||
const { plan } = userSubscriptionPlan;
|
const { plan } = userSubscriptionPlan;
|
||||||
const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate();
|
const dueDate = dayjs().add(INVOICE.DUEDATE, "day").toDate();
|
||||||
|
|
||||||
// Update the original invoice's current recurring cycle
|
|
||||||
invoice.currentRecurringCycle += 1;
|
invoice.currentRecurringCycle += 1;
|
||||||
await queryRunner.manager.save(Invoice, invoice);
|
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) {
|
private async sendBillInvoiceReminder(job: Job<{ invoiceId: string }>, token?: string) {
|
||||||
this.logger.log(`Sending bill invoice reminder: ${job.data.invoiceId} to user. ${token}`);
|
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.`,
|
`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;
|
const userSubscription = invoice.items[0].subscriptionPlan;
|
||||||
userSubscription.status = SubscriptionStatus.INACTIVE;
|
userSubscription.status = SubscriptionStatus.CANCELED;
|
||||||
|
|
||||||
await queryRunner.manager.save(userSubscription);
|
await queryRunner.manager.save(userSubscription);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export enum SubscriptionStatus {
|
export enum SubscriptionStatus {
|
||||||
ACTIVE = "ACTIVE",
|
ACTIVE = "ACTIVE",
|
||||||
INACTIVE = "INACTIVE",
|
INACTIVE = "INACTIVE",
|
||||||
// CANCELED = "CANCELED",
|
CANCELED = "CANCELED",
|
||||||
// EXPIRED = "EXPIRED",
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user