Files
jorgecuadros-platform/apps/api/src/reports/reports.registry.ts
T
rmancinasandClaude Sonnet 5 f7ae0d5342
Build and Push Images / Build jorgecuadros-web (push) Failing after 59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m59s
feat(reports): parameterized renewal-notice report + legacy report reference
Replaces ~40 legacy Access renewal-notice report clones (one per carrier
per coverage tier, e.g. AMPL/RC/LIC RENEW X MES/VENCE ATLAS 13/2013) with
one parameterized aviso-renovacion report driven by real Policy/Vehicle/
coveragesJson data instead of hand-typed label text per clone.

- schema.prisma: add RenewalNotice, replacing the legacy CONTROL <ramo>
  RENEW[2/3] X MES paper log of which notice generation was sent
- reports: new "letter" ReportFormat + aviso-renovacion registry entry +
  LetterLayout renderer in ReportRunner.tsx
- docs/RENEWAL_NOTICES.md + migration/legacy_report_defs/: extracted (via
  Application.SaveAsText, since the VBA project wouldn't load) and
  documented the legacy report/query chain this replaces

Coveragesjson key names and a mark-as-sent mutation are still unverified/
unbuilt — see caveats in docs/RENEWAL_NOTICES.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:05:21 -07:00

967 lines
32 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 {
intParam,
NOT_VOIDED,
parseDate,
type ReportDef,
} from "./reports.types";
/* ------------------------------------------------------------------ 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
* — but this report is read-only; marking a notice as sent is a separate
* mutation (not yet built) that would upsert `RenewalNotice` by
* `[policyId, generation]`.
*/
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: {
id: true,
policyNumber: true,
policyTo: true,
netPremium: true,
policyFee: true,
total: true,
currency: true,
coveragesJson: true,
customer: { select: { name: true, nameMissing: true } },
insuranceProvider: { select: { name: true } },
vehicles: {
take: 1,
select: {
make: true,
model: true,
modelYear: true,
bodyType: true,
engineNumber: true,
licensePlate: true,
},
},
renewalNotices: {
where: { generation },
select: { sentAt: true, channel: true },
},
},
});
let totalPremium = new Prisma.Decimal(0);
let sentCount = 0;
const out = rows.map((r) => {
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
const notice = r.renewalNotices[0];
if (notice?.sentAt) sentCount++;
// Legacy coverage columns not modeled as first-class Policy fields —
// see docs/RENEWAL_NOTICES.md's column-mapping table. Keys are best-
// effort (derived from the source schema, not yet verified against a
// live migrated DB) — confirm before relying on them in production.
const cov = (r.coveragesJson ?? {}) as Record<string, unknown>;
return {
__kind: "letter",
policyId: r.id,
policyNumber: r.policyNumber,
customerName: nameOf(r.customer),
provider: r.insuranceProvider?.name ?? "—",
policyTo: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
netPremium: r.netPremium ? r.netPremium.toFixed(2) : null,
policyFee: r.policyFee ? r.policyFee.toFixed(2) : null,
total: r.total ? r.total.toFixed(2) : null,
currency: r.currency,
coverageDays: cov.cobertura ?? null,
cslLimit: cov.csl_limite ?? null,
medicalCoverage: cov.gastos_medico ?? null,
propertyDamage: cov.propiedades ?? null,
perPersonLiability: cov.personas ?? null,
additionalService: cov.servicio_adicional ?? cov.servicio_adiconal ?? null,
vehicle: r.vehicles[0]
? {
make: r.vehicles[0].make,
model: r.vehicles[0].model,
modelYear: r.vehicles[0].modelYear,
bodyType: r.vehicles[0].bodyType,
engineNumber: r.vehicles[0].engineNumber,
licensePlate: r.vehicles[0].licensePlate,
}
: null,
generation,
sentAt: notice?.sentAt
? notice.sentAt.toISOString().slice(0, 10)
: null,
};
});
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 el historial completo de movimientos con saldo " +
"corrido. 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" };
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
// as BillingService.statement so the numbers match what the customer
// already sees in /estado-cuenta/[id].
const rows = await prisma.transaction.findMany({
where: {
customerId,
voidedAt: 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 } },
},
});
// Compute running balance per currency, then return newest-first.
const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => {
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
const next = prev.plus(r.amount);
running.set(r.currency, next);
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),
};
});
movements.reverse();
// Per-currency summary + per-domain breakdown.
const perCurrency = new Map<
string,
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
>();
for (const r of rows) {
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" },
...movements,
],
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
};
},
};
/* ------------------------------------------------------------------ export */
export const REPORTS: ReportDef[] = [
listadoEnRojo,
pagosNoEfectuados,
faltantes,
reporteDeEfectivo,
vigente,
avisoRenovacion,
edoCuentaDatos,
];
export function findReport(slug: string): ReportDef | undefined {
return REPORTS.find((r) => r.slug === slug);
}