/** * The catalog. One entry per report. Adding a new report is one new entry * here — no new route, no new page, no new component. * * Filter behavior to keep in mind: * - Currency balances are NEVER summed across currencies (912 customers * carry both MXN and USD; the legacy data has no FX per row, so any * cross-currency total would be invented). Every report that touches * the ledger accepts a `currency` filter and reports per currency. * - Voided transactions must be excluded from totals (NOT_VOIDED). The * UI still shows them struck-through; the SQL drops them. * - The legacy `REPORTE DE EFECTIVO` covered cash receipts only. In the * new schema those are `Transaction` rows with `legacySourceTable` in * the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash). */ import { Prisma } from "@jorgecuadros/database"; import { BALANCE_FORWARD_TYPE, notCashJournal, periodSourceTable, } from "../billing/billing.service"; import { intParam, NOT_VOIDED, parseDate, type ReportDef, } from "./reports.types"; import { renewalLetterSelect, toRenewalLetterRow, } from "./renewal-letter"; /* ------------------------------------------------------------------ helpers */ function nameOf(c: { name: string; nameMissing: boolean }): string { return c.nameMissing ? "(sin nombre)" : c.name; } /* ------------------------------------------------------------------ reports */ /** * LISTADO EN ROJO — overdue customers worklist. * Same data as the receivables worklist with balance=owing, but presented * as a printable report rather than a paginated browser. */ const listadoEnRojo: ReportDef = { slug: "listado-en-rojo", title: "Clientes en rojo", description: "Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " + "ordenados del más antiguo al más reciente.", domain: "estado-cuenta", legacyName: "LISTADO EN ROJO", format: "tabular", params: [ { key: "currency", label: "Moneda", kind: "select", options: [ { value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }, ], defaultValue: "MXN", }, { key: "query", label: "Buscar (nombre o ciudad)", kind: "text", placeholder: "Ej. Pérez, Tijuana…", }, ], columns: [ { key: "id", label: "#", type: "text" }, { key: "name", label: "Cliente", type: "text" }, { key: "city", label: "Ciudad", type: "text" }, { key: "movements", label: "Movs.", type: "number", align: "right" }, { key: "balance", label: "Saldo", type: "money", align: "right" }, { key: "lastMovement", label: "Último movimiento", type: "date" }, ], async run(prisma, p) { const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD"; const q = p.query?.trim(); const nameFilter = q ? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})` : Prisma.empty; const bal = currency === "USD" ? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)` : Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`; const rows = await prisma.$queryRaw< Array<{ id: string; name: string; nameMissing: boolean; city: string | null; movements: bigint | number | string; balance: Prisma.Decimal | null; lastMovement: Date | null; }> >` SELECT c.id, c.name, c.nameMissing, c.city, COUNT(*) AS movements, ${bal} AS balance, MAX(t.transactionDate) AS lastMovement FROM customers c JOIN transactions t ON t.customerId = c.id WHERE t.voidedAt IS NULL ${nameFilter} GROUP BY c.id, c.name, c.nameMissing, c.city HAVING ${bal} < -0.005 ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC `; let totalBalance = new Prisma.Decimal(0); let totalMovs = 0; const out = rows.map((r) => { const b = r.balance ?? new Prisma.Decimal(0); totalBalance = totalBalance.plus(b); totalMovs += Number(r.movements); return { id: r.id.slice(0, 8), name: nameOf(r), city: r.city ?? "—", movements: Number(r.movements), balance: b.toFixed(2), lastMovement: r.lastMovement ? r.lastMovement.toISOString().slice(0, 10) : "—", }; }); return { rows: out, totals: { customers: out.length, movements: totalMovs, balance: totalBalance.toFixed(2), currency, }, subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`, }; }, }; /** * PAGOS NO EFECTUADOS (AGUA / LUZ / TEL). * Customers enrolled in a service (by PropertyService.kind) with no * related ledger charge in the last N days. Heuristic: any non-voided * charge-type transaction in the window counts as "they paid". The * report groups by property so the same customer with two water meters * appears once per property. */ const pagosNoEfectuados: ReportDef = { slug: "pagos-no-efectuados", title: "Pagos no efectuados", description: "Clientes con un servicio contratado (agua, luz o teléfono) sin " + "movimientos de cargo en los últimos N días. Heurística basada en el " + "servicio registrado en la propiedad y la ausencia de cargos en el " + "periodo seleccionado.", domain: "servicios", legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL", format: "tabular", params: [ { key: "serviceKind", label: "Servicio", kind: "select", options: [ { value: "WATER", label: "Agua" }, { value: "ELECTRICITY", label: "Luz" }, { value: "PHONE", label: "Teléfono" }, ], defaultValue: "WATER", }, { key: "days", label: "Días sin movimiento", kind: "number", defaultValue: "60", }, { key: "currency", label: "Moneda", kind: "select", options: [ { value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }, ], defaultValue: "MXN", }, ], columns: [ { key: "customerId", label: "Cliente #", type: "text" }, { key: "customerName", label: "Cliente", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" }, { key: "accountNumber", label: "Cuenta / Medidor", type: "text" }, { key: "lastCharge", label: "Último cargo", type: "date" }, { key: "balance", label: "Saldo", type: "money", align: "right" }, ], async run(prisma, p) { const kind = (p.serviceKind ?? "WATER") as | "WATER" | "ELECTRICITY" | "PHONE"; const days = intParam(p, "days", 60, 1, 365); const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD"; const cutoff = new Date(Date.now() - days * 86400000); // Customers enrolled in the service on a non-archived property, with no // charge-type transaction in the window. The subquery picks up // *anything* the customer paid (any domain, any type) — close enough // for the staff's "who's overdue" view; the precise per-service match // would need a per-service typeId taxonomy that doesn't exist in the // legacy data. const rows = await prisma.$queryRaw< Array<{ customerId: string; customerName: string; nameMissing: boolean; propertyId: string; propertyAddress: string | null; accountNumber: string | null; lastCharge: Date | null; balance: Prisma.Decimal | null; }> >` SELECT c.id AS customerId, c.name AS customerName, c.nameMissing AS nameMissing, pr.id AS propertyId, pr.addressLine1 AS propertyAddress, ps.accountNumber AS accountNumber, (SELECT MAX(t.transactionDate) FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.amount < 0 AND t.transactionDate >= ${cutoff}) AS lastCharge, (SELECT SUM(t.amount) FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.currency = ${currency}) AS balance FROM property_services ps JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL WHERE ps.kind = ${kind} AND ps.active = 1 AND NOT EXISTS ( SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.amount < 0 AND t.transactionDate >= ${cutoff} ) ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC `; let totalBalance = new Prisma.Decimal(0); const out = rows.map((r) => { const b = r.balance ?? new Prisma.Decimal(0); totalBalance = totalBalance.plus(b); return { customerId: r.customerId.slice(0, 8), customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing, }), propertyAddress: r.propertyAddress ?? "—", accountNumber: r.accountNumber ?? "—", lastCharge: r.lastCharge ? r.lastCharge.toISOString().slice(0, 10) : "—", balance: b.toFixed(2), }; }); return { rows: out, totals: { rows: out.length, balance: totalBalance.toFixed(2), currency, }, subtitle: `Servicio: ${ kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono" } · ${days} días · ${out.length} propiedades sin cargo reciente`, }; }, }; /** * FALTANTES DE (AGUA / LUZ / TEL). * Data-quality report: properties enrolled in a service that are missing * the key identifier the legacy system required (account/meter/route). * Different `faltante` per service kind in the legacy because the * service's identifier fields differ; here we flag any of the three * common identifiers being blank. */ const faltantes: ReportDef = { slug: "faltantes", title: "Faltantes de datos por servicio", description: "Calidad de datos: propiedades con un servicio contratado que no " + "tienen número de cuenta, medidor o ruta registrado. El reporte que " + "en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.", domain: "servicios", legacyName: "FALTANTES DE AGUA/LUZ/TEL", format: "tabular", params: [ { key: "serviceKind", label: "Servicio", kind: "select", options: [ { value: "WATER", label: "Agua" }, { value: "ELECTRICITY", label: "Luz" }, { value: "PHONE", label: "Teléfono" }, ], defaultValue: "WATER", }, ], columns: [ { key: "customerId", label: "Cliente #", type: "text" }, { key: "customerName", label: "Cliente", type: "text" }, { key: "propertyAddress", label: "Dirección", type: "text" }, { key: "missing", label: "Faltante", type: "text" }, { key: "dueDay", label: "Día de vencimiento", type: "text" }, ], async run(prisma, p) { const kind = (p.serviceKind ?? "WATER") as | "WATER" | "ELECTRICITY" | "PHONE"; // A row per (property, missing field). The "missing" string describes // what's blank so the report is self-explanatory when printed. const rows = await prisma.$queryRaw< Array<{ customerId: string; customerName: string; nameMissing: boolean; propertyId: string; propertyAddress: string | null; dueDay: string | null; missing: string; }> >` SELECT c.id AS customerId, c.name AS customerName, c.nameMissing AS nameMissing, pr.id AS propertyId, pr.addressLine1 AS propertyAddress, ps.dueDay AS dueDay, CASE WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta' WHEN ps.meterNumber IS NULL OR ps.meterNumber = '' THEN 'Sin número de medidor' WHEN ps.route IS NULL OR ps.route = '' THEN 'Sin ruta' ELSE '' END AS missing FROM property_services ps JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL WHERE ps.kind = ${kind} AND ps.active = 1 AND ( ps.accountNumber IS NULL OR ps.accountNumber = '' OR ps.meterNumber IS NULL OR ps.meterNumber = '' OR ps.route IS NULL OR ps.route = '' ) ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC `; const out = rows.map((r) => ({ customerId: r.customerId.slice(0, 8), customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing, }), propertyAddress: r.propertyAddress ?? "—", missing: r.missing, dueDay: r.dueDay ?? "—", })); return { rows: out, totals: { rows: out.length }, subtitle: `Servicio: ${ kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono" } · ${out.length} propiedades con datos faltantes`, }; }, }; /** * REPORTE DE EFECTIVO — cash reconciliation. * Credits in the EFECTIVO* legacy source tables OR with no cheque number * (true cash) within a date range. Excludes voided rows. Matches the * shape of the legacy REPORTE DE EFECTIVO report. */ const reporteDeEfectivo: ReportDef = { slug: "reporte-de-efectivo", title: "Reporte de efectivo", description: "Recibos de efectivo en el periodo seleccionado. Cubre los abonos " + "provenientes de las tablas legacy EFECTIVO* y los créditos sin " + "número de cheque (efectivo real). El match del reporte original.", domain: "chequera", legacyName: "REPORTE DE EFECTIVO", format: "tabular", params: [ { key: "from", label: "Desde", kind: "date" }, { key: "to", label: "Hasta", kind: "date", endOfDay: true }, { key: "currency", label: "Moneda", kind: "select", options: [ { value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }, ], defaultValue: "MXN", }, ], columns: [ { key: "date", label: "Fecha", type: "date" }, { key: "customerName", label: "Cliente", type: "text" }, { key: "concept", label: "Concepto", type: "text" }, { key: "source", label: "Origen", type: "text" }, { key: "amount", label: "Monto", type: "money", align: "right" }, ], async run(prisma, p) { const from = parseDate(p.from); const to = parseDate(p.to, true); const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD"; const ands: Prisma.TransactionWhereInput[] = [ NOT_VOIDED, { amount: { gt: 0 } }, { currency }, { OR: [ { legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } }, { AND: [ { checkNumber: null }, { legacySourceTable: { not: "CHEQUE FM3" } }, ], }, ], }, ]; if (from || to) { ands.push({ transactionDate: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}), }, }); } const rows = await prisma.transaction.findMany({ where: { AND: ands }, orderBy: { transactionDate: "asc" }, select: { transactionDate: true, amount: true, reference: true, checkNumber: true, message: true, legacySourceTable: true, type: { select: { nameEs: true, nameEn: true } }, customer: { select: { name: true, nameMissing: true } }, }, }); let total = new Prisma.Decimal(0); const out = rows.map((r) => { total = total.plus(r.amount); return { date: r.transactionDate.toISOString().slice(0, 10), customerName: nameOf(r.customer), concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—", source: r.legacySourceTable ?? "—", amount: r.amount.toFixed(2), }; }); return { rows: out, totals: { rows: out.length, total: total.toFixed(2), currency, }, subtitle: `Efectivo · ${currency} · ${out.length} recibos${ from ? ` desde ${p.from}` : "" }${to ? ` hasta ${p.to}` : ""}`, }; }, }; /** * VIGENTE (LIC / INCEN / MULT / …) — policies up for renewal. * Wraps the policy listing with status=expiring and a policy-type filter, * sorted by soonest expiry. The legacy VIGENTE LIC / INCEN / MULT * reports are the same data; the new filter is a dropdown. */ const vigente: ReportDef = { slug: "vigente", title: "Pólizas por vencer", description: "Pólizas que vencen en los próximos N días, filtradas por ramo. " + "Equivalente a los reportes VIGENTE LIC / INCEN / MULT de la legacy.", domain: "polizas", legacyName: "VIGENTE LIC/INCEN/MULT", format: "tabular", params: [ { key: "typeName", label: "Ramo", kind: "select", options: [ { value: "LICENCIAS", label: "Licencias" }, { value: "INCENDIO", label: "Incendio" }, { value: "MULT", label: "Multirriesgo" }, { value: "MCA2", label: "MCA2 (auto)" }, { value: "ME", label: "ME" }, { value: "MF", label: "MF" }, { value: "RC", label: "RC" }, { value: "INCEN", label: "Incen" }, { value: "TAMPL", label: "TAMPL" }, { value: "FAMILIAR", label: "Familiar" }, ], defaultValue: "LICENCIAS", }, { key: "days", label: "Ventana (días)", kind: "number", defaultValue: "30", }, ], columns: [ { key: "policyNumber", label: "Póliza", type: "text" }, { key: "customerName", label: "Cliente", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" }, { key: "agent", label: "Agente", type: "text" }, { key: "from", label: "Desde", type: "date" }, { key: "to", label: "Vence", type: "date" }, { key: "daysToExpire", label: "Días", type: "number", align: "right" }, { key: "premium", label: "Prima neta", type: "money", align: "right" }, ], async run(prisma, p) { const typeName = p.typeName ?? "LICENCIAS"; const days = intParam(p, "days", 30, 1, 365); const now = new Date(); const today = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), ); const soon = new Date(today.getTime() + days * 86400000); const rows = await prisma.policy.findMany({ where: { policyType: { name: typeName }, archivedAt: null, policyTo: { gte: today, lte: soon }, }, orderBy: { policyTo: "asc" }, select: { policyNumber: true, policyFrom: true, policyTo: true, netPremium: true, agentName: true, customer: { select: { name: true, nameMissing: true } }, insuranceProvider: { select: { name: true } }, }, }); let totalPremium = new Prisma.Decimal(0); const out = rows.map((r) => { const daysTo = r.policyTo ? Math.round( (r.policyTo.getTime() - today.getTime()) / 86400000, ) : 0; if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); return { policyNumber: r.policyNumber, customerName: nameOf(r.customer), provider: r.insuranceProvider?.name ?? "—", agent: r.agentName ?? "—", from: r.policyFrom ? r.policyFrom.toISOString().slice(0, 10) : "—", to: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—", daysToExpire: daysTo, premium: r.netPremium ? r.netPremium.toFixed(2) : "0.00", }; }); return { rows: out, totals: { rows: out.length, premium: totalPremium.toFixed(2), }, subtitle: `Ramo: ${typeName} · ${days} días · ${out.length} pólizas por vencer`, }; }, }; /** * AVISO DE RENOVACION — insurance renewal notice. * * Replaces ~40 legacy report clones (one per carrier per coverage tier — * `AMPL R RENEW X MES NEW ATLAS 13`, `... QUALITAS ...`, `LIC RENEW X * VENCE ATLAS 2013`, etc., see docs/RENEWAL_NOTICES.md) with one * parameterized report: pick the ramo, the expiry month/year, which * notice generation (1st/2nd/3rd, mirroring the legacy RENEW/RENEW2/ * RENEW3 escalation), and optionally a carrier filter. * * The legacy reports hardcoded per-policy figures (deductible, CSL limit, * premium) as static label text re-typed by hand for every new rate/ * carrier clone. Here they're read from real columns / `coveragesJson` * (see docs/RENEWAL_NOTICES.md's column-mapping table) so one template * covers every carrier and tier instead of a clone per combination. * * `sentStatus` is read from `RenewalNotice` (schema.prisma) — the * replacement for the legacy `CONTROL RENEW[2/3] X MES` paper log. */ const avisoRenovacion: ReportDef = { slug: "aviso-renovacion", title: "Aviso de renovación", description: "Cartas de aviso de renovación para pólizas por vencer en el mes y " + "año seleccionados, con la generación de aviso (1a/2a/3a) y filtro " + "opcional por aseguradora. Sustituye a los ~40 reportes clonados por " + "aseguradora/cobertura de la legacy (ver docs/RENEWAL_NOTICES.md).", domain: "polizas", legacyName: "AMPL R RENEW X MES NEW ATLAS 13 / RC RENEW X MES NEW ATLAS 13 / " + "LIC RENEW X VENCE ATLAS 2013 / RCR RENEW X MES NEWATLAS 2013 (y " + "sus clones por aseguradora y cobertura)", format: "letter", params: [ { key: "policyType", label: "Ramo", kind: "select", options: [ { value: "AUTO", label: "Auto" }, { value: "LICENCIAS", label: "Licencias" }, { value: "INCENDIO", label: "Incendio" }, { value: "MULT", label: "Multirriesgo" }, { value: "M_EMPR", label: "M Empresarial" }, ], defaultValue: "AUTO", }, { key: "month", label: "Mes de vencimiento (1-12)", kind: "number", defaultValue: String(new Date().getUTCMonth() + 1), }, { key: "year", label: "Año de vencimiento", kind: "number", defaultValue: String(new Date().getUTCFullYear()), }, { key: "generation", label: "Generación de aviso", kind: "select", options: [ { value: "1", label: "1er aviso" }, { value: "2", label: "2o aviso" }, { value: "3", label: "3er aviso" }, ], defaultValue: "1", }, { key: "provider", label: "Aseguradora (opcional)", kind: "text", placeholder: "Ej. ATLAS, QUALITAS…", }, ], // Flat columns so CSV/XLSX/generic PDF exports stay useful even though // the on-screen view renders each row as a full letter (LetterLayout in // ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement". columns: [ { key: "policyNumber", label: "Póliza", type: "text" }, { key: "customerName", label: "Cliente", type: "text" }, { key: "provider", label: "Aseguradora", type: "text" }, { key: "policyTo", label: "Vence", type: "date" }, { key: "netPremium", label: "Prima neta", type: "money", align: "right" }, { key: "total", label: "Total", type: "money", align: "right" }, { key: "generation", label: "Generación", type: "number" }, { key: "sentAt", label: "Enviado", type: "date" }, ], async run(prisma, p) { const typeName = p.policyType ?? "AUTO"; const month = intParam(p, "month", new Date().getUTCMonth() + 1, 1, 12); const year = intParam(p, "year", new Date().getUTCFullYear(), 1990, 2100); const generation = intParam(p, "generation", 1, 1, 3); const provider = p.provider?.trim(); const from = new Date(Date.UTC(year, month - 1, 1)); const to = new Date(Date.UTC(year, month, 1)); const rows = await prisma.policy.findMany({ where: { policyType: { name: typeName }, archivedAt: null, policyTo: { gte: from, lt: to }, ...(provider ? { insuranceProvider: { name: { contains: provider } } } : {}), }, orderBy: { policyTo: "asc" }, select: renewalLetterSelect(generation), }); let totalPremium = new Prisma.Decimal(0); let sentCount = 0; const out = rows.map((r) => { if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium); const letter = toRenewalLetterRow(r, generation); if (letter.sentAt) sentCount++; return letter; }); return { rows: out, totals: { cartas: out.length, enviadas: sentCount, pendientes: out.length - sentCount, primaTotal: totalPremium.toFixed(2), }, subtitle: `Ramo: ${typeName} · vencen ${String(month).padStart(2, "0")}/${year} · generación ${generation}${ provider ? ` · aseguradora: ${provider}` : "" } · ${out.length} avisos`, }; }, }; /** * EDO CUENTA DATOS — per-customer account statement. * Wraps the existing BillingService.statement() output. The full layout * (header, balance per currency, by-domain split, by-type breakdown, * full movement list with running balance) is rendered by the statement * page; this report is the same data with print/PDF/CSV/XLSX outputs. */ const edoCuentaDatos: ReportDef = { slug: "edo-cuenta-datos", title: "Estado de cuenta", description: "Estado de cuenta de un cliente: saldos por moneda, desglose por " + "ramo y concepto, y los movimientos del año en curso con saldo " + "corrido, abriendo con el saldo anterior. El reporte del cliente final.", domain: "estado-cuenta", legacyName: "EDO CUENTA DATOS", format: "statement", params: [ { key: "customerId", label: "Cliente", kind: "customer-picker" }, // Which period to print. Blank means the year in progress; an earlier year // prints from its imported archive, the same source the on-screen // statement reads. { key: "year", label: "Periodo (año)", kind: "number", placeholder: "año en curso", }, ], columns: [ // Statement rows carry synthetic `__kind` discriminators instead of // column keys; the runner renders the special cases inline. These // columns drive CSV/XLSX when the user wants a flat movement export. { key: "date", label: "Fecha", type: "date" }, { key: "concept", label: "Concepto", type: "text" }, { key: "reference", label: "Referencia", type: "text" }, { key: "amount", label: "Cargo / Abono", type: "money", align: "right" }, { key: "balanceAfter", label: "Saldo", type: "money", align: "right" }, ], async run(prisma, p) { const customerId = p.customerId; if (!customerId) { return { rows: [], subtitle: "Selecciona un cliente" }; } const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { id: true, name: true, nameMissing: true, addressLine1: true, city: true, state: true, email: true, phone: true, }, }); if (!customer) return { rows: [], subtitle: "Cliente no encontrado" }; // The source-table exclusion, the balance floor and the year scope below // are BillingService.statement's, because this report and // /estado-cuenta/[id] are the same statement — one printable, one on // screen — and a customer holding both must not read two balances. const floor = await prisma.transaction.findFirst({ where: { customerId, voidedAt: null, type: { nameEn: BALANCE_FORWARD_TYPE }, }, orderBy: { transactionDate: "desc" }, select: { transactionDate: true }, }); // Which period to print. An earlier year comes from its imported archive, // tagged rather than dated, exactly as the on-screen statement reads it. const thisYear = new Date().getUTCFullYear(); const askedYear = Number(p.year); const requestedYear = Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear; const isArchive = requestedYear !== thisYear; const rows = await prisma.transaction.findMany({ where: { customerId, voidedAt: null, ...(isArchive ? // The archive is one period's ledger already, so the tag is the // whole filter and the balance floor must not apply — the floor // hides exactly the history this period is asking for. { legacySourceTable: periodSourceTable(requestedYear) } : { ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), // Archive rows count as history below the year start (that is // what `opening` is for, and for a customer floored by an archive // it is the only carry there is) and are dropped at or above it. // Same rule as the on-screen twin — see BillingService.statement. AND: [ { OR: [ { legacySourceTable: null }, { legacySourceTable: { not: { startsWith: "datos2@" } } }, { transactionDate: { lt: new Date(Date.UTC(requestedYear, 0, 1)), }, }, ], }, // The cash receipt book, which the ledger already carries as // its own `C` postings. Taken from the shared helper // rather than restated, so the printed statement and the screen // cannot drift apart — and so this keeps the database // qualifier that spares the insurance line's own EFECTIVO. notCashJournal(), ], }), }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], select: { id: true, transactionDate: true, domain: true, amount: true, currency: true, reference: true, period: true, checkNumber: true, message: true, legacySourceTable: true, type: { select: { nameEs: true, nameEn: true } }, }, }); // Scoped to the calendar year and listed oldest-first, the way the legacy // EDO CUENTA sheet reads. Rows from earlier years still move the running // balance — they are folded into `opening` and printed as a single "saldo // anterior" line, which is what a BALANCE FORWARD row is. // An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE // FORWARD row is the carry, printed like legacy printed it. const yearStart = isArchive ? new Date(0) : new Date(Date.UTC(requestedYear, 0, 1)); const year = requestedYear; const running = new Map(); const opening = new Map(); const visible: typeof rows = []; const movements = rows.flatMap((r) => { const prev = running.get(r.currency) ?? new Prisma.Decimal(0); const next = prev.plus(r.amount); running.set(r.currency, next); if (r.transactionDate < yearStart) { opening.set(r.currency, next); return []; } visible.push(r); return { date: r.transactionDate.toISOString().slice(0, 10), domain: r.domain, currency: r.currency, reference: r.reference ?? "", period: r.period ?? "", checkNumber: r.checkNumber ?? "", concept: r.type?.nameEs ?? r.type?.nameEn ?? "—", amount: r.amount.toFixed(2), balanceAfter: next.toFixed(2), }; }); // The carried balance, printed as the statement's first line — same shape // as a movement row so it needs nothing special from the renderer. const carried = [...opening.entries()] .filter(([, amount]) => !amount.isZero()) .map(([currency, amount]) => ({ date: yearStart.toISOString().slice(0, 10), domain: "UTILITY", currency, reference: "", period: `Al cierre de ${year - 1}`, checkNumber: "", concept: "SALDO ANTERIOR", amount: amount.toFixed(2), balanceAfter: amount.toFixed(2), })); // Per-currency summary, seeded with the carried balance so it reconciles // against the last running balance printed below. const perCurrency = new Map< string, { currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number } >(); for (const [currency, amount] of opening) { perCurrency.set(currency, { currency, charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0), credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount, count: 0, }); } for (const r of visible) { const c = perCurrency.get(r.currency) ?? { currency: r.currency, charges: new Prisma.Decimal(0), credits: new Prisma.Decimal(0), count: 0, }; c.count += 1; if (r.amount.lessThan(0)) c.charges = c.charges.plus(r.amount); else c.credits = c.credits.plus(r.amount); perCurrency.set(r.currency, c); } return { rows: [ { __kind: "header", name: nameOf(customer), address: customer.addressLine1 ?? "", city: [customer.city, customer.state].filter(Boolean).join(", "), phone: customer.phone ?? "", email: customer.email ?? "", }, ...[...perCurrency.values()].map((c) => ({ __kind: "summary", currency: c.currency, charges: c.charges.toFixed(2), credits: c.credits.toFixed(2), balance: c.charges.plus(c.credits).toFixed(2), count: c.count, })), { __kind: "movements-header" }, ...carried, ...movements, ], subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`, }; }, }; /** * REPORTE CHEQUE COUNT — everything captured against one check. * * The reconciliation half of the batch-capture flow (docs/RECEIPT_CAPTURE_SPEC * §1.3): staff key many customers' receipts against one physical check, then * check that what was captured adds up to what the check was cut for. Replaces * `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE` and * `REPORTE POR CHEQUE PARA ALFA` — four legacy objects, one parameterized * report. * * Deliberately mirrors `BillingService.byCheck`'s rules rather than inventing * its own: voided rows are dropped entirely, and outstanding (NOPAGO) rows are * listed but excluded from the total, because the check never funded them. */ const chequeCount: ReportDef = { slug: "cheque-count", title: "Reporte por cheque", description: "Todos los movimientos capturados contra un mismo cheque, con el total " + "para conciliar contra el importe físico del cheque. Los movimientos " + "pendientes de pago (sin fondos) se listan pero no suman al total.", domain: "estado-cuenta", legacyName: "REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE COUNT", format: "tabular", params: [ { key: "checkNumber", label: "Número de cheque", kind: "text", placeholder: "Ej. 10432", }, ], columns: [ { key: "customerName", label: "Cliente", type: "text" }, { key: "reference", label: "Referencia", type: "text" }, { key: "period", label: "Periodo", type: "text" }, { key: "concept", label: "Concepto", type: "text" }, { key: "transactionDate", label: "Fecha", type: "date" }, { key: "status", label: "Estado", type: "text" }, { key: "amount", label: "Importe", type: "money", align: "right" }, ], async run(prisma, p) { const checkNumber = p.checkNumber?.trim(); if (!checkNumber) { return { rows: [], totals: { movimientos: 0 }, subtitle: "Indique un número de cheque", }; } const rows = await prisma.transaction.findMany({ where: { checkNumber, ...NOT_VOIDED }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], select: { transactionDate: true, amount: true, currency: true, reference: true, period: true, outstanding: true, type: { select: { nameEn: true, nameEs: true } }, customer: { select: { name: true, nameMissing: true } }, }, }); // Per currency, and never collapsed — same rule as the rest of the ledger. const totals = new Map(); let outstandingCount = 0; for (const r of rows) { if (r.outstanding) { outstandingCount++; continue; } totals.set( r.currency, (totals.get(r.currency) ?? new Prisma.Decimal(0)).plus(r.amount), ); } const totalsOut: Record = { movimientos: rows.length, }; for (const [currency, sum] of totals) { totalsOut[`total ${currency}`] = sum.toFixed(2); } if (outstandingCount) totalsOut["sin fondos"] = outstandingCount; return { rows: rows.map((r) => ({ customerName: nameOf(r.customer), reference: r.reference ?? "—", period: r.period ?? "—", concept: r.type?.nameEs || r.type?.nameEn || "Sin clasificar", transactionDate: r.transactionDate.toISOString().slice(0, 10), status: r.outstanding ? "Sin fondos" : "Pagado", amount: r.amount.toFixed(2), currency: r.currency, })), totals: totalsOut, subtitle: `Cheque ${checkNumber} · ${rows.length} movimientos`, }; }, }; /* ------------------------------------------------------------------ export */ export const REPORTS: ReportDef[] = [ listadoEnRojo, pagosNoEfectuados, faltantes, reporteDeEfectivo, vigente, avisoRenovacion, edoCuentaDatos, chequeCount, ]; export function findReport(slug: string): ReportDef | undefined { return REPORTS.find((r) => r.slug === slug); }