feat(bank): chequera register module (plan step 7)

Adds the office's own bank-register browser over the migrated SCOTHIA
data (22,354 bank_transactions), the last self-contained feature module.

API (apps/api/src/bank):
- GET /bank        register browser: search over concepto/reference/notes/
                   amountInWords; direction (income|expense|void), cleared and
                   date-range filters; 5 sorts; income/expense/net totals for
                   the whole filtered set, not just the page
- GET /bank/stats  headline income/expense/net + counts, date span, pending
- GET /bank/facets year list for the period filter
- GET /bank/summary  year and month rollups with a running net-movement figure

Web (/banco): "Movimientos" register + "Resumen por periodo" with year->month
drill-down; added to the AppShell nav as "Chequera".

Deliberately kept OUT of /estado-cuenta: this is the office's own money, not
customer balances, and the two are never summed or shown together.

No category/ramo dimension, and the deferred concept->ramo classifier is
dropped as won't-build: concepto is a payee name (0 of 22,354 match a
category) and TABLA RAMODOS is an expense chart of accounts + owner names,
not the insurance/servicios/fideicomiso split it was assumed to be, so a
classifier would invent data. Single currency (MXN); the "acumulado" is net
movement since the register opened (no opening balance in the source), not a
bank balance. Verified end-to-end in the browser; totals reconcile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 11:32:05 -07:00
co-authored by Claude Opus 4.8
parent 12a1523073
commit db862df8fe
11 changed files with 1434 additions and 14 deletions
+47
View File
@@ -6,6 +6,13 @@ import type {
BalanceFilter,
BalanceListResponse,
BalanceSort,
BankCleared,
BankDirection,
BankFacets,
BankListResponse,
BankSort,
BankStats,
BankSummary,
BillingFacets,
BillingStats,
BusinessLine,
@@ -294,3 +301,43 @@ export function getBillingFacets(): Promise<BillingFacets> {
export function getStatement(customerId: string): Promise<Statement> {
return apiFetch<Statement>(`/billing/customers/${customerId}`);
}
/* ------------------------------------------------- Bank register (chequera) */
export interface BankQuery {
query?: string;
page?: number;
pageSize?: number;
direction?: BankDirection;
cleared?: BankCleared;
/** `YYYY-MM-DD`, inclusive on both ends. */
from?: string;
to?: string;
sort?: BankSort;
}
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.direction) params.set("direction", q.direction);
if (q.cleared) params.set("cleared", q.cleared);
if (q.from) params.set("from", q.from);
if (q.to) params.set("to", q.to);
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
}
export function getBankStats(): Promise<BankStats> {
return apiFetch<BankStats>("/bank/stats");
}
export function getBankFacets(): Promise<BankFacets> {
return apiFetch<BankFacets>("/bank/facets");
}
export function getBankSummary(year?: number): Promise<BankSummary> {
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
}
+54
View File
@@ -1,6 +1,7 @@
// Spanish label maps + formatting helpers. Single source of truth for i18n.
import type {
BankDirection,
LedgerDirection,
PolicyStatus,
ServiceKind,
@@ -219,6 +220,59 @@ export function balanceTone(balance: string | number): "owing" | "credit" | "fla
return n < 0 ? "owing" : "credit";
}
// ----- chequera / bank register -----
/**
* The office's own account, so the words are the bank's, not the ledger's:
* an ingreso is money arriving, an egreso money leaving, and a zero-amount row
* is a cheque that was voided.
*/
export const BANK_DIRECTION_LABELS: Record<BankDirection, string> = {
income: "Ingreso",
expense: "Egreso",
void: "Cancelado",
};
export function bankDirectionLabel(d: BankDirection): string {
return BANK_DIRECTION_LABELS[d] ?? d;
}
/** CSS-class suffix for colouring a bank figure, matching `.tx-amount`. */
export function bankTone(d: BankDirection): "pos" | "neg" | "" {
if (d === "income") return "pos";
return d === "expense" ? "neg" : "";
}
/** Legacy SCOTHIA table a register row came from. */
export const BANK_SOURCE_LABELS: Record<string, string> = {
"DATOS I": "Ingresos",
"DATOS E": "Egresos",
};
export function bankSourceLabel(source: string | null | undefined): string {
if (!source) return "—";
return BANK_SOURCE_LABELS[source] ?? source;
}
export const MONTH_NAMES = [
"Enero",
"Febrero",
"Marzo",
"Abril",
"Mayo",
"Junio",
"Julio",
"Agosto",
"Septiembre",
"Octubre",
"Noviembre",
"Diciembre",
];
export function monthName(month: number): string {
return MONTH_NAMES[month - 1] ?? String(month);
}
// ----- formatting -----
export function formatMoney(
+88
View File
@@ -671,3 +671,91 @@ export interface CustomerDetail {
transactions: Transaction[];
transactionSummary: TransactionSummaryRow[];
}
/* ------------------------------------------------- Bank register (chequera) */
/**
* The office's own checking account. Single-currency (MXN) and with no customer
* link — see `bank.service.ts`. Positive is a deposit, negative a payment, and
* exactly zero a cancelled cheque.
*/
export type BankDirection = "income" | "expense" | "void";
export type BankCleared = "cleared" | "pending";
export type BankSort =
| "date_desc"
| "date_asc"
| "amount_desc"
| "amount_asc"
| "reference";
export interface BankListItem {
id: string;
transactionDate: string;
/** "INGRESO" / "EGRESO" as recorded in the source. */
transactionType: string | null;
/** Cheque number on egresos, deposit slip on ingresos. */
reference: string | null;
/** The payee or payer — a name, not a category. */
concept: string | null;
amount: string;
direction: BankDirection;
cleared: boolean;
transferred: boolean;
notes: string | null;
/** "CIENTO CINCUENTA MIL PESOS 00/100" — egresos only. */
amountInWords: string | null;
source: string | null;
}
export interface BankTotals {
income: string;
incomeCount: number;
expense: string;
expenseCount: number;
net: string;
voidCount: number;
}
export interface BankListResponse {
items: BankListItem[];
total: number;
page: number;
pageSize: number;
pageCount: number;
/** Totals for the whole filtered set, not just the current page. */
totals: BankTotals;
}
export interface BankStats extends BankTotals {
movements: number;
firstMovement: string | null;
lastMovement: string | null;
/** `operado` is false — recorded but not yet cleared by the bank. */
pending: number;
transferred: number;
}
export interface BankFacets {
years: { year: number; count: number }[];
}
export interface BankPeriodRow {
/** Year, or month number 112 in the monthly table. */
period: number;
count: number;
income: string;
expense: string;
net: string;
/** Net movement since the register opened — not a bank balance. */
cumulative: string;
}
export interface BankSummary {
year: number | null;
years: (BankPeriodRow & { opening: string })[];
months: BankPeriodRow[];
/** Cumulative figure the selected year opened on. */
opening: string;
}