Compare commits

4 Commits

Author SHA1 Message Date
john kevin asprec
fc89ed124f feat: add DashboardController with authenticated KPI and financial summary endpoints 2026-06-16 22:32:54 +08:00
kevin-asprec
d4d9249933 Update Task #39: Creation date already implemented in remittance history 2026-05-07 06:35:16 +08:00
kevin-asprec
6ea1f412a1 feat: data import module and payment fixes
- Add import module with CSV/Excel template download and bulk import endpoints
- Support client+subscription and outstanding invoice imports with per-row error handling
- Templates include Field Guide sheet with valid plan/area names
- Add manager role to unremitted payments endpoint
- Add createdAt field to remittance history response
2026-05-07 06:19:43 +08:00
kevin-asprec
7f9beaac2b feat: unremitted per-user filtering, EOD unremitted reminders, remittance breakdown
- Filter unremitted payments by current user only (remove manager exception)
- Add PaymentScheduler with 5PM daily cron for unremitted reminders
- Add getUnremittedBreakdown service for per-collector dashboard totals
- Register PaymentScheduler in SchedulerModule
2026-05-06 22:43:44 +08:00
13 changed files with 8290 additions and 14 deletions

11
alter-tenant-id.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
cd packages/db
node -e "
const { Client } = require('pg');
const url = process.env.DATABASE_URL.replace(/%21/g, '!');
const c = new Client(url);
c.connect().then(() => c.query('ALTER TABLE users ALTER COLUMN tenantId DROP NOT NULL'))
.then(() => { console.log('tenantId nullable OK'); return c.end(); })
.catch(e => { console.log('Error:', e.message.substring(0,100)); return c.end(); });
"
npx tsx prisma/seed.ts

7602
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
{
"name": "fiberops-api",
"private": true,
"workspaces": ["packages/*"],
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
@@ -32,6 +34,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"xlsx": "^0.18.5",
"zod": "^3.24.0"
},
"devDependencies": {

View File

@@ -29,6 +29,7 @@ import { AccountingModule } from './accounting/accounting.module';
import { PayrollModule } from './payroll/payroll.module';
import { RoleModule } from './role/role.module';
import { CommentModule } from './comment/comment.module';
import { ImportModule } from './import/import.module';
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { PermissionsGuard } from './common/guards/permissions.guard';
@@ -71,6 +72,7 @@ import { AccessGuard } from './common/guards/access.guard';
PayrollModule,
RoleModule,
CommentModule,
ImportModule,
],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@MinLength(1)
content!: string;
}

View File

@@ -14,28 +14,24 @@ export class DashboardController {
@Get('kpis')
@Roles('manager')
async getKpis(@CurrentUser() user: CurrentUserPayload) {
const data = await this.dashboardService.getKpis(user.tenantId);
return { data };
return this.dashboardService.getKpis(user.tenantId);
}
@Get('revenue-chart')
@Roles('manager')
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
const data = await this.dashboardService.getRevenueChart(user.tenantId);
return { data };
return this.dashboardService.getRevenueChart(user.tenantId);
}
@Get('activity')
@Roles('manager')
async getActivity(@CurrentUser() user: CurrentUserPayload) {
const data = await this.dashboardService.getRecentActivity(user.tenantId);
return { data };
return this.dashboardService.getRecentActivity(user.tenantId);
}
@Get('financial-summary')
@Roles('manager')
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
const data = await this.dashboardService.getFinancialSummary(user.tenantId);
return { data };
return this.dashboardService.getFinancialSummary(user.tenantId);
}
}

View File

@@ -0,0 +1,91 @@
import {
Controller,
Post,
Get,
Param,
UseGuards,
UseInterceptors,
UploadedFile,
Res,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { ImportService } from './import.service';
import { Roles } from '../common/decorators/roles.decorator';
import { RolesGuard } from '../common/guards/roles.guard';
import { TenantGuard } from '../common/guards/tenant.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('import')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class ImportController {
constructor(private readonly importService: ImportService) {}
@Get('template/:type')
@Roles('tenant_admin')
async downloadTemplate(
@Param('type') type: string,
@CurrentUser() user: CurrentUserPayload,
@Res() res: Response,
) {
const { buffer, filename, contentType } =
await this.importService.generateTemplate(user.tenantId, type);
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Post('clients')
@Roles('tenant_admin')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = file.originalname.split('.').pop()?.toLowerCase();
if (['csv', 'xlsx', 'xls'].includes(ext || '')) {
cb(null, true);
} else {
cb(new Error('Only CSV and Excel files are allowed'), false);
}
},
}),
)
async importClients(
@CurrentUser() user: CurrentUserPayload,
@UploadedFile() file: Express.Multer.File,
) {
return this.importService.importClients(
user.tenantId,
user.sub,
file.buffer,
file.originalname,
);
}
@Post('invoices')
@Roles('tenant_admin')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = file.originalname.split('.').pop()?.toLowerCase();
if (['csv', 'xlsx', 'xls'].includes(ext || '')) {
cb(null, true);
} else {
cb(new Error('Only CSV and Excel files are allowed'), false);
}
},
}),
)
async importInvoices(
@CurrentUser() user: CurrentUserPayload,
@UploadedFile() file: Express.Multer.File,
) {
return this.importService.importInvoices(
user.tenantId,
file.buffer,
file.originalname,
);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ImportController],
providers: [ImportService],
})
export class ImportModule {}

View File

@@ -0,0 +1,427 @@
import {
Injectable,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as XLSX from 'xlsx';
interface RowResult {
row: number;
status: 'ok' | 'error';
message?: string;
}
interface ImportResult {
total: number;
imported: number;
errors: number;
details: RowResult[];
}
@Injectable()
export class ImportService {
constructor(private readonly prisma: PrismaService) {}
/* ------------------------------------------------------------------ */
/* Template generation */
/* ------------------------------------------------------------------ */
async generateTemplate(
tenantId: string,
type: string,
): Promise<{ buffer: Buffer; filename: string; contentType: string }> {
if (type === 'clients') {
return this.generateClientsTemplate(tenantId);
}
if (type === 'invoices') {
return this.generateInvoicesTemplate(tenantId);
}
throw new BadRequestException(
`Unknown template type "${type}". Use "clients" or "invoices".`,
);
}
private async generateClientsTemplate(tenantId: string) {
const [plans, areas] = await Promise.all([
this.prisma.plan.findMany({
where: { tenantId, isActive: true, deletedAt: null },
select: { name: true },
orderBy: { name: 'asc' },
}),
this.prisma.area.findMany({
where: { tenantId, deletedAt: null },
select: { name: true },
orderBy: { name: 'asc' },
}),
]);
const headers = [
'firstName*',
'lastName*',
'email',
'phone',
'address*',
'accountNumber',
'planName*',
'subscriptionType*',
'areaName',
];
const exampleRow = [
'Juan',
'Dela Cruz',
'juan@example.com',
'+639171234567',
'123 Main Street, Barangay 1',
'C-000001',
plans[0]?.name || 'Plan 1',
'postpaid',
areas[0]?.name || '',
];
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
// Add data-validation notes in a second sheet
const notesData = [
['Field', 'Required', 'Description', 'Valid Values'],
['firstName', 'Yes', 'Client first name', ''],
['lastName', 'Yes', 'Client last name', ''],
['email', 'No', 'Email address', ''],
['phone', 'No', 'Phone number', ''],
['address', 'Yes', 'Full address', ''],
['accountNumber', 'No', 'Existing account number (auto-generated if empty)', ''],
['planName', 'Yes', 'Must match an existing plan name', plans.map((p) => p.name).join(', ') || '(no plans created yet)'],
['subscriptionType', 'Yes', 'Subscription type', 'prepaid, postpaid'],
['areaName', 'No', 'Must match an existing area name', areas.map((a) => a.name).join(', ') || '(no areas created yet)'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notesData);
// Set column widths
ws['!cols'] = headers.map(() => ({ wch: 25 }));
notesWs['!cols'] = [
{ wch: 20 },
{ wch: 10 },
{ wch: 45 },
{ wch: 50 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Clients');
XLSX.utils.book_append_sheet(wb, notesWs, 'Field Guide');
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
return {
buffer,
filename: 'fiberops_clients_template.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
}
private async generateInvoicesTemplate(tenantId: string) {
const headers = [
'accountNumber*',
'invoiceNumber',
'amount*',
'balance*',
'dueDate*',
'status*',
];
const exampleRow = [
'C-000001',
'',
'1500.00',
'1500.00',
'2026-06-01',
'unpaid',
];
const ws = XLSX.utils.aoa_to_sheet([headers, exampleRow]);
const notesData = [
['Field', 'Required', 'Description', 'Valid Values'],
['accountNumber', 'Yes', 'Must match an existing client account number', ''],
['invoiceNumber', 'No', 'Invoice number (auto-generated if empty)', ''],
['amount', 'Yes', 'Invoice total amount', ''],
['balance', 'Yes', 'Outstanding balance', ''],
['dueDate', 'Yes', 'Due date in YYYY-MM-DD format', ''],
['status', 'Yes', 'Invoice status', 'unpaid, partial'],
];
const notesWs = XLSX.utils.aoa_to_sheet(notesData);
ws['!cols'] = headers.map(() => ({ wch: 25 }));
notesWs['!cols'] = [
{ wch: 20 },
{ wch: 10 },
{ wch: 50 },
{ wch: 30 },
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Invoices');
XLSX.utils.book_append_sheet(wb, notesWs, 'Field Guide');
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
return {
buffer,
filename: 'fiberops_invoices_template.xlsx',
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
}
/* ------------------------------------------------------------------ */
/* Client + Subscription import */
/* ------------------------------------------------------------------ */
async importClients(
tenantId: string,
userId: string,
fileBuffer: Buffer,
filename: string,
): Promise<ImportResult> {
const rows = this.parseFile(fileBuffer, filename);
if (rows.length === 0) {
throw new BadRequestException('File is empty');
}
// Prefetch plans and areas for lookup
const [plans, areas] = await Promise.all([
this.prisma.plan.findMany({
where: { tenantId, isActive: true, deletedAt: null },
select: { id: true, name: true },
}),
this.prisma.area.findMany({
where: { tenantId, deletedAt: null },
select: { id: true, name: true },
}),
]);
const planMap = new Map(plans.map((p) => [p.name.toLowerCase(), p.id]));
const areaMap = new Map(areas.map((a) => [a.name.toLowerCase(), a.id]));
const details: RowResult[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNumber = i + 2; // Excel is 1-indexed, +1 for header
try {
const firstName = this.req(row, 'firstName', rowNumber);
const lastName = this.req(row, 'lastName', rowNumber);
const address = this.req(row, 'address', rowNumber);
const planName = this.req(row, 'planName', rowNumber);
const subscriptionType = this.req(row, 'subscriptionType', rowNumber);
if (!['prepaid', 'postpaid'].includes(subscriptionType.toLowerCase())) {
throw new Error(`Invalid subscriptionType "${subscriptionType}". Must be "prepaid" or "postpaid".`);
}
const planId = planMap.get(planName.toLowerCase());
if (!planId) {
throw new Error(`Plan "${planName}" not found. Available plans: ${plans.map((p) => p.name).join(', ') || '(none)'}`);
}
const areaName = this.opt(row, 'areaName');
const areaId = areaName ? areaMap.get(areaName.toLowerCase()) : undefined;
if (areaName && !areaId) {
throw new Error(`Area "${areaName}" not found. Available areas: ${areas.map((a) => a.name).join(', ') || '(none)'}`);
}
const accountNumber = this.opt(row, 'accountNumber') || undefined;
// Check account number uniqueness if provided
if (accountNumber) {
const existing = await this.prisma.client.findFirst({
where: { tenantId, accountNumber },
});
if (existing) {
throw new Error(`Account number "${accountNumber}" already exists for client ${existing.firstName} ${existing.lastName}`);
}
}
// Generate account number if not provided
const finalAccountNumber =
accountNumber ||
(await this.generateAccountNumber(tenantId));
const client = await this.prisma.client.create({
data: {
tenantId,
accountNumber: finalAccountNumber,
firstName,
lastName,
email: this.opt(row, 'email'),
phone: this.opt(row, 'phone'),
address,
areaId,
},
});
// Create subscription
await this.prisma.subscription.create({
data: {
tenantId,
clientId: client.id,
planId,
type: subscriptionType.toLowerCase(),
status: 'pending',
},
});
details.push({ row: rowNumber, status: 'ok', message: `Created ${firstName} ${lastName} (${finalAccountNumber})` });
} catch (err: any) {
details.push({ row: rowNumber, status: 'error', message: err.message });
}
}
const imported = details.filter((d) => d.status === 'ok').length;
const errors = details.filter((d) => d.status === 'error').length;
// Log audit
if (imported > 0) {
await this.prisma.auditLog.create({
data: {
tenantId,
userId,
action: 'IMPORT_CLIENTS',
entity: 'client',
entityId: 'bulk',
details: { imported, errors, total: rows.length },
},
});
}
return { total: rows.length, imported, errors, details };
}
/* ------------------------------------------------------------------ */
/* Invoice import */
/* ------------------------------------------------------------------ */
async importInvoices(
tenantId: string,
fileBuffer: Buffer,
filename: string,
): Promise<ImportResult> {
const rows = this.parseFile(fileBuffer, filename);
if (rows.length === 0) {
throw new BadRequestException('File is empty');
}
const details: RowResult[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNumber = i + 2;
try {
const accountNumber = this.req(row, 'accountNumber', rowNumber);
const amountStr = this.req(row, 'amount', rowNumber);
const balanceStr = this.req(row, 'balance', rowNumber);
const dueDateStr = this.req(row, 'dueDate', rowNumber);
const status = this.req(row, 'status', rowNumber);
// Find client by account number
const client = await this.prisma.client.findFirst({
where: { tenantId, accountNumber },
});
if (!client) {
throw new Error(`Client with account number "${accountNumber}" not found`);
}
const amount = parseFloat(amountStr);
const balance = parseFloat(balanceStr);
if (isNaN(amount) || amount <= 0) {
throw new Error(`Invalid amount "${amountStr}"`);
}
if (isNaN(balance) || balance < 0) {
throw new Error(`Invalid balance "${balanceStr}"`);
}
const dueDate = new Date(dueDateStr);
if (isNaN(dueDate.getTime())) {
throw new Error(`Invalid dueDate "${dueDateStr}". Use YYYY-MM-DD format.`);
}
if (!['unpaid', 'partial'].includes(status.toLowerCase())) {
throw new Error(`Invalid status "${status}". Must be "unpaid" or "partial".`);
}
// Generate invoice number
const invoiceNumber =
this.opt(row, 'invoiceNumber') ||
(await this.generateInvoiceNumber(tenantId));
await this.prisma.invoice.create({
data: {
tenantId,
clientId: client.id,
number: invoiceNumber,
amount,
balance,
status: status.toLowerCase(),
dueDate,
},
});
details.push({
row: rowNumber,
status: 'ok',
message: `Invoice ${invoiceNumber} for ${client.firstName} ${client.lastName} (${accountNumber})`,
});
} catch (err: any) {
details.push({ row: rowNumber, status: 'error', message: err.message });
}
}
const imported = details.filter((d) => d.status === 'ok').length;
const errors = details.filter((d) => d.status === 'error').length;
return { total: rows.length, imported, errors, details };
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
private parseFile(buffer: Buffer, filename: string): Record<string, string>[] {
const ext = filename.split('.').pop()?.toLowerCase();
const wb = XLSX.read(buffer, { type: 'buffer' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows: Record<string, string>[] = XLSX.utils.sheet_to_json(ws, {
defval: '',
});
// Normalize headers: strip asterisks and whitespace
return rows.map((row) => {
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(row)) {
normalized[key.replace(/\*/g, '').trim()] = String(value).trim();
}
return normalized;
});
}
private req(row: Record<string, string>, field: string, rowNumber: number): string {
const value = row[field];
if (!value) {
throw new Error(`Missing required field "${field}"`);
}
return value;
}
private opt(row: Record<string, string>, field: string): string | undefined {
const value = row[field];
return value || undefined;
}
private async generateAccountNumber(tenantId: string): Promise<string> {
const count = await this.prisma.client.count({ where: { tenantId } });
return `C-${String(count + 1).padStart(6, '0')}`;
}
private async generateInvoiceNumber(tenantId: string): Promise<string> {
const count = await this.prisma.invoice.count({ where: { tenantId } });
return `INV-${String(count + 1).padStart(6, '0')}`;
}
}

View File

@@ -41,10 +41,9 @@ export class PaymentController {
}
@Get('unremitted')
@Roles('technician', 'collector')
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('all') all?: string) {
@Roles('technician', 'collector', 'manager')
async getUnremitted(@CurrentUser() user: CurrentUserPayload) {
const isManager = user.roles?.some((r: string) => ['manager', 'tenant_admin', 'super_admin'].includes(r));
// Managers see all unremitted; collectors see only their own
return this.paymentService.getUnremittedPayments(user.tenantId, isManager ? null : user.sub);
}
@@ -80,4 +79,10 @@ export class PaymentController {
) {
return this.paymentService.rejectRemittance(user.tenantId, id, user.sub);
}
@Get('unremitted-breakdown')
@Roles('manager')
async getUnremittedBreakdown(@CurrentUser() user: CurrentUserPayload) {
return this.paymentService.getUnremittedBreakdown(user.tenantId);
}
}

View File

@@ -151,6 +151,7 @@ export class PaymentService {
// Attach full payment details to each remittance's join records
return remittances.map((r) => ({
...r,
createdAt: r.submittedAt.toISOString(),
payments: r.payments.map((rp) => ({
...rp,
payment: paymentMap.get(rp.paymentId) ?? null,
@@ -301,4 +302,36 @@ export class PaymentService {
return result;
}
async getUnremittedBreakdown(tenantId: string) {
const remittedIds = (await this.prisma.remittancePayment.findMany({
where: { remittance: { tenantId } },
select: { paymentId: true },
})).map((r) => r.paymentId);
const grouped = await this.prisma.payment.groupBy({
by: ['collectedById'],
where: {
tenantId,
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
collectedById: { not: null },
},
_sum: { amount: true },
_count: true,
});
const collectorIds = grouped.map((g) => g.collectedById!).filter(Boolean);
const collectors = await this.prisma.user.findMany({
where: { id: { in: collectorIds } },
select: { id: true, firstName: true, lastName: true },
});
const nameMap = new Map(collectors.map((c) => [c.id, `${c.firstName} ${c.lastName}`]));
return grouped.map((g) => ({
collectorId: g.collectedById,
collectorName: nameMap.get(g.collectedById!) ?? 'Unknown',
totalAmount: Number(g._sum.amount ?? 0),
count: g._count,
}));
}
}

View File

@@ -0,0 +1,86 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
@Injectable()
export class PaymentScheduler {
private readonly logger = new Logger(PaymentScheduler.name);
constructor(
private readonly prisma: PrismaService,
private readonly notificationService: NotificationService,
) {}
@Cron('0 17 * * *')
async sendUnremittedReminders() {
this.logger.log('Sending unremitted payment reminders...');
const tenants = await this.prisma.tenant.findMany({ where: { isActive: true } });
for (const tenant of tenants) {
try {
await this._remindForTenant(tenant.id);
} catch (error) {
this.logger.error(`Failed to send reminders for tenant ${tenant.slug}:`, error);
}
}
this.logger.log('Unremitted payment reminders complete.');
}
private async _remindForTenant(tenantId: string) {
// Find remitted payment IDs
const remittedIds = (await this.prisma.remittancePayment.findMany({
where: { remittance: { tenantId } },
select: { paymentId: true },
})).map((r) => r.paymentId);
// Find unremitted payments grouped by collector
const unremitted = await this.prisma.payment.findMany({
where: {
tenantId,
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
collectedById: { not: null },
},
select: { collectedById: true, amount: true },
});
if (unremitted.length === 0) return;
// Group by collector
const byCollector = new Map<string, number>();
for (const p of unremitted) {
const id = p.collectedById!;
byCollector.set(id, (byCollector.get(id) ?? 0) + Number(p.amount));
}
// Check which collectors already got a reminder today
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const alreadyReminded = await this.prisma.notification.findMany({
where: {
tenantId,
channel: 'unremitted_reminder',
createdAt: { gte: todayStart },
},
select: { userId: true },
});
const remindedSet = new Set(alreadyReminded.map((n) => n.userId).filter(Boolean));
for (const [collectorId, total] of byCollector) {
if (remindedSet.has(collectorId)) continue;
this.notificationService.create(tenantId, {
userId: collectorId,
type: 'in_app',
channel: 'unremitted_reminder',
title: 'End-of-Day Reminder',
message: `You have ₱${total.toLocaleString()} in unremitted payments. Please submit your remittance.`,
}).catch((err) =>
this.logger.error(`Failed to notify ${collectorId}: ${err.message}`),
);
}
}
}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { InvoiceScheduler } from './invoice.scheduler';
import { PaymentScheduler } from './payment.scheduler';
import { BillingModule } from '../billing/billing.module';
import { NotificationModule } from '../notification/notification.module';
@Module({
imports: [ScheduleModule.forRoot(), BillingModule],
providers: [InvoiceScheduler],
imports: [ScheduleModule.forRoot(), BillingModule, NotificationModule],
providers: [InvoiceScheduler, PaymentScheduler],
})
export class SchedulerModule {}