initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:55 +08:00
commit 4a8f1e9318
157 changed files with 9198 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import { IsOptional, IsString, IsArray, IsUUID } from 'class-validator';
export class CreateRemittanceDto {
@IsArray()
@IsUUID('4', { each: true })
paymentIds: string[];
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsNumber, IsPositive, IsOptional, IsUUID, IsIn } from 'class-validator';
export class RecordPaymentDto {
@IsUUID()
clientId: string;
@IsUUID()
invoiceId: string;
@IsNumber()
@IsPositive()
amount: number;
@IsString()
@IsIn(['gcash', 'maya', 'cash', 'bank_transfer'])
method: string;
@IsOptional()
@IsString()
referenceNo?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,81 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { PaymentService } from './payment.service';
import { RecordPaymentDto } from './dto/record-payment.dto';
import { CreateRemittanceDto } from './dto/create-remittance.dto';
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('payments')
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
export class PaymentController {
constructor(private readonly paymentService: PaymentService) {}
@Get()
@Roles('technician')
async findAll(
@CurrentUser() user: CurrentUserPayload,
@Query('clientId') clientId?: string,
) {
return this.paymentService.findAll(user.tenantId, { clientId });
}
@Post()
@Roles('technician')
async record(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: RecordPaymentDto,
) {
return this.paymentService.recordPayment(user.tenantId, user.sub, dto);
}
@Get('unremitted')
@Roles('technician')
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('collectorId') collectorId?: string) {
return this.paymentService.getUnremittedPayments(user.tenantId, collectorId || user.sub);
}
@Get('remittances')
@Roles('technician')
async findRemittances(@CurrentUser() user: CurrentUserPayload) {
return this.paymentService.findRemittances(user.tenantId);
}
@Post('remittances')
@Roles('technician')
async submitRemittance(
@CurrentUser() user: CurrentUserPayload,
@Body() dto: CreateRemittanceDto,
) {
return this.paymentService.submitRemittance(user.tenantId, user.sub, dto);
}
@Patch('remittances/:id/confirm')
@Roles('manager')
async confirmRemittance(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.paymentService.confirmRemittance(user.tenantId, id, user.sub);
}
@Patch('remittances/:id/reject')
@Roles('manager')
async rejectRemittance(
@CurrentUser() user: CurrentUserPayload,
@Param('id') id: string,
) {
return this.paymentService.rejectRemittance(user.tenantId, id, user.sub);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { PaymentController } from './payment.controller';
import { PaymentService } from './payment.service';
import { InvoiceModule } from '../invoice/invoice.module';
import { SubscriptionModule } from '../subscription/subscription.module';
import { AccountingModule } from '../accounting/accounting.module';
import { NotificationModule } from '../notification/notification.module';
@Module({
imports: [InvoiceModule, SubscriptionModule, AccountingModule, NotificationModule],
controllers: [PaymentController],
providers: [PaymentService],
exports: [PaymentService],
})
export class PaymentModule {}

View File

@@ -0,0 +1,249 @@
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { InvoiceService } from '../invoice/invoice.service';
import { SubscriptionService } from '../subscription/subscription.service';
import { AuditService } from '../audit/audit.service';
import { RecordPaymentDto } from './dto/record-payment.dto';
import { CreateRemittanceDto } from './dto/create-remittance.dto';
import { JournalService } from '../accounting/journal.service';
import { NotificationService } from '../notification/notification.service';
@Injectable()
export class PaymentService {
private readonly logger = new Logger(PaymentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly invoiceService: InvoiceService,
private readonly subscriptionService: SubscriptionService,
private readonly audit: AuditService,
private readonly journal: JournalService,
private readonly notificationService: NotificationService,
) {}
async findAll(tenantId: string, filters?: { clientId?: string }) {
const db = this.prisma.forTenant(tenantId);
return db.payment.findMany({
where: {
...(filters?.clientId && { clientId: filters.clientId }),
},
include: {
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
invoice: { select: { id: true, number: true, amount: true, status: true } },
collectedBy: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async recordPayment(tenantId: string, collectedById: string, dto: RecordPaymentDto) {
// Verify client exists
const client = await this.prisma.client.findFirst({
where: { id: dto.clientId, tenantId },
});
if (!client) {
throw new NotFoundException('Client not found');
}
// Verify invoice exists and belongs to client
const invoice = await this.prisma.invoice.findFirst({
where: { id: dto.invoiceId, tenantId, clientId: dto.clientId },
});
if (!invoice) {
throw new NotFoundException('Invoice not found');
}
if (invoice.status === 'paid' || invoice.status === 'void') {
throw new BadRequestException(`Invoice is already ${invoice.status}`);
}
// Record payment
const payment = await this.prisma.payment.create({
data: {
tenantId,
clientId: dto.clientId,
invoiceId: dto.invoiceId,
collectedById,
amount: dto.amount,
method: dto.method,
referenceNo: dto.referenceNo,
notes: dto.notes,
},
include: {
client: { select: { id: true, firstName: true, lastName: true } },
invoice: { select: { id: true, number: true } },
},
});
// Audit log + Journal entry (fire-and-forget)
this.audit.log({
tenantId, userId: collectedById, action: 'payment.created', entity: 'payment', entityId: payment.id,
details: { amount: dto.amount, method: dto.method, invoiceId: dto.invoiceId, clientId: dto.clientId },
}).catch(() => {});
// Notify users about the payment
this.notificationService.create(tenantId, {
type: 'in_app',
channel: 'payment_confirmation',
title: 'Payment Received',
message: `${Number(dto.amount).toLocaleString()} payment from ${payment.client?.firstName} ${payment.client?.lastName} (${dto.method})`,
}).catch(() => {});
// Lookup collector name for journal
const collector = await this.prisma.user.findUnique({ where: { id: collectedById } });
this.journal.journalForPayment(tenantId, payment.id, dto.amount, dto.method, {
invoiceNumber: payment.invoice?.number,
clientName: payment.client ? `${payment.client.firstName} ${payment.client.lastName}` : undefined,
collectorId: collectedById,
collectorName: collector ? `${collector.firstName} ${collector.lastName}` : undefined,
}).catch(() => {});
// Apply payment to invoice balance
await this.invoiceService.applyPayment(tenantId, dto.invoiceId, dto.amount);
// Check if this is a prepaid first payment — trigger activation workflow
const updatedInvoice = await this.prisma.invoice.findFirst({
where: { id: dto.invoiceId },
});
if (updatedInvoice && updatedInvoice.status === 'paid') {
await this.subscriptionService.handlePrepaidPayment(
tenantId,
dto.clientId,
collectedById,
);
}
return payment;
}
// ─── Remittance (custodial clearing) ─────────────────────────
async findRemittances(tenantId: string) {
return this.prisma.remittance.findMany({
where: { tenantId },
include: {
collector: { select: { id: true, firstName: true, lastName: true } },
confirmedBy: { select: { id: true, firstName: true, lastName: true } },
payments: true,
},
orderBy: { submittedAt: 'desc' },
});
}
async getUnremittedPayments(tenantId: string, collectorId: string) {
const remittedIds = (await this.prisma.remittancePayment.findMany({ select: { paymentId: true } }))
.map((r) => r.paymentId);
return this.prisma.payment.findMany({
where: { tenantId, collectedById: collectorId, id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] } },
include: {
client: { select: { firstName: true, lastName: true, accountNumber: true } },
invoice: { select: { number: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async submitRemittance(tenantId: string, collectorId: string, dto: CreateRemittanceDto) {
const payments = await this.prisma.payment.findMany({
where: { id: { in: dto.paymentIds }, tenantId, collectedById: collectorId },
});
if (payments.length !== dto.paymentIds.length) {
throw new BadRequestException('Some payments do not belong to you');
}
const totalAmount = payments.reduce((s, p) => s + Number(p.amount), 0);
return this.prisma.remittance.create({
data: {
tenantId, collectorId, totalAmount, notes: dto.notes,
payments: { create: dto.paymentIds.map((paymentId) => ({ paymentId })) },
},
include: { collector: { select: { id: true, firstName: true, lastName: true } }, payments: true },
});
}
async confirmRemittance(tenantId: string, remittanceId: string, confirmedById: string) {
const remittance = await this.prisma.remittance.findFirst({
where: { id: remittanceId, tenantId },
include: { payments: true, collector: { select: { id: true, firstName: true, lastName: true } } },
});
if (!remittance) throw new NotFoundException('Remittance not found');
if (remittance.status !== 'pending') throw new BadRequestException(`Remittance is already ${remittance.status}`);
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
if (activeUserCount > 1 && remittance.collectorId === confirmedById) {
throw new ForbiddenException('Collector cannot confirm their own remittance');
}
const result = await this.prisma.remittance.update({
where: { id: remittanceId },
data: { status: 'confirmed', confirmedById, confirmedAt: new Date() },
include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } },
});
this.audit.log({
tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId,
details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) },
}).catch(() => {});
// Journal: clear collector custody → company accounts
const linkedPayments = await this.prisma.payment.findMany({
where: { id: { in: remittance.payments.map((p) => p.paymentId) } },
});
const byMethod: Record<string, number> = {};
for (const p of linkedPayments) { byMethod[p.method] = (byMethod[p.method] || 0) + Number(p.amount); }
const collectorName = `${remittance.collector.firstName} ${remittance.collector.lastName}`;
this.journal.journalForRemittance(
tenantId, remittanceId, remittance.collectorId, collectorName,
Object.entries(byMethod).map(([method, total]) => ({ method, total })),
).catch((err) => this.logger.error(`Remittance journal failed: ${err.message}`, err.stack));
// Sync CompanyAccount balances for each payment method
const METHOD_COA: Record<string, string> = {
cash: '1010', gcash: '1020', maya: '1030', bank_transfer: '1040',
};
for (const [method, total] of Object.entries(byMethod)) {
const coaCode = METHOD_COA[method];
if (coaCode) {
this.journal.updateCompanyAccountBalance(tenantId, coaCode, total, 'increment')
.catch((err) => this.logger.error(`COH sync failed for ${method}: ${err.message}`));
}
}
return result;
}
async rejectRemittance(tenantId: string, remittanceId: string, rejectedById: string) {
const remittance = await this.prisma.remittance.findFirst({
where: { id: remittanceId, tenantId },
});
if (!remittance) {
throw new NotFoundException('Remittance not found');
}
if (remittance.collectorId === rejectedById) {
const activeUserCount = await this.prisma.user.count({ where: { tenantId, isActive: true } });
if (activeUserCount > 1) {
throw new ForbiddenException('Collector cannot reject their own remittance');
}
}
return this.prisma.remittance.update({
where: { id: remittanceId },
data: {
status: 'rejected',
confirmedById: rejectedById,
confirmedAt: new Date(),
},
});
}
}