import { Injectable, NotFoundException } from "@nestjs/common"; import { randomUUID } from "node:crypto"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; import { StorageService } from "../storage/storage.service"; import { extForUpload, type UploadedFileLike } from "../storage/upload-file"; import { toDate } from "../common/coerce"; import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; import { BeneficiaryDto, ClaimDto, DriverDto, InstallmentDto, UpdateBeneficiaryDto, UpdateClaimDto, UpdateDriverDto, UpdateInstallmentDto, VehicleDto, } from "./children.dto"; import { AdjusterDto, PolicyTypeDto, ProviderDto, UpdateAdjusterDto, UpdatePolicyTypeDto, UpdateProviderDto, } from "./lookup.dto"; /** * Vigencia buckets, derived from `policyTo` against today. `undated` is a real * bucket rather than an error case: 528 of the migrated policies carry no end * date at all (the legacy Access tables left it blank), so they can neither be * called current nor expired. */ export type PolicyStatus = "active" | "expiring" | "expired" | "undated"; export type PolicySort = | "expiry_desc" | "expiry_asc" | "customer" | "number" | "premium_desc"; export interface ListParams { query?: string; page: number; pageSize: number; status?: PolicyStatus; /** Window in days for the `expiring` bucket. */ days: number; typeId?: string; providerId?: string; liquidated?: boolean; includeArchived?: boolean; sort: PolicySort; } /** Midnight today, UTC — policy dates are stored date-only at 00:00 UTC. */ function today(): Date { const now = new Date(); return new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), ); } function addDays(d: Date, days: number): Date { return new Date(d.getTime() + days * 86400000); } function statusOf(policyTo: Date | null, from: Date, soon: Date): PolicyStatus { if (!policyTo) return "undated"; if (policyTo < from) return "expired"; return policyTo <= soon ? "expiring" : "active"; } function daysUntil(policyTo: Date | null, from: Date): number | null { if (!policyTo) return null; return Math.round((policyTo.getTime() - from.getTime()) / 86400000); } @Injectable() export class PoliciesService { constructor( private readonly prisma: PrismaService, private readonly storage: StorageService, ) {} private statusWhere( status: PolicyStatus | undefined, days: number, ): Prisma.PolicyWhereInput { const from = today(); switch (status) { case "active": return { policyTo: { gte: from } }; case "expiring": return { policyTo: { gte: from, lte: addDays(from, days) } }; case "expired": return { policyTo: { lt: from } }; case "undated": return { policyTo: null }; default: return {}; } } private orderBy(sort: PolicySort): Prisma.PolicyOrderByWithRelationInput[] { switch (sort) { case "expiry_asc": return [{ policyTo: "asc" }]; case "customer": return [{ customer: { name: "asc" } }, { policyTo: "desc" }]; case "number": return [{ policyNumber: "asc" }]; case "premium_desc": // Sorts on netPremium, not total: `total` is 0 or null on all but 2 of // the 2378 migrated policies, so ordering by it is meaningless. return [{ netPremium: "desc" }]; default: // MySQL sorts NULLs last on DESC, which puts the 528 undated policies // at the end instead of the top — the behaviour we want by default. return [{ policyTo: "desc" }]; } } /** Policy list with search, vigencia/type/provider filters, paginated. */ async list(params: ListParams) { const { query, page, pageSize, status, days, typeId, providerId, liquidated, includeArchived, sort } = params; const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) }; if (!includeArchived) where.archivedAt = null; if (query && query.trim()) { const q = query.trim(); where.OR = [ { policyNumber: { contains: q } }, { customer: { name: { contains: q } } }, { agentName: { contains: q } }, { vehicles: { some: { licensePlate: { contains: q } } } }, { insuredDrivers: { some: { fullName: { contains: q } } } }, { legacyId: { contains: q } }, ]; } if (typeId) where.policyTypeId = typeId; if (providerId) where.insuranceProviderId = providerId; if (liquidated !== undefined) where.liquidated = liquidated; const [total, rows] = await this.prisma.$transaction([ this.prisma.policy.count({ where }), this.prisma.policy.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, orderBy: this.orderBy(sort), select: { id: true, policyNumber: true, agentName: true, policyFrom: true, policyTo: true, netPremium: true, total: true, currency: true, liquidated: true, archivedAt: true, customer: { select: { id: true, name: true, city: true } }, policyType: { select: { id: true, name: true } }, insuranceProvider: { select: { id: true, name: true } }, _count: { select: { vehicles: true, installments: true, documents: true } }, }, }), ]); const from = today(); const soon = addDays(from, days); const items = rows.map((r) => ({ id: r.id, policyNumber: r.policyNumber, agentName: r.agentName, policyFrom: r.policyFrom, policyTo: r.policyTo, netPremium: r.netPremium, total: r.total, currency: r.currency, liquidated: r.liquidated, archived: r.archivedAt != null, customerId: r.customer.id, customerName: r.customer.name, customerCity: r.customer.city, policyType: r.policyType, insuranceProvider: r.insuranceProvider, status: statusOf(r.policyTo, from, soon), daysToExpiry: daysUntil(r.policyTo, from), vehicleCount: r._count.vehicles, installmentCount: r._count.installments, documentCount: r._count.documents, })); return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) }; } /** Top-line counts for the policies page header. */ async stats(days: number) { const from = today(); const soon = addDays(from, days); const [total, active, expiring, expired, undated, liquidated] = await this.prisma.$transaction([ this.prisma.policy.count(), this.prisma.policy.count({ where: { policyTo: { gte: from } } }), this.prisma.policy.count({ where: { policyTo: { gte: from, lte: soon } }, }), this.prisma.policy.count({ where: { policyTo: { lt: from } } }), this.prisma.policy.count({ where: { policyTo: null } }), this.prisma.policy.count({ where: { liquidated: true } }), ]); // Premium in force, per currency — the two currencies can't be summed. const inForce = await this.prisma.policy.groupBy({ by: ["currency"], where: { policyTo: { gte: from } }, _sum: { total: true, netPremium: true }, _count: { _all: true }, }); return { total, active, expiring, expired, undated, liquidated, pending: total - liquidated, days, premiumInForce: inForce.map((r) => ({ currency: r.currency, total: r._sum.total, netPremium: r._sum.netPremium, count: r._count._all, })), }; } /** Filter dropdown options, with counts so empty choices are visible. */ async facets() { const [types, providers] = await this.prisma.$transaction([ this.prisma.policyType.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, _count: { select: { policies: true } } }, }), this.prisma.insuranceProvider.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, _count: { select: { policies: true } } }, }), ]); return { types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })), providers: providers.map((p) => ({ id: p.id, name: p.name, count: p._count.policies, })), }; } /** Full policy view, including the owning customer. */ async detail(id: string, days: number) { const policy = await this.prisma.policy.findUnique({ where: { id }, include: { customer: { select: { id: true, name: true, nameSource: true, city: true, state: true, phone: true, mobile: true, email: true, }, }, policyType: true, insuranceProvider: true, installments: { orderBy: { sequence: "asc" } }, vehicles: true, insuredDrivers: true, beneficiaries: true, claims: { include: { adjuster: true } }, documents: true, properties: { select: { id: true, addressLine1: true, addressLine2: true, zone: true }, }, }, }); if (!policy) { throw new NotFoundException(`Policy ${id} not found`); } const from = today(); return { ...policy, status: statusOf(policy.policyTo, from, addDays(from, days)), daysToExpiry: daysUntil(policy.policyTo, from), }; } // --- policy header writes ------------------------------------------------- private headerData(dto: CreatePolicyDto | UpdatePolicyDto) { const { policyDate, policyFrom, policyTo, liquidationDate, ...rest } = dto as CreatePolicyDto; return { ...rest, ...(policyDate !== undefined && { policyDate: toDate(policyDate) }), ...(policyFrom !== undefined && { policyFrom: toDate(policyFrom) }), ...(policyTo !== undefined && { policyTo: toDate(policyTo) }), ...(liquidationDate !== undefined && { liquidationDate: toDate(liquidationDate) }), }; } async create(dto: CreatePolicyDto) { // Validate the customer FK up front for a clean 404 instead of a raw // Prisma constraint error. const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId }, select: { id: true }, }); if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`); return this.prisma.policy.create({ data: { ...this.headerData(dto), policyNumber: dto.policyNumber, customerId: dto.customerId, }, }); } async update(id: string, dto: UpdatePolicyDto) { await this.ensurePolicy(id); return this.prisma.policy.update({ where: { id }, data: this.headerData(dto) }); } async archive(id: string) { await this.ensurePolicy(id); return this.prisma.policy.update({ where: { id }, data: { archivedAt: new Date() } }); } async restore(id: string) { await this.ensurePolicy(id); return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } }); } private async ensurePolicy(id: string) { const found = await this.prisma.policy.findUnique({ where: { id }, select: { id: true }, }); if (!found) throw new NotFoundException(`Policy ${id} not found`); } // --- child rows ----------------------------------------------------------- // Each child is created under a policy and edited/removed by its own id, // scoped to that policy so one policy's id can't touch another's rows. private async ensureChild( model: "policyPaymentInstallment" | "vehicle" | "insuredDriver" | "policyBeneficiary" | "claim", policyId: string, childId: string, ) { await this.ensurePolicy(policyId); // @ts-expect-error dynamic delegate access is safe for these known models const row = await this.prisma[model].findFirst({ where: { id: childId, policyId }, select: { id: true }, }); if (!row) throw new NotFoundException(`Child ${childId} not found on policy ${policyId}`); } async addInstallment(policyId: string, dto: InstallmentDto) { await this.ensurePolicy(policyId); return this.prisma.policyPaymentInstallment.create({ data: { policyId, sequence: dto.sequence, amount: dto.amount, currency: dto.currency, dueDate: toDate(dto.dueDate) ?? undefined, paidDate: toDate(dto.paidDate) ?? undefined, checkNumber: dto.checkNumber, isCash: dto.isCash, }, }); } async updateInstallment(policyId: string, id: string, dto: UpdateInstallmentDto) { await this.ensureChild("policyPaymentInstallment", policyId, id); return this.prisma.policyPaymentInstallment.update({ where: { id }, data: { sequence: dto.sequence, amount: dto.amount, currency: dto.currency, ...(dto.dueDate !== undefined && { dueDate: toDate(dto.dueDate) }), ...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }), checkNumber: dto.checkNumber, isCash: dto.isCash, }, }); } async removeInstallment(policyId: string, id: string) { await this.ensureChild("policyPaymentInstallment", policyId, id); return this.prisma.policyPaymentInstallment.delete({ where: { id } }); } async addVehicle(policyId: string, dto: VehicleDto) { await this.ensurePolicy(policyId); return this.prisma.vehicle.create({ data: { policyId, ...dto } }); } async updateVehicle(policyId: string, id: string, dto: VehicleDto) { await this.ensureChild("vehicle", policyId, id); return this.prisma.vehicle.update({ where: { id }, data: { ...dto } }); } async removeVehicle(policyId: string, id: string) { await this.ensureChild("vehicle", policyId, id); return this.prisma.vehicle.delete({ where: { id } }); } async addDriver(policyId: string, dto: DriverDto) { await this.ensurePolicy(policyId); return this.prisma.insuredDriver.create({ data: { policyId, ...dto, birthDate: toDate(dto.birthDate) ?? undefined }, }); } async updateDriver(policyId: string, id: string, dto: UpdateDriverDto) { await this.ensureChild("insuredDriver", policyId, id); return this.prisma.insuredDriver.update({ where: { id }, data: { ...dto, ...(dto.birthDate !== undefined && { birthDate: toDate(dto.birthDate) }) }, }); } async removeDriver(policyId: string, id: string) { await this.ensureChild("insuredDriver", policyId, id); return this.prisma.insuredDriver.delete({ where: { id } }); } async addBeneficiary(policyId: string, dto: BeneficiaryDto) { await this.ensurePolicy(policyId); return this.prisma.policyBeneficiary.create({ data: { policyId, ...dto } }); } async updateBeneficiary(policyId: string, id: string, dto: UpdateBeneficiaryDto) { await this.ensureChild("policyBeneficiary", policyId, id); return this.prisma.policyBeneficiary.update({ where: { id }, data: { ...dto } }); } async removeBeneficiary(policyId: string, id: string) { await this.ensureChild("policyBeneficiary", policyId, id); return this.prisma.policyBeneficiary.delete({ where: { id } }); } async addClaim(policyId: string, dto: ClaimDto) { await this.ensurePolicy(policyId); return this.prisma.claim.create({ data: { policyId, ...this.claimData(dto) } }); } async updateClaim(policyId: string, id: string, dto: UpdateClaimDto) { await this.ensureChild("claim", policyId, id); return this.prisma.claim.update({ where: { id }, data: this.claimData(dto) }); } async removeClaim(policyId: string, id: string) { await this.ensureChild("claim", policyId, id); return this.prisma.claim.delete({ where: { id } }); } private claimData(dto: ClaimDto) { const { incidentDate, reportedDate, settlementDate, ...rest } = dto; return { ...rest, ...(incidentDate !== undefined && { incidentDate: toDate(incidentDate) }), ...(reportedDate !== undefined && { reportedDate: toDate(reportedDate) }), ...(settlementDate !== undefined && { settlementDate: toDate(settlementDate) }), }; } // --- documents ------------------------------------------------------------ // Blob in object storage under `policy//…`; row is the pointer. async addDocument( policyId: string, file: UploadedFileLike, documentType?: string, ) { await this.ensurePolicy(policyId); const key = `policy/${policyId}/${randomUUID()}${extForUpload(file)}`; await this.storage.put(key, file.buffer, file.mimetype); return this.prisma.policyDocument.create({ data: { policyId, documentType: documentType?.trim() || "DOCUMENT", storageKey: key, }, }); } async getDocument(policyId: string, id: string) { const row = await this.prisma.policyDocument.findFirst({ where: { id, policyId }, }); if (!row) throw new NotFoundException(`Document ${id} not found on policy ${policyId}`); const blob = await this.storage.getStream(row.storageKey); return { row, ...blob }; } async removeDocument(policyId: string, id: string) { await this.ensurePolicy(policyId); const row = await this.prisma.policyDocument.findFirst({ where: { id, policyId }, select: { id: true, storageKey: true }, }); if (!row) throw new NotFoundException(`Document ${id} not found on policy ${policyId}`); const deleted = await this.prisma.policyDocument.delete({ where: { id } }); await this.storage.delete(row.storageKey); return deleted; } // --- lookups (providers / policy types / adjusters) ----------------------- listLookups() { return this.prisma.$transaction([ this.prisma.insuranceProvider.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, _count: { select: { policies: true } } }, }), this.prisma.policyType.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true, shortDescription: true, _count: { select: { policies: true } }, }, }), this.prisma.adjuster.findMany({ orderBy: { name: "asc" } }), ]).then(([providers, types, adjusters]) => ({ providers, types, adjusters })); } createProvider(dto: ProviderDto) { return this.prisma.insuranceProvider.create({ data: dto }); } updateProvider(id: string, dto: UpdateProviderDto) { return this.prisma.insuranceProvider.update({ where: { id }, data: dto }); } removeProvider(id: string) { return this.prisma.insuranceProvider.delete({ where: { id } }); } createPolicyType(dto: PolicyTypeDto) { return this.prisma.policyType.create({ data: dto }); } updatePolicyType(id: string, dto: UpdatePolicyTypeDto) { return this.prisma.policyType.update({ where: { id }, data: dto }); } removePolicyType(id: string) { return this.prisma.policyType.delete({ where: { id } }); } createAdjuster(dto: AdjusterDto) { return this.prisma.adjuster.create({ data: dto }); } updateAdjuster(id: string, dto: UpdateAdjusterDto) { return this.prisma.adjuster.update({ where: { id }, data: dto }); } removeAdjuster(id: string) { return this.prisma.adjuster.delete({ where: { id } }); } }