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
+36 -6
View File
@@ -40,8 +40,10 @@ import type {
* currency at a time — a column that alternated between pesos and dollars would
* be a meaningless number.
*
* Like the legacy EDO CUENTA report, the table covers the current year only and
* runs oldest-first, opening on the balance carried in from before it.
* Like the legacy EDO CUENTA report, the table covers one calendar year and runs
* 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({
params,
@@ -67,19 +69,27 @@ function StatementView({ id }: { id: string }) {
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
const [domain, setDomain] = useState<TransactionDomain | "">("");
/** null = the current period; the API decides what that is. */
const [year, setYear] = useState<number | null>(null);
function reload() {
let alive = true;
setLoading(true);
setError(null);
getStatement(id)
getStatement(id, year ?? undefined)
.then((d) => {
if (!alive) return;
setData(d);
// 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];
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);
})
.catch((e) => {
@@ -101,7 +111,7 @@ function StatementView({ id }: { id: string }) {
getBillingFacets().then(setFacets).catch(() => setFacets(null));
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
}, [id, year]);
const movements = useMemo(() => {
if (!data || !currency) return [];
@@ -229,6 +239,26 @@ function StatementView({ id }: { id: string }) {
)}
<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">
<span className="filter-label">Moneda</span>
<select
+7 -2
View File
@@ -615,8 +615,13 @@ export function getBillingFacets(): Promise<BillingFacets> {
return apiFetch<BillingFacets>("/billing/facets");
}
export function getStatement(customerId: string): Promise<Statement> {
return apiFetch<Statement>(`/billing/customers/${customerId}`);
/** `year` omitted reads the current period; earlier years come from an archive. */
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
+7
View File
@@ -1096,6 +1096,13 @@ export interface Statement {
};
/** Calendar year the statement covers; movements are scoped to it. */
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[];
byDomain: StatementDomainRow[];
byType: StatementTypeRow[];