import { Injectable, NotFoundException } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; import { CreateCustomerDto } from "./create-customer.dto"; import { UpdateCustomerDto } from "./update-customer.dto"; export interface ListParams { query?: string; page: number; pageSize: number; line?: "utility" | "insurance" | "both"; includeArchived?: boolean; } /** Parse an optional ISO date string to a Date (or null to clear it). */ function toDate(v?: string): Date | null | undefined { if (v === undefined) return undefined; if (v === "" || v === null) return null; const d = new Date(v); return isNaN(d.getTime()) ? undefined : d; } @Injectable() export class CustomersService { constructor(private readonly prisma: PrismaService) {} /** Unified customer list with search + business-line filter, paginated. */ async list({ query, page, pageSize, line, includeArchived }: ListParams) { const where: Prisma.CustomerWhereInput = {}; if (!includeArchived) where.archivedAt = null; if (query && query.trim()) { const q = query.trim(); where.OR = [ { name: { contains: q } }, { email: { contains: q } }, { phone: { contains: q } }, { mobile: { contains: q } }, { city: { contains: q } }, { legacyRefs: { some: { legacyId: { contains: q } } } }, ]; } if (line === "utility") where.properties = { some: {} }; if (line === "insurance") where.policies = { some: {} }; if (line === "both") { where.properties = { some: {} }; where.policies = { some: {} }; } const [total, rows] = await this.prisma.$transaction([ this.prisma.customer.count({ where }), this.prisma.customer.findMany({ where, skip: (page - 1) * pageSize, take: pageSize, // Nameless records last: ordering by name alone floats every // "(SIN NOMBRE)" to the top, since "(" sorts before every letter. orderBy: [{ nameMissing: "asc" }, { name: "asc" }], select: { id: true, name: true, nameSource: true, city: true, state: true, email: true, phone: true, mobile: true, status: true, archivedAt: true, _count: { select: { properties: true, policies: true, transactions: true } }, }, }), ]); const items = rows.map((r) => ({ id: r.id, name: r.name, nameSource: r.nameSource, city: r.city, state: r.state, email: r.email, phone: r.phone, mobile: r.mobile, status: r.status, archived: r.archivedAt != null, propertyCount: r._count.properties, policyCount: r._count.policies, transactionCount: r._count.transactions, hasUtilities: r._count.properties > 0, hasInsurance: r._count.policies > 0, })); return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) }; } /** Full unified customer view: identity + both business lines + ledger. */ async detail(id: string) { const customer = await this.prisma.customer.findUnique({ where: { id }, include: { legacyRefs: true, properties: { include: { services: true, trustAccount: true, documents: true }, }, policies: { orderBy: { policyFrom: "desc" }, include: { policyType: true, insuranceProvider: true, installments: { orderBy: { sequence: "asc" } }, vehicles: true, insuredDrivers: true, beneficiaries: true, claims: true, documents: true, }, }, transactions: { orderBy: { transactionDate: "desc" }, take: 100, include: { type: true }, }, }, }); if (!customer) { throw new NotFoundException(`Customer ${id} not found`); } // Ledger totals per domain + currency (the "one statement across both // business lines" payoff), computed in the DB rather than in JS. const summary = await this.prisma.transaction.groupBy({ by: ["domain", "currency"], // Exclude voided rows so the per-domain balance matches the statement. where: { customerId: id, voidedAt: null }, _sum: { amount: true }, _count: { _all: true }, }); return { ...customer, transactionSummary: summary.map((s) => ({ domain: s.domain, currency: s.currency, total: s._sum.amount, count: s._count._all, })), }; } // --- writes --------------------------------------------------------------- private toData(dto: CreateCustomerDto | UpdateCustomerDto) { // Whitelisted by the DTO already; map the date strings to Date objects. const { identificationExpiration, customerSince, ...rest } = dto; return { ...rest, ...(identificationExpiration !== undefined && { identificationExpiration: toDate(identificationExpiration), }), ...(customerSince !== undefined && { customerSince: toDate(customerSince) }), }; } async create(dto: CreateCustomerDto) { return this.prisma.customer.create({ // App-created rows: nameMissing false (name is required), no legacy // provenance — those columns stay null, marking a native record. data: { ...this.toData(dto), name: dto.name, nameMissing: false }, }); } async update(id: string, dto: UpdateCustomerDto) { await this.ensureExists(id); return this.prisma.customer.update({ where: { id }, data: this.toData(dto) }); } /** Soft-delete: hide from default lists, keep the row + provenance. */ async archive(id: string) { await this.ensureExists(id); return this.prisma.customer.update({ where: { id }, data: { archivedAt: new Date() }, }); } async restore(id: string) { await this.ensureExists(id); return this.prisma.customer.update({ where: { id }, data: { archivedAt: null }, }); } private async ensureExists(id: string) { const found = await this.prisma.customer.findUnique({ where: { id }, select: { id: true }, }); if (!found) throw new NotFoundException(`Customer ${id} not found`); } /** Top-line counts for a dashboard header. */ async stats() { const [customers, withUtilities, withInsurance, policies, properties, transactions] = await this.prisma.$transaction([ this.prisma.customer.count(), this.prisma.customer.count({ where: { properties: { some: {} } } }), this.prisma.customer.count({ where: { policies: { some: {} } } }), this.prisma.policy.count(), this.prisma.property.count(), this.prisma.transaction.count(), ]); const bothLines = await this.prisma.customer.count({ where: { properties: { some: {} }, policies: { some: {} } }, }); return { customers, withUtilities, withInsurance, bothLines, policies, properties, transactions }; } }