feat(statements): a year selector, reading each closed year from its archive
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s

The statement has been pinned to the calendar year in progress since bc74905.
Now that prior years are imported, the year becomes a choice: the current one
still reads the live ledger, and any earlier one reads that year's archive.

A period is selected by its `datos2@YYYY` tag, not by a date range. That is how
legacy addressed it — one table per closed year, `SELECT ... FROM `2025`` — and
the distinction is load-bearing: the archives carry rows dated a day or two into
the following January, so a date window would file them under the wrong year in
one direction and drop them in the other.

Two things the archive branch must not inherit:

  - The balance floor. It exists to stop a later opening balance double-counting
    the history it summarizes; for a period view that history is precisely what
    is being asked for, so applying it would return nothing at all.
  - The cash-source exclusion. It reproduces legacy's DATOS2-only datosfreak,
    and an archive is DATOS2 rows already.

No fold into an opening balance either — the archive holds its own Jan-1 BALANCE
FORWARD row, which is the carry, listed exactly as legacy listed it.

The current period stays deliberately open-ended at the top. A period is a table
in legacy, not a date range, so whatever the office filed in it belongs to it,
including the future-dated rows the live ledger carries out to 2028. Bounding it
would hide them from every view.

`availableYears` reports the periods a customer actually has, so the picker never
offers a year that would render empty — "you had no activity in 2019" is a
different claim from "2019 was never imported", and only one of them is true.
The selector hides itself entirely for a customer with a single period, and a
year outside the list is a 404 rather than a silent fall back to the current one.

The same period rule lands on the printable twin (edo-cuenta-datos gains a
"Periodo (año)" parameter) and on the portal, where fetchLedgerRowsPlatform was
also filtering by date with no source exclusion at all — so period=2025 would
have returned the archive rows on top of that year's EFECTIVO receipts, counting
every prior-year payment twice. The portal's allowlist is now built per data
source and validated at the point of use: DreamHost holds the current year plus
one archive table, the platform holds however many were imported, and
fetchLedgerRowsLegacy interpolates the period as a table name, so a
platform-only year must not reach it — including on the fallback path when the
platform is unreachable.

Left alone: the /clientes/:id ledger card still shows the current year. It reads
transactionYear off the customers endpoint rather than the statement, it is a
summary that links to the full statement, and giving it its own year state would
duplicate the page it links to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 15:48:25 -07:00
co-authored by Claude Opus 5
parent 29ae9fa5bc
commit 93f817158e
8 changed files with 262 additions and 57 deletions
+51 -20
View File
@@ -15,7 +15,10 @@
*/
import { Prisma } from "@jorgecuadros/database";
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
import {
BALANCE_FORWARD_TYPE,
periodSourceTable,
} from "../billing/billing.service";
import {
intParam,
NOT_VOIDED,
@@ -759,6 +762,15 @@ const edoCuentaDatos: ReportDef = {
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
@@ -804,28 +816,43 @@ const edoCuentaDatos: ReportDef = {
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,
...(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",
...(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 } } : {}),
// 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: {
@@ -847,8 +874,12 @@ const edoCuentaDatos: ReportDef = {
// 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();
// 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>();