initial: standalone repo from monorepo split
This commit is contained in:
11
src/payment/dto/create-remittance.dto.ts
Normal file
11
src/payment/dto/create-remittance.dto.ts
Normal 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;
|
||||
}
|
||||
25
src/payment/dto/record-payment.dto.ts
Normal file
25
src/payment/dto/record-payment.dto.ts
Normal 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;
|
||||
}
|
||||
81
src/payment/payment.controller.ts
Normal file
81
src/payment/payment.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
src/payment/payment.module.ts
Normal file
15
src/payment/payment.module.ts
Normal 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 {}
|
||||
249
src/payment/payment.service.ts
Normal file
249
src/payment/payment.service.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user