import { BadRequestException, ConflictException, 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"; import type { OnboardCustomerDto } from "./onboard-customer.dto"; import { CashReceiptsService } from "../cash-receipts/cash-receipts.service"; import { BALANCE_FORWARD_TYPE, notCashJournal, PERIOD_TABLE_PREFIX, } from "../billing/billing.service"; /** * Keeps an imported prior period out of the *current* period, NULL-safely. * * A closed year is imported as its own tagged copy (`datos2@2025`). Below the * year start it is history and counts — for the one customer whose newest * BALANCE FORWARD lives inside an archive it is the only carry there is, and * dropping it understated NUMid 295 by his entire 2025 closing balance. At or * above the year start it must go: the archives spill a couple of rows into the * following January, and those already sit inside the next year's BALANCE * FORWARD, which is the sum of the whole archive. * * Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to * NULL for an app-captured row (no legacySourceTable), dropping every one. */ const archiveIsHistory = ( yearStart: Date, ): Prisma.TransactionWhereInput => ({ OR: [ { legacySourceTable: null }, { legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } }, { transactionDate: { lt: yearStart } }, ], }); 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, private readonly cashReceipts: CashReceiptsService, ) {} /** 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 } } } }, ...(/^\d+$/.test(q) ? [{ folderNumber: Number(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, folderNumber: 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, folderNumber: r.folderNumber, 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) { // The movement list on the customer file is the same statement the office // prints, so it follows the same rule as BillingService.statement: this // calendar year, oldest-first. No `take` any more — the cap used to hide // the end of a busy customer's year once the order flipped, and a single // year is small (365 rows for the heaviest customer in the book). const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); 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: { // Archives are excluded by tag, not by date. They are not cleanly // bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one // in 2026, datos2@2025 two more — so a date test alone would surface // a closed year's rows in the current year's list, duplicating the // live ledger's own copy of them for three customers. // Archives are kept out by tag, not by date. They are not cleanly // bounded — datos2@2025 carries rows dated into 2026 — so a date test // alone would surface a closed year's rows in the current year's // list. Nothing below yearStart reaches this list anyway, so the // window rule reduces to a plain exclusion here. where: { transactionDate: { gte: yearStart }, ...archiveIsHistory(yearStart), }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], 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. // // These have to answer the same question BillingService.statement answers, // because this card is titled "Estado de cuenta" and links straight to it — // two screens quoting one customer two different balances is worse than // either number alone. So it takes the same three rules the statement uses: // the balance floor, the cash-source exclusion, and dropping outstanding // rows the office has not paid yet. // // Without the floor these were a raw lifetime sum, double-counting the // pre-cutover history each BALANCE FORWARD row already absorbs. Importing // prior periods made that visibly worse: for NUMid 501 the tiles read // -7,119.29 before the archives landed and -15,270.59 after, against a true // -10,715.29 — the difference being exactly the 2024 and 2025 closing // balances, added a second time on top of the opening row that contains // them. const floor = await this.prisma.transaction.findFirst({ where: { customerId: id, voidedAt: null, type: { nameEn: BALANCE_FORWARD_TYPE }, }, orderBy: { transactionDate: "desc" }, select: { transactionDate: true }, }); const summary = await this.prisma.transaction.groupBy({ by: ["domain", "currency"], where: { customerId: id, voidedAt: null, outstanding: false, ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), // The floor alone does not settle the archives: a customer floored by // an archive clears it with every row of that archive, and the rows // archives spill into the following January clear any floor. AND: [archiveIsHistory(yearStart), notCashJournal()], }, _sum: { amount: true }, _count: { _all: true }, }); return { ...customer, /** Calendar year the movement list covers. */ transactionYear: yearStart.getUTCFullYear(), 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) { await this.assertFolderFree(dto.folderNumber); 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 }, }); } /** * Open an account from one screen: customer, property, services and the * opening balance taken as the first payment. One database transaction — a * half-created account (a customer with no property, a payment with no * customer) is worse than none, because the office would key it again. */ async onboard(dto: OnboardCustomerDto, userId: string) { const pay = dto.openingPayment; if (pay) { if (!(pay.amount > 0)) throw new BadRequestException("El saldo inicial debe ser mayor a cero."); if (pay.method === "CHECK" && !pay.checkNumber?.trim()) { throw new BadRequestException("Falta el número de cheque del saldo inicial."); } } if (dto.services?.length && !dto.property) { throw new BadRequestException("Los servicios necesitan una propiedad."); } await this.assertFolderFree(dto.customer.folderNumber); return this.prisma.$transaction(async (tx) => { const customer = await tx.customer.create({ data: { ...this.toData(dto.customer), name: dto.customer.name, nameMissing: false }, }); const property = dto.property ? await tx.property.create({ data: { ...dto.property, customerId: customer.id } }) : null; if (property && dto.services?.length) { await tx.propertyService.createMany({ data: dto.services.map((s) => ({ ...s, propertyId: property.id })), }); } const concept = pay?.concept?.trim() || "SALDO INICIAL"; let receipt = null; let payment = null; if (pay?.method === "CASH") { receipt = await this.cashReceipts.create( { customerId: customer.id, receivedAt: pay.receivedAt, amount: pay.amount, currency: pay.currency, exchangeRate: pay.exchangeRate, concept, }, userId, tx, ); } else if (pay?.method === "CHECK") { const date = new Date(pay.receivedAt); if (isNaN(date.getTime())) throw new BadRequestException("Fecha del saldo inicial inválida."); const type = await tx.typeTransaction.findFirst({ where: { nameEn: "CHECK DEPOSIT" }, select: { id: true }, }); payment = await tx.transaction.create({ data: { customerId: customer.id, domain: "UTILITY", typeId: type?.id, transactionDate: date, amount: new Prisma.Decimal(pay.amount.toFixed(2)), currency: pay.currency, checkNumber: pay.checkNumber!.trim(), message: concept, captureSource: "MANUAL", }, }); } return { customer, property, receipt, payment }; }); } async update(id: string, dto: UpdateCustomerDto) { const current = await this.ensureExists(id); // An archived customer's number is history, not a claim on the shelf. if (!current.archivedAt) await this.assertFolderFree(dto.folderNumber, 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) { const current = await this.ensureExists(id); // Its folder may have been handed to someone else while it was closed. await this.assertFolderFree(current.folderNumber, 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, archivedAt: true, folderNumber: true }, }); if (!found) throw new NotFoundException(`Customer ${id} not found`); return found; } // --- folder numbers --------------------------------------------------------- /** * A folder number belongs to one open account at a time. Archived customers * keep theirs as a record of which folder they used, without holding it. * * ponytail: check-then-write, not a constraint — two staff saving the same * number in the same instant both win. A functional unique index on * IF(archivedAt IS NULL, folderNumber, NULL) closes it, if Prisma ever models one. */ private async assertFolderFree(folderNumber: number | null | undefined, exceptId?: string) { if (folderNumber == null) return; const holder = await this.prisma.customer.findFirst({ where: { folderNumber, archivedAt: null, ...(exceptId ? { id: { not: exceptId } } : {}) }, select: { name: true }, }); if (holder) { throw new ConflictException(`La carpeta ${folderNumber} ya la tiene ${holder.name}`); } } /** Lowest folder number no open account holds — how the office reuses them. */ async nextFolderNumber(): Promise { const [row] = await this.prisma.$queryRaw<{ n: bigint | number }[]>` SELECT MIN(c.n) AS n FROM ( SELECT 1 AS n UNION SELECT folderNumber + 1 FROM customers WHERE archivedAt IS NULL AND folderNumber IS NOT NULL ) c WHERE c.n NOT IN ( SELECT folderNumber FROM customers WHERE archivedAt IS NULL AND folderNumber IS NOT NULL )`; return Number(row.n); } /** 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 }; } }