Files
jorgecuadros-platform/apps/api/src/reports/reports.registry.ts
T
rmancinasandClaude Opus 5.5 ca76f4d57a
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m47s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
feat(efectivo): the EFE cash receipt book, folio and posting in one step
The office still issues cash receipts from Access (EFE MENU -> EFE). This is
that form: cliente, fecha, monto + moneda, concepto, elaboró (the logged-in
user), fecha de depósito, and a folio that continues the legacy book.

- cash_receipts, folio unique. Next folio = one past the highest of the
  platform's receipts and the imported utilities EFECTIVO journal, so after
  the cutover import it lands on the office's next number (13935 on prod
  today). A typed folio the legacy book already used is refused.
- Issuing a receipt posts its credit in the same database transaction, as
  legacy posted to DATOS2: reference C<folio>, CASH DEPOSIT, pseudo check
  CASH<MMDD><P|D>. Dollars with a rate credit a pesos ledger (1,500 USD at
  16.80 -> 25,200 MXN, as C13830 did). Voiding that row voids the receipt.
- /efectivo: issue, search, record the deposit date; /efectivo/<folio>
  prints the receipt.
- Reporte de efectivo now lists platform receipts by receipt, not by check,
  so each is counted once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:24:43 -07:00

1288 lines
45 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, type ServiceKind } from "@jorgecuadros/database";
import {
BALANCE_FLOOR_JOIN,
BALANCE_FORWARD_TYPE,
balanceScopeSql,
NOT_SUPERSEDED,
notCashJournal,
periodSourceTable,
} from "../billing/billing.service";
import { UTILITIES_TABLE } from "../customers/numid.service";
import type { PrismaService } from "../prisma/prisma.service";
import { CONCEPT_BY_KIND } from "../statements/statements.service";
import {
type ColumnDef,
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;
}
const isoDay = (d: Date | null | undefined) =>
d ? d.toISOString().slice(0, 10) : "—";
/** Every report leads with the customer's shelf folder number (2026-10 meeting). */
const FOLDER_COLUMN: ColumnDef = { key: "folder", label: "Carpeta", type: "text" };
/** Policy reports add the insurance file number beside it. */
const INSURANCE_NO_COLUMN: ColumnDef = {
key: "insuranceNo",
label: "No. seguros",
type: "text",
};
/**
* Customer numbers for a page of report rows. The office files, captures and
* talks in folder numbers, not platform ids.
*
* The insurance file number is the legacy insurance/DATGRAL id, a separate id
* space under the same table name as the utilities NUMid; 16 customers hold
* more than one.
*/
async function legacyNumbers(prisma: PrismaService, customerIds: string[]) {
const ids = [...new Set(customerIds)];
const [customers, refs] = ids.length
? await Promise.all([
prisma.customer.findMany({
where: { id: { in: ids } },
select: { id: true, folderNumber: true },
}),
prisma.customerLegacyRef.findMany({
where: {
customerId: { in: ids },
sourceTable: UTILITIES_TABLE,
sourceSystem: "insurance",
},
select: { customerId: true, legacyId: true },
}),
])
: [[], []];
const folders = new Map(customers.map((c) => [c.id, c.folderNumber]));
const insurance = new Map<string, string[]>();
for (const r of refs) {
insurance.set(r.customerId, [...(insurance.get(r.customerId) ?? []), r.legacyId]);
}
return {
folder: (id: string) => String(folders.get(id) ?? "—"),
insurance: (id: string) =>
insurance
.get(id)
?.sort((a, b) => Number(a) - Number(b))
.join(", ") || "—",
};
}
/* ------------------------------------------------------------------ reports */
/**
* LISTADO EN ROJO — overdue customers worklist.
*
* Legacy printed it from `ALFABETICO EN ROJO`: `SUM(CHARGECREDIT) < 0` over
* DATOS2, by name. That sum was only honest because DATOS2 held one year plus
* its BALANCE FORWARD. The platform keeps every year, so the balance here is
* the statement's own — the floor, the cash journal and the archive window via
* the same fragments `BillingService.balances()` uses. Summed raw, as this
* report used to, 195 customers read in the red against 22 real ones: every
* closed account whose history the opening balance had already settled.
*
* Ordered by the most recent real movement (Jorge, 2026-10): the oldest
* debtors are the accounts that closed, the ones worth chasing are recent.
* Inactive customers (`status = 0` — the office's own and internal accounts,
* e.g. TRASPASOS PAYPAL at -12M) stay out unless asked for.
*/
const listadoEnRojo: ReportDef = {
slug: "listado-en-rojo",
title: "Clientes en rojo",
description:
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada — " +
"el mismo saldo de su estado de cuenta — ordenados por el movimiento " +
"más reciente. Los clientes inactivos quedan fuera salvo que se pidan.",
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: "domain",
label: "Ramo",
kind: "select",
options: [
{ value: "ALL", label: "Todos" },
{ value: "UTILITY", label: "Servicios" },
{ value: "INSURANCE", label: "Seguros" },
],
defaultValue: "ALL",
},
{ key: "since", label: "Con movimiento desde", kind: "date" },
{
key: "minOwed",
label: "Adeudo mínimo",
kind: "number",
placeholder: "0",
},
{
key: "inactive",
label: "Clientes inactivos",
kind: "select",
options: [
{ value: "no", label: "Excluir" },
{ value: "yes", label: "Incluir" },
],
defaultValue: "no",
},
{
key: "sort",
label: "Orden",
kind: "select",
options: [
{ value: "recent", label: "Movimiento más reciente" },
{ value: "debt", label: "Mayor adeudo" },
{ value: "name", label: "Nombre" },
],
defaultValue: "recent",
},
{
key: "query",
label: "Buscar (nombre o ciudad)",
kind: "text",
placeholder: "Ej. Pérez, Tijuana…",
},
],
columns: [
FOLDER_COLUMN,
{ key: "name", label: "Cliente", type: "text" },
{ key: "city", label: "Ciudad", type: "text" },
{ key: "lastMovement", label: "Último movimiento", type: "date" },
{ key: "lastPayment", label: "Último abono", type: "date" },
{ key: "balance", label: "Saldo", type: "money", align: "right" },
],
async run(prisma, p) {
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
const q = p.query?.trim();
const since = parseDate(p.since);
const minOwed = Math.max(Number(p.minOwed) || 0, 0.005);
const filters: Prisma.Sql[] = [];
if (q) {
filters.push(Prisma.sql`(c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`);
}
if (p.domain === "UTILITY" || p.domain === "INSURANCE") {
filters.push(Prisma.sql`t.domain = ${p.domain}`);
}
if (p.inactive !== "yes") {
filters.push(Prisma.sql`c.status = 1 AND c.archivedAt IS NULL`);
}
const where = filters.length
? Prisma.sql`AND ${Prisma.join(filters, " AND ")}`
: 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)`;
// The Jan 1 BALANCE FORWARD is not a movement — every customer has one, so
// counting it would date every closed account to this year.
const real = Prisma.sql`(tt.nameEn IS NULL OR tt.nameEn <> ${BALANCE_FORWARD_TYPE})`;
const lastMovement = Prisma.sql`MAX(CASE WHEN ${real} THEN t.transactionDate END)`;
const orderBy =
p.sort === "debt"
? Prisma.sql`ORDER BY balance ASC`
: p.sort === "name"
? Prisma.sql`ORDER BY c.nameMissing ASC, c.name ASC`
: Prisma.sql`ORDER BY lastMovement DESC, balance ASC`;
const rows = await prisma.$queryRaw<
Array<{
id: string;
name: string;
nameMissing: boolean;
city: string | null;
balance: Prisma.Decimal | null;
lastMovement: Date | null;
lastPayment: Date | null;
}>
>`
SELECT c.id, c.name, c.nameMissing, c.city,
${bal} AS balance,
${lastMovement} AS lastMovement,
MAX(CASE WHEN ${real} AND t.amount > 0 THEN t.transactionDate END) AS lastPayment
FROM customers c
JOIN transactions t ON t.customerId = c.id
LEFT JOIN type_transactions tt ON tt.id = t.typeId
${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED}
${balanceScopeSql()} ${where}
GROUP BY c.id, c.name, c.nameMissing, c.city
HAVING ${bal} < ${-minOwed}
${since ? Prisma.sql`AND ${lastMovement} >= ${since}` : Prisma.empty}
${orderBy}
`;
const nums = await legacyNumbers(prisma, rows.map((r) => r.id));
let totalBalance = new Prisma.Decimal(0);
const out = rows.map((r) => {
const b = r.balance ?? new Prisma.Decimal(0);
totalBalance = totalBalance.plus(b);
return {
folder: nums.folder(r.id),
name: nameOf(r),
city: r.city ?? "—",
lastMovement: isoDay(r.lastMovement),
lastPayment: isoDay(r.lastPayment),
balance: b.toFixed(2),
};
});
return {
rows: out,
totals: {
customers: out.length,
balance: totalBalance.toFixed(2),
currency,
},
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor${
p.inactive === "yes" ? " · incluye inactivos" : ""
}`,
};
},
};
/** The services whose bill the office pays and books as a ledger concept. */
const SERVICE_OPTIONS: { value: ServiceKind; label: string }[] = [
{ value: "WATER", label: "Agua" },
{ value: "ELECTRIC", label: "Luz" },
{ value: "TELEPHONE", label: "Teléfono" },
{ value: "GAS", label: "Gas" },
{ value: "PROPERTY_TAX", label: "Predial" },
{ value: "FEDERAL_ZONE", label: "Zona federal" },
{ value: "CABLE", label: "Cable" },
];
const serviceLabel = (kind: string) =>
SERVICE_OPTIONS.find((o) => o.value === kind)?.label ?? kind;
/**
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL / …).
*
* Legacy `PAGO AGUA/LUZ/TEL NO`: every customer with the service, LEFT JOIN
* `LOCALIZA PAGO *` — the rows of THAT service's concept dated in the period
* the operator typed — where nothing was found. So: "whose water did we not
* pay this month". The first port asked a different question ("no charge of
* any kind in N days"), which is why customers who were up to date kept
* showing: one IZZI or annual-fee row hid a missing water bill, and the
* reverse. It also passed `ELECTRICITY`/`PHONE`, which are not ServiceKind
* values, so Luz and Teléfono never returned a row at all.
*
* Matched per customer, as legacy did. A charge row carries no link to the
* service it paid, and its reference only matches the stored account for
* water and phone (813/822, 501/521) — CFE bills post under a reference that
* is not the RPU (5 of 1109) — so matching on the reference would list every
* electric customer as unpaid.
*
* A row captured "sin fondos" in the window is not a payment: the customer is
* listed with that status, which is the follow-up the office actually owes.
*/
const pagosNoEfectuados: ReportDef = {
slug: "pagos-no-efectuados",
title: "Pagos no efectuados",
description:
"Clientes con el servicio contratado sin un pago de ese servicio " +
"registrado en el periodo. Sin fechas, revisa un ciclo hacia atrás " +
"desde hoy: 31 días, o 62 para luz (bimestral).",
domain: "servicios",
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
format: "tabular",
params: [
{
key: "serviceKind",
label: "Servicio",
kind: "select",
options: SERVICE_OPTIONS,
defaultValue: "WATER",
},
{ key: "from", label: "Desde (por defecto, un ciclo atrás)", kind: "date" },
{ key: "to", label: "Hasta (por defecto, hoy)", kind: "date", endOfDay: true },
{
key: "inactive",
label: "Clientes inactivos",
kind: "select",
options: [
{ value: "no", label: "Excluir" },
{ value: "yes", label: "Incluir" },
],
defaultValue: "no",
},
],
columns: [
FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "accountNumber", label: "Cuenta", type: "text" },
{ key: "status", label: "Estado", type: "text" },
{ key: "lastPaid", label: "Último pago del servicio", type: "date" },
],
async run(prisma, p) {
const kind = (
SERVICE_OPTIONS.some((o) => o.value === p.serviceKind)
? p.serviceKind
: "WATER"
) as ServiceKind;
const concept = CONCEPT_BY_KIND[kind]!;
// No period given: one billing cycle back from the end date, so the
// report means the same on the 2nd as on the 28th. "This month" listed
// nearly everyone early in the month — the bills are captured ~20th.
const to = parseDate(p.to, true) ?? new Date();
const cycleDays = kind === "ELECTRIC" ? 62 : 31;
const from =
parseDate(p.from) ?? new Date(to.getTime() - cycleDays * 86400000);
const inactive =
p.inactive === "yes"
? Prisma.empty
: Prisma.sql`AND c.status = 1`;
// One concept row for the customer, not voided, in the window.
const conceptRow = (outstanding: 0 | 1) => Prisma.sql`
SELECT 1 FROM transactions t
JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.outstanding = ${outstanding} AND tt.nameEn = ${concept}
AND t.transactionDate >= ${from} AND t.transactionDate <= ${to}`;
const rows = await prisma.$queryRaw<
Array<{
customerId: string;
customerName: string;
nameMissing: boolean;
addresses: string | null;
accounts: string | null;
unfunded: bigint | number;
lastPaid: Date | null;
}>
>`
SELECT
c.id AS customerId,
c.name AS customerName,
c.nameMissing AS nameMissing,
GROUP_CONCAT(DISTINCT pr.addressLine1 ORDER BY pr.addressLine1 SEPARATOR ' / ') AS addresses,
GROUP_CONCAT(DISTINCT NULLIF(ps.accountNumber, '') SEPARATOR ', ') AS accounts,
EXISTS(${conceptRow(1)}) AS unfunded,
(SELECT MAX(t.transactionDate) FROM transactions t
JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid
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 ${inactive}
AND NOT EXISTS (${conceptRow(0)})
GROUP BY c.id, c.name, c.nameMissing
ORDER BY c.nameMissing ASC, c.name ASC
`;
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
let unfunded = 0;
const out = rows.map((r) => {
if (Number(r.unfunded)) unfunded++;
return {
folder: nums.folder(r.customerId),
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
propertyAddress: r.addresses ?? "—",
accountNumber: r.accounts ?? "—",
status: Number(r.unfunded) ? "Sin fondos" : "Sin pago",
lastPaid: isoDay(r.lastPaid),
};
});
return {
rows: out,
totals: {
clientes: out.length,
"sin pago": out.length - unfunded,
"sin fondos": unfunded,
},
subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`,
};
},
};
/**
* What each service must have on file to be paid and matched — the columns
* legacy's FALTANTES DE AGUA/LUZ/TEL read (DATMEX.AGUA; MEDIDOR + RPU;
* TELEFONO), in the field `scopedRefField` stores each one in. The first port
* demanded account AND meter AND route of every kind; water and phone have no
* route, so every one of them was flagged.
*/
const REQUIRED_FIELDS: Partial<
Record<ServiceKind, Array<["accountNumber" | "meterNumber", string]>>
> = {
WATER: [["accountNumber", "Sin número de cuenta"]],
ELECTRIC: [
["accountNumber", "Sin RPU"],
["meterNumber", "Sin medidor"],
],
TELEPHONE: [["accountNumber", "Sin número de teléfono"]],
};
/**
* FALTANTES DE (AGUA / LUZ / TEL) — data quality: active services missing the
* identifier the office needs to pay and match the bill.
*/
const faltantes: ReportDef = {
slug: "faltantes",
title: "Faltantes de datos por servicio",
description:
"Calidad de datos: servicios activos sin el dato que se necesita para " +
"pagar el recibo — agua: número de cuenta; luz: RPU y medidor; " +
"teléfono: número.",
domain: "servicios",
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
format: "tabular",
params: [
{
key: "serviceKind",
label: "Servicio",
kind: "select",
options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]),
defaultValue: "WATER",
},
],
columns: [
FOLDER_COLUMN,
{ key: "customerName", label: "Cliente", type: "text" },
{ key: "propertyAddress", label: "Dirección", type: "text" },
{ key: "missing", label: "Faltante", type: "text" },
],
async run(prisma, p) {
const kind = (
p.serviceKind && REQUIRED_FIELDS[p.serviceKind as ServiceKind]
? p.serviceKind
: "WATER"
) as ServiceKind;
const required = REQUIRED_FIELDS[kind]!;
// Column names come from the constant above, never from the request.
const blank = (field: string) =>
Prisma.sql`(ps.${Prisma.raw(field)} IS NULL OR TRIM(ps.${Prisma.raw(field)}) = '')`;
const rows = await prisma.$queryRaw<
Array<{
customerId: string;
customerName: string;
nameMissing: boolean;
propertyAddress: string | null;
missing: string;
}>
>`
SELECT
c.id AS customerId,
c.name AS customerName,
c.nameMissing AS nameMissing,
pr.addressLine1 AS propertyAddress,
CONCAT_WS(', ', ${Prisma.join(
required.map(([field, label]) => Prisma.sql`IF(${blank(field)}, ${label}, NULL)`),
)}) 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 c.status = 1
AND (${Prisma.join(required.map(([field]) => blank(field)), " OR ")})
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
`;
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
const out = rows.map((r) => ({
folder: nums.folder(r.customerId),
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
propertyAddress: r.propertyAddress ?? "—",
missing: r.missing,
}));
return {
rows: out,
totals: { rows: out.length },
subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios 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: los recibos EFE de la " +
"plataforma, los 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" },
FOLDER_COLUMN,
{ 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 },
// A credit opening balance has no check number but is not cash.
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
{
OR: [
// A platform receipt posts one row (its C<folio> credit, under a
// CASH… pseudo check), so it is matched by the receipt, not the check.
{ cashReceipt: { isNot: null } },
{ 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,
cashReceipt: { select: { folio: true } },
type: { select: { nameEs: true, nameEn: true } },
customer: { select: { id: true, name: true, nameMissing: true } },
},
});
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
let total = new Prisma.Decimal(0);
const out = rows.map((r) => {
total = total.plus(r.amount);
return {
date: r.transactionDate.toISOString().slice(0, 10),
folder: nums.folder(r.customer.id),
customerName: nameOf(r.customer),
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
source: r.cashReceipt ? `EFE ${r.cashReceipt.folio}` : 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" },
FOLDER_COLUMN,
INSURANCE_NO_COLUMN,
{ 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: { id: true, name: true, nameMissing: true } },
insuranceProvider: { select: { name: true } },
},
});
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
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,
folder: nums.folder(r.customer.id),
insuranceNo: nums.insurance(r.customer.id),
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" },
FOLDER_COLUMN,
INSURANCE_NO_COLUMN,
{ 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),
});
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
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),
folder: nums.folder(r.customer.id),
insuranceNo: nums.insurance(r.customer.id),
};
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" };
const folder = (await legacyNumbers(prisma, [customer.id])).folder(customer.id);
// 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<folio>` 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<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),
folder,
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: `Carpeta ${folder} · ${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: [
FOLDER_COLUMN,
{ 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: { id: true, 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;
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
return {
rows: rows.map((r) => ({
folder: nums.folder(r.customer.id),
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);
}