feat(statements): a year selector, reading each closed year from its archive
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:
@@ -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 () => {
|
||||
const floor = new Date("2026-01-01T00:00:00Z");
|
||||
const { service, findMany } = serviceWith(floor);
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(findMany.mock.calls[0][0].where).toMatchObject({
|
||||
expect(rowsQuery(findMany).where).toMatchObject({
|
||||
customerId: "c1",
|
||||
transactionDate: { gte: floor },
|
||||
});
|
||||
@@ -132,9 +145,7 @@ describe("balance floor", () => {
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
expect(findMany.mock.calls[0][0].where).not.toHaveProperty(
|
||||
"transactionDate",
|
||||
);
|
||||
expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate");
|
||||
});
|
||||
|
||||
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||
@@ -145,7 +156,7 @@ describe("balance floor", () => {
|
||||
|
||||
await service.statement("c1");
|
||||
|
||||
const where = findMany.mock.calls[0][0].where;
|
||||
const where = rowsQuery(findMany).where;
|
||||
expect(where.OR).toEqual([
|
||||
{ legacySourceTable: null },
|
||||
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
|
||||
|
||||
@@ -119,10 +119,23 @@ export class BillingController {
|
||||
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")
|
||||
statement(@Param("id") id: string) {
|
||||
return this.billing.statement(id);
|
||||
statement(@Param("id") id: string, @Query("year") year?: string) {
|
||||
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. */
|
||||
|
||||
@@ -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
|
||||
* (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[] = [
|
||||
"EFECTIVO",
|
||||
"EFECTIVO_BACKUP",
|
||||
@@ -701,16 +715,22 @@ export class BillingService {
|
||||
/**
|
||||
* One customer's statement across both business lines.
|
||||
*
|
||||
* Scoped to the current calendar year and listed oldest-first, matching the
|
||||
* legacy EDO CUENTA report the office has printed for years: an opening
|
||||
* balance at the top, then the year's movements in the order they happened.
|
||||
* Scoped to one calendar year and listed oldest-first, matching the legacy
|
||||
* EDO CUENTA report the office has printed for years: an opening balance at
|
||||
* 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
|
||||
* carries 365 movements (mean 26), and a running balance is meaningless if
|
||||
* the client only holds a slice. The running balance is accumulated per
|
||||
* 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({
|
||||
where: { id: customerId },
|
||||
select: {
|
||||
@@ -734,6 +754,36 @@ export class BillingService {
|
||||
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
|
||||
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||
// 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({
|
||||
where: {
|
||||
customerId,
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
||||
// `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[],
|
||||
},
|
||||
},
|
||||
],
|
||||
...(isArchive
|
||||
? // An archive is already exactly one period's ledger, so the tag is
|
||||
// the whole filter. The balance floor is deliberately NOT applied:
|
||||
// it exists to stop a later opening balance double-counting the
|
||||
// history it summarizes, and here that history is the thing being
|
||||
// asked for. The exclusion list is moot too — an archive holds only
|
||||
// DATOS2 rows, which is what legacy's year table held.
|
||||
{ legacySourceTable: periodSourceTable(requested) }
|
||||
: {
|
||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
||||
// `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" }],
|
||||
select: {
|
||||
@@ -800,7 +860,18 @@ export class BillingService {
|
||||
// opening balance), the earlier rows still have to be *counted* or every
|
||||
// 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.
|
||||
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>();
|
||||
/** Balance carried into `yearStart`, per currency. */
|
||||
@@ -976,7 +1047,8 @@ export class BillingService {
|
||||
propertyCount: customer._count.properties,
|
||||
policyCount: customer._count.policies,
|
||||
},
|
||||
year: yearStart.getUTCFullYear(),
|
||||
year: requested,
|
||||
availableYears,
|
||||
summary: [...perCurrency.values()].map((c) => {
|
||||
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user