From 7f9beaac2b175ad451bc7836d78b0665b304a81f Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Wed, 6 May 2026 22:43:44 +0800 Subject: [PATCH] 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 --- src/payment/payment.controller.ts | 12 +++-- src/payment/payment.service.ts | 32 +++++++++++ src/scheduler/payment.scheduler.ts | 86 ++++++++++++++++++++++++++++++ src/scheduler/scheduler.module.ts | 6 ++- 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 src/scheduler/payment.scheduler.ts diff --git a/src/payment/payment.controller.ts b/src/payment/payment.controller.ts index 5c32abe..3409e12 100644 --- a/src/payment/payment.controller.ts +++ b/src/payment/payment.controller.ts @@ -42,10 +42,8 @@ export class PaymentController { @Get('unremitted') @Roles('technician', 'collector') - async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('all') all?: string) { - 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); + async getUnremitted(@CurrentUser() user: CurrentUserPayload) { + return this.paymentService.getUnremittedPayments(user.tenantId, user.sub); } @Get('remittances') @@ -80,4 +78,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); + } } diff --git a/src/payment/payment.service.ts b/src/payment/payment.service.ts index 5ebc358..f9d24e5 100644 --- a/src/payment/payment.service.ts +++ b/src/payment/payment.service.ts @@ -301,4 +301,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, + })); + } } diff --git a/src/scheduler/payment.scheduler.ts b/src/scheduler/payment.scheduler.ts new file mode 100644 index 0000000..0f91eea --- /dev/null +++ b/src/scheduler/payment.scheduler.ts @@ -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(); + 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}`), + ); + } + } +} diff --git a/src/scheduler/scheduler.module.ts b/src/scheduler/scheduler.module.ts index b7e6a56..3340fee 100644 --- a/src/scheduler/scheduler.module.ts +++ b/src/scheduler/scheduler.module.ts @@ -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 {}