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
+16 -5
View File
@@ -115,13 +115,26 @@ describe("balance floor", () => {
); );
}); });
/**
* statement() issues two findMany calls: first the period discovery (which
* years this customer has an archive for), then the statement rows. Select
* the rows query by its shape so adding another lookup later moves nothing
* here — the previous version indexed call 0 and broke the moment period
* support landed.
*/
function rowsQuery(findMany: jest.Mock) {
const call = findMany.mock.calls.find((c) => c[0]?.orderBy);
if (!call) throw new Error("statement() issued no ordered rows query");
return call[0];
}
it("bounds the statement at the floor, inclusive", async () => { it("bounds the statement at the floor, inclusive", async () => {
const floor = new Date("2026-01-01T00:00:00Z"); const floor = new Date("2026-01-01T00:00:00Z");
const { service, findMany } = serviceWith(floor); const { service, findMany } = serviceWith(floor);
await service.statement("c1"); await service.statement("c1");
expect(findMany.mock.calls[0][0].where).toMatchObject({ expect(rowsQuery(findMany).where).toMatchObject({
customerId: "c1", customerId: "c1",
transactionDate: { gte: floor }, transactionDate: { gte: floor },
}); });
@@ -132,9 +145,7 @@ describe("balance floor", () => {
await service.statement("c1"); await service.statement("c1");
expect(findMany.mock.calls[0][0].where).not.toHaveProperty( expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate");
"transactionDate",
);
}); });
it("keeps the source-table exclusion alongside the floor", async () => { it("keeps the source-table exclusion alongside the floor", async () => {
@@ -145,7 +156,7 @@ describe("balance floor", () => {
await service.statement("c1"); await service.statement("c1");
const where = findMany.mock.calls[0][0].where; const where = rowsQuery(findMany).where;
expect(where.OR).toEqual([ expect(where.OR).toEqual([
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } }, { legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
+16 -3
View File
@@ -119,10 +119,23 @@ export class BillingController {
return this.billing.byCheck(n); return this.billing.byCheck(n);
} }
/** One customer's full statement across both business lines. */ /**
* One customer's statement across both business lines, for one period.
*
* `year` omitted means the current one. Any earlier year is served from its
* imported archive; the response carries `availableYears` so the caller can
* offer only the periods this customer actually has.
*/
@Get("customers/:id") @Get("customers/:id")
statement(@Param("id") id: string) { statement(@Param("id") id: string, @Query("year") year?: string) {
return this.billing.statement(id); let parsed: number | undefined;
if (year !== undefined && year !== "") {
parsed = Number(year);
if (!Number.isInteger(parsed)) {
throw new BadRequestException("year debe ser un año de cuatro dígitos");
}
}
return this.billing.statement(id, parsed);
} }
/** Cross-customer movement browser. */ /** Cross-customer movement browser. */
+93 -21
View File
@@ -221,6 +221,20 @@ export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transact
* browser keep them — they're real money, just tracked separately * browser keep them — they're real money, just tracked separately
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream). * (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
*/ */
/**
* `legacySourceTable` of an imported prior period.
*
* A closed year arrives as its own Access snapshot and is tagged rather than
* dated (see migration/transform_transactions.py). The tag is what a period
* view filters on: the archives are not cleanly date-bounded — 2025 carries
* rows dated into 2026 — and legacy did not filter by date either, it selected
* `FROM \`2025\``. Filtering on provenance reproduces the legacy period exactly.
*/
export const periodSourceTable = (year: number) => `datos2@${year}`;
/** Matches any imported period tag, for discovering which years a customer has. */
const PERIOD_TABLE_PREFIX = "datos2@";
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
"EFECTIVO", "EFECTIVO",
"EFECTIVO_BACKUP", "EFECTIVO_BACKUP",
@@ -701,16 +715,22 @@ export class BillingService {
/** /**
* One customer's statement across both business lines. * One customer's statement across both business lines.
* *
* Scoped to the current calendar year and listed oldest-first, matching the * Scoped to one calendar year and listed oldest-first, matching the legacy
* legacy EDO CUENTA report the office has printed for years: an opening * EDO CUENTA report the office has printed for years: an opening balance at
* balance at the top, then the year's movements in the order they happened. * the top, then the year's movements in the order they happened.
*
* `year` selects the period. The current year is read from the live tables;
* any earlier year is read from its imported archive, which legacy kept as a
* separate table and this reads by its `datos2@YYYY` tag. `availableYears`
* reports which periods this customer actually has, so a caller never offers
* a year that would render empty.
* *
* Returns the *whole* year rather than a page of it: the heaviest customer * Returns the *whole* year rather than a page of it: the heaviest customer
* carries 365 movements (mean 26), and a running balance is meaningless if * carries 365 movements (mean 26), and a running balance is meaningless if
* the client only holds a slice. The running balance is accumulated per * the client only holds a slice. The running balance is accumulated per
* currency in chronological order, with each row's balance-after attached. * currency in chronological order, with each row's balance-after attached.
*/ */
async statement(customerId: string) { async statement(customerId: string, year?: number) {
const customer = await this.prisma.customer.findUnique({ const customer = await this.prisma.customer.findUnique({
where: { id: customerId }, where: { id: customerId },
select: { select: {
@@ -734,6 +754,36 @@ export class BillingService {
throw new NotFoundException(`Customer ${customerId} not found`); throw new NotFoundException(`Customer ${customerId} not found`);
} }
// Which periods this customer has. The current year is always offered —
// it is the live ledger even when empty — and each imported archive adds
// the year it holds.
const archives = await this.prisma.transaction.findMany({
where: {
customerId,
voidedAt: null,
legacySourceTable: { startsWith: PERIOD_TABLE_PREFIX },
},
distinct: ["legacySourceTable"],
select: { legacySourceTable: true },
});
const thisYear = new Date().getUTCFullYear();
const archiveYears = archives
.map((a) => Number(a.legacySourceTable?.slice(PERIOD_TABLE_PREFIX.length)))
.filter((y) => Number.isInteger(y) && y < thisYear);
const availableYears = [...new Set([thisYear, ...archiveYears])].sort(
(a, b) => b - a,
);
// An unknown year would silently render as the current one, which reads as
// "this customer had no activity in 2019" rather than "there is no 2019".
const requested = year ?? thisYear;
if (!availableYears.includes(requested)) {
throw new NotFoundException(
`El cliente no tiene movimientos del periodo ${requested}.`,
);
}
const isArchive = requested !== thisYear;
// One customer, so the balance floor is a single date rather than the // One customer, so the balance floor is a single date rather than the
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before // derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
// the opening balance are already inside it, and showing them would both // the opening balance are already inside it, and showing them would both
@@ -759,21 +809,31 @@ export class BillingService {
const rows = await this.prisma.transaction.findMany({ const rows = await this.prisma.transaction.findMany({
where: { where: {
customerId, customerId,
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), ...(isArchive
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and ? // An archive is already exactly one period's ledger, so the tag is
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row // the whole filter. The balance floor is deliberately NOT applied:
// (which has no legacySourceTable) silently vanished from the // it exists to stop a later opening balance double-counting the
// statement while still showing in the movement browser. Rows the app // history it summarizes, and here that history is the thing being
// books must appear on the customer's statement, so the null case is // asked for. The exclusion list is moot too — an archive holds only
// spelled out. // DATOS2 rows, which is what legacy's year table held.
OR: [ { legacySourceTable: periodSourceTable(requested) }
{ legacySourceTable: null }, : {
{ ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
legacySourceTable: { // NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[], // `NULL NOT IN (...)` is NULL, not true — so every app-captured row
}, // (which has no legacySourceTable) silently vanished from the
}, // statement while still showing in the movement browser. Rows the app
], // books must appear on the customer's statement, so the null case is
// spelled out.
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
},
},
],
}),
}, },
orderBy: [{ transactionDate: "asc" }, { id: "asc" }], orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: { select: {
@@ -800,7 +860,18 @@ export class BillingService {
// opening balance), the earlier rows still have to be *counted* or every // opening balance), the earlier rows still have to be *counted* or every
// balance below is wrong, so they are folded into `opening` rather than // balance below is wrong, so they are folded into `opening` rather than
// listed. That is the same thing a BALANCE FORWARD row does, just computed. // listed. That is the same thing a BALANCE FORWARD row does, just computed.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); //
// 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 current ledger carries (it runs to 2028). An
// upper bound would hide them from every view, which is not what legacy did
// and not what the office has been reading.
//
// An archive needs no fold at all: it *is* the period, and its own Jan-1
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
const yearStart = isArchive
? new Date(0)
: new Date(Date.UTC(requested, 0, 1));
const running = new Map<string, Prisma.Decimal>(); const running = new Map<string, Prisma.Decimal>();
/** Balance carried into `yearStart`, per currency. */ /** Balance carried into `yearStart`, per currency. */
@@ -976,7 +1047,8 @@ export class BillingService {
propertyCount: customer._count.properties, propertyCount: customer._count.properties,
policyCount: customer._count.policies, policyCount: customer._count.policies,
}, },
year: yearStart.getUTCFullYear(), year: requested,
availableYears,
summary: [...perCurrency.values()].map((c) => { summary: [...perCurrency.values()].map((c) => {
const open = opening.get(c.currency) ?? new Prisma.Decimal(0); const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
return { return {
@@ -0,0 +1,36 @@
import { periodSourceTable } from "./billing.service";
/**
* A closed year is imported as its own tagged set of rows rather than being
* identified by date. The tag is written by migration/transform_transactions.py
* and read by BillingService.statement, the edo-cuenta-datos report, and the
* PHP portal — three places that must agree on the exact string.
*/
describe("periodSourceTable", () => {
it("names the archive the migration writes", () => {
expect(periodSourceTable(2025)).toBe("datos2@2025");
expect(periodSourceTable(2024)).toBe("datos2@2024");
});
it("stays distinct from the live ledger's own table", () => {
// The live table is plain `datos2`. legacyId is a positional ordinal that
// restarts at 0 in every archive, so a shared name would collide with the
// current year row-for-row on the unique key.
expect(periodSourceTable(2025)).not.toBe("datos2");
expect(periodSourceTable(2025).startsWith("datos2@")).toBe(true);
});
it("is not matched by the statement's cash-source exclusion list", () => {
// STATEMENT_EXCLUDED_SOURCE_TABLES drops the EFECTIVO family to reproduce
// legacy's DATOS2-only datosfreak. An archive holds DATOS2 rows, so it must
// survive that filter or a prior year renders empty.
const excluded = [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
];
expect(excluded).not.toContain(periodSourceTable(2025));
});
});
+51 -20
View File
@@ -15,7 +15,10 @@
*/ */
import { Prisma } from "@jorgecuadros/database"; import { Prisma } from "@jorgecuadros/database";
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service"; import {
BALANCE_FORWARD_TYPE,
periodSourceTable,
} from "../billing/billing.service";
import { import {
intParam, intParam,
NOT_VOIDED, NOT_VOIDED,
@@ -759,6 +762,15 @@ const edoCuentaDatos: ReportDef = {
format: "statement", format: "statement",
params: [ params: [
{ key: "customerId", label: "Cliente", kind: "customer-picker" }, { 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: [ columns: [
// Statement rows carry synthetic `__kind` discriminators instead of // Statement rows carry synthetic `__kind` discriminators instead of
@@ -804,28 +816,43 @@ const edoCuentaDatos: ReportDef = {
select: { transactionDate: true }, 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({ const rows = await prisma.transaction.findMany({
where: { where: {
customerId, customerId,
voidedAt: null, voidedAt: null,
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), ...(isArchive
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn` ? // The archive is one period's ledger already, so the tag is the
// drops every app-captured row (they have no legacySourceTable) — the // whole filter and the balance floor must not apply — the floor
// same defect this report's on-screen twin was fixed for. // hides exactly the history this period is asking for.
OR: [ { legacySourceTable: periodSourceTable(requestedYear) }
{ legacySourceTable: null }, : {
{ ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
legacySourceTable: { // NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
notIn: [ // drops every app-captured row (they have no legacySourceTable) — the
"EFECTIVO", // same defect this report's on-screen twin was fixed for.
"EFECTIVO_BACKUP", OR: [
"EFECTIVO FM3", { legacySourceTable: null },
"CHEQUE FM3", {
"IVA 2015", legacySourceTable: {
notIn: [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
],
},
},
], ],
}, }),
},
],
}, },
orderBy: [{ transactionDate: "asc" }, { id: "asc" }], orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: { select: {
@@ -847,8 +874,12 @@ const edoCuentaDatos: ReportDef = {
// EDO CUENTA sheet reads. Rows from earlier years still move the running // EDO CUENTA sheet reads. Rows from earlier years still move the running
// balance — they are folded into `opening` and printed as a single "saldo // balance — they are folded into `opening` and printed as a single "saldo
// anterior" line, which is what a BALANCE FORWARD row is. // anterior" line, which is what a BALANCE FORWARD row is.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); // An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE
const year = yearStart.getUTCFullYear(); // 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 running = new Map<string, Prisma.Decimal>();
const opening = new Map<string, Prisma.Decimal>(); const opening = new Map<string, Prisma.Decimal>();
+36 -6
View File
@@ -40,8 +40,10 @@ import type {
* currency at a time — a column that alternated between pesos and dollars would * currency at a time — a column that alternated between pesos and dollars would
* be a meaningless number. * be a meaningless number.
* *
* Like the legacy EDO CUENTA report, the table covers the current year only and * Like the legacy EDO CUENTA report, the table covers one calendar year and runs
* runs oldest-first, opening on the balance carried in from before it. * oldest-first, opening on the balance carried in from before it. The period
* selector switches years; earlier ones are served from the imported archive of
* that year, which is how legacy kept them — one table per closed year.
*/ */
export default function EstadoCuentaDetailPage({ export default function EstadoCuentaDetailPage({
params, params,
@@ -67,19 +69,27 @@ function StatementView({ id }: { id: string }) {
const [currency, setCurrency] = useState<LedgerCurrency | null>(null); const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
const [domain, setDomain] = useState<TransactionDomain | "">(""); const [domain, setDomain] = useState<TransactionDomain | "">("");
/** null = the current period; the API decides what that is. */
const [year, setYear] = useState<number | null>(null);
function reload() { function reload() {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
setError(null); setError(null);
getStatement(id) getStatement(id, year ?? undefined)
.then((d) => { .then((d) => {
if (!alive) return; if (!alive) return;
setData(d); setData(d);
// Default to the currency the customer actually moves the most in; // Default to the currency the customer actually moves the most in;
// preserve a previously-chosen currency across reloads. // preserve a previously-chosen currency across reloads — but only if
// the loaded period still has it. Switching to a year the customer
// never moved dollars in would otherwise leave the picker on USD with
// no matching option, showing an empty table for a year that has rows.
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0]; const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN"); const fallback = busiest?.currency ?? "MXN";
setCurrency((prev) =>
prev && d.summary.some((s) => s.currency === prev) ? prev : fallback,
);
setLoading(false); setLoading(false);
}) })
.catch((e) => { .catch((e) => {
@@ -101,7 +111,7 @@ function StatementView({ id }: { id: string }) {
getBillingFacets().then(setFacets).catch(() => setFacets(null)); getBillingFacets().then(setFacets).catch(() => setFacets(null));
return cleanup; return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); }, [id, year]);
const movements = useMemo(() => { const movements = useMemo(() => {
if (!data || !currency) return []; if (!data || !currency) return [];
@@ -229,6 +239,26 @@ function StatementView({ id }: { id: string }) {
)} )}
<div className="filter-row"> <div className="filter-row">
{/* Only the periods this customer has. A year with no archive would
render an empty table that reads as "no hubo movimientos" when the
truth is that the year was never imported. */}
{data.availableYears.length > 1 && (
<label className="filter-field">
<span className="filter-label">Periodo</span>
<select
className="input select"
value={data.year}
onChange={(e) => setYear(Number(e.target.value))}
>
{data.availableYears.map((y) => (
<option key={y} value={y}>
{y}
{y === data.availableYears[0] ? " (en curso)" : ""}
</option>
))}
</select>
</label>
)}
<label className="filter-field"> <label className="filter-field">
<span className="filter-label">Moneda</span> <span className="filter-label">Moneda</span>
<select <select
+7 -2
View File
@@ -615,8 +615,13 @@ export function getBillingFacets(): Promise<BillingFacets> {
return apiFetch<BillingFacets>("/billing/facets"); return apiFetch<BillingFacets>("/billing/facets");
} }
export function getStatement(customerId: string): Promise<Statement> { /** `year` omitted reads the current period; earlier years come from an archive. */
return apiFetch<Statement>(`/billing/customers/${customerId}`); export function getStatement(
customerId: string,
year?: number,
): Promise<Statement> {
const q = year === undefined ? "" : `?year=${year}`;
return apiFetch<Statement>(`/billing/customers/${customerId}${q}`);
} }
/** Append a new ledger movement. Booked movements are never edited — fix /** Append a new ledger movement. Booked movements are never edited — fix
+7
View File
@@ -1096,6 +1096,13 @@ export interface Statement {
}; };
/** Calendar year the statement covers; movements are scoped to it. */ /** Calendar year the statement covers; movements are scoped to it. */
year: number; year: number;
/**
* Periods this customer actually has, newest first. The current year is
* always present; each earlier year comes from an imported archive. Offering
* anything outside this list would render an empty statement that reads as
* "no activity" rather than "not imported".
*/
availableYears: number[];
summary: StatementSummary[]; summary: StatementSummary[];
byDomain: StatementDomainRow[]; byDomain: StatementDomainRow[];
byType: StatementTypeRow[]; byType: StatementTypeRow[];