Merge dev to main #1

Open
kibin wants to merge 33 commits from dev into main
4 changed files with 130 additions and 6 deletions
Showing only changes of commit 7f9beaac2b - Show all commits

View File

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

@@ -301,4 +301,36 @@ export class PaymentService {
return result; 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 { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { InvoiceScheduler } from './invoice.scheduler'; import { InvoiceScheduler } from './invoice.scheduler';
import { PaymentScheduler } from './payment.scheduler';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { NotificationModule } from '../notification/notification.module';
@Module({ @Module({
imports: [ScheduleModule.forRoot(), BillingModule], imports: [ScheduleModule.forRoot(), BillingModule, NotificationModule],
providers: [InvoiceScheduler], providers: [InvoiceScheduler, PaymentScheduler],
}) })
export class SchedulerModule {} export class SchedulerModule {}