Files
jorgecuadros-platform/apps/api/src/reports/reports.registry.ts
T
rmancinasandClaude Opus 5 bc749055e7
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
feat(statements): scope the estado de cuenta to the current year, oldest-first
The office's EDO CUENTA sheet has always been a *year* statement: a balance
forward line dated January 1st, then that year's movements in the order they
happened. Both of ours read the other way — every year the customer ever had,
newest first — so staff comparing the screen against the printed sheet were
reading two different documents.

Movements are now bounded to the calendar year and returned ascending, on the
screen (/estado-cuenta/[id]) and in the printable `edo-cuenta-datos` report
alike.

Earlier rows are dropped from the *list*, not from the arithmetic. The balance
floor normally lands on January 1st already, so for most customers nothing
extra is dropped at all; when it doesn't — a customer the last legacy publish
skipped, or one that never had an opening balance — the earlier rows are
folded into a carried balance and shown as a single "saldo anterior" line.
Discarding them instead would restart every balance at zero on January 1st and
nothing would throw; the numbers would just be wrong, which is how the
double-counting bug survived for years. `opening` is exposed per currency and
per business line so the totals still reconcile against the last running
balance printed.

Two things the report was missing on its own are fixed while it is being
touched, since it must agree with the screen to the peso:

  - it never applied the balance floor, so every pre-cutover row was counted
    twice — once inside the opening balance and once as itself;
  - its source-table exclusion used a bare `notIn`, and `NULL NOT IN (...)` is
    NULL rather than true, so every app-captured row (which has no
    legacySourceTable) silently vanished from the printout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:45:15 -07:00

1074 lines
36 KiB
TypeScript

/**
* 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 } 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 <ramo> 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" },
],
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 },
});
const rows = await prisma.transaction.findMany({
where: {
customerId,
voidedAt: null,
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
// drops every app-captured row (they have no legacySourceTable) — the
// same defect this report's on-screen twin was fixed for.
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
],
},
},
],
},
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.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
const year = yearStart.getUTCFullYear();
const running = new Map<string, Prisma.Decimal>();
const opening = new Map<string, Prisma.Decimal>();
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<string, Prisma.Decimal>();
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<string, string | number> = {
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);
}