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>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
|
||||
import {
|
||||
intParam,
|
||||
NOT_VOIDED,
|
||||
@@ -751,8 +752,8 @@ const edoCuentaDatos: ReportDef = {
|
||||
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.",
|
||||
"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",
|
||||
@@ -789,22 +790,42 @@ const edoCuentaDatos: ReportDef = {
|
||||
});
|
||||
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].
|
||||
// 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,
|
||||
legacySourceTable: {
|
||||
notIn: [
|
||||
"EFECTIVO",
|
||||
"EFECTIVO_BACKUP",
|
||||
"EFECTIVO FM3",
|
||||
"CHEQUE FM3",
|
||||
"IVA 2015",
|
||||
],
|
||||
},
|
||||
...(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: {
|
||||
@@ -822,12 +843,28 @@ const edoCuentaDatos: ReportDef = {
|
||||
},
|
||||
});
|
||||
|
||||
// Compute running balance per currency, then return newest-first.
|
||||
// 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 movements = rows.map((r) => {
|
||||
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,
|
||||
@@ -840,14 +877,38 @@ const edoCuentaDatos: ReportDef = {
|
||||
balanceAfter: next.toFixed(2),
|
||||
};
|
||||
});
|
||||
movements.reverse();
|
||||
|
||||
// Per-currency summary + per-domain breakdown.
|
||||
// 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 r of rows) {
|
||||
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) ??
|
||||
{
|
||||
@@ -881,9 +942,10 @@ const edoCuentaDatos: ReportDef = {
|
||||
count: c.count,
|
||||
})),
|
||||
{ __kind: "movements-header" },
|
||||
...carried,
|
||||
...movements,
|
||||
],
|
||||
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
|
||||
subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user