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
+48 -14
View File
@@ -153,9 +153,12 @@ the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
3. **Old external-DB credential** — the old repo's `dbConnection.php` has a hardcoded 3. **Old external-DB credential** — the old repo's `dbConnection.php` has a hardcoded
plaintext MySQL password committed to git history. Not carried into the new platform, plaintext MySQL password committed to git history. Not carried into the new platform,
but rotate it regardless; it is already exposed. but rotate it regardless; it is already exposed.
4. **`bank_transactions.categoryId` is null on all 22354 rows**the concept→ramo 4. **`bank_transactions.categoryId` is null on all 22354 rows — RESOLVED as won't-build**
classifier was deferred. Needed before any "insurance vs utilities vs trust" split of (plan step 7). The concept→ramo classifier was investigated and dropped: `concepto` is a
the office's own bank activity. payee name (0 of 22354 match a category), and TABLA RAMODOS is a property-management
expense chart of accounts + owner names, not the insurance/servicios/fideicomiso split it
was assumed to be — so a classifier would invent data rather than produce a business-line
view. The `/banco` module intentionally has no category dimension. See §8 step 7(b).
5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over 5. **`TRASPASOS PAYPAL` is a clearing account, not a customer** — carries -7.03M MXN over
309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in 309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in
code; needs a business decision on how to model it. code; needs a business decision on how to model it.
@@ -321,6 +324,40 @@ for what's actually next.
(d) The biggest debtor by far is **"TRASPASOS PAYPAL"** (-7.03M MXN over 309 movements) — (d) The biggest debtor by far is **"TRASPASOS PAYPAL"** (-7.03M MXN over 309 movements) —
a house/clearing account, not a person. Left in rather than special-cased, but it will a house/clearing account, not a person. Left in rather than special-cased, but it will
head the adeudo worklist until someone decides how to model it. head the adeudo worklist until someone decides how to model it.
- **Bank register module (plan step 7) — DONE**: `apps/api/src/bank/`
(`GET /bank` register browser with search over concepto/beneficiario, cheque
`reference`, `notes` and `amountInWords`; filters for direction
(income|expense|void), cleared status and a from/to date range, 5 sorts, and
**income/expense/net totals for the whole filtered set**; `/bank/stats`,
`/bank/facets` (year list), `/bank/summary` year/month rollup with a running
net figure) + web `/banco` (two tabs: "Movimientos" register and "Resumen por
periodo" with clickable year→month drill-down). Added to the AppShell nav as
"Chequera". Verified end-to-end in the browser: totals reconcile
(6948 income + 14615 expense + 791 void = 22354; net +899,375.77 matches
stats; 2013 months open at $0 and close at the year's net $794,295.78).
**Design decisions / data findings:**
(a) **Kept separate from `/estado-cuenta` on purpose** — this is the office's
own chequera, not customer money; the two ledgers are never summed or shown
together. Distinct nav entry, distinct page, distinct API module.
(b) **No category/ramo dimension, and the concept→ramo classifier was NOT
built** (closes open item §6.4): the data cannot support it. `DATOS E/I` have
no ramo column to migrate; `concepto` is a *payee* name (PAYPAL, CFE, ~1,900
individuals), and 0 of 22354 concepts match a `business_line_categories` name;
and the 66 TABLA RAMODOS rows are a property-management expense chart of
accounts (Payroll, Pool Labor, Gardening) + owner names, *not* the
insurance/servicios/fideicomiso split the migration comment implied. A
classifier would invent data, so `categoryId` stays null and the module does
not filter on it. Register is browsable by date/payee/amount/cheque instead.
(c) **Single currency (MXN).** `bank_transactions` has no currency column and
every `amountInWords` is spelled out in PESOS — so, unlike the customer
ledger, everything here is one currency and not split per-currency.
(d) **The "acumulado" is net movement since the register opened, not a bank
balance** — SCOTHIA carries no opening balance (its `ban` table holds only the
bank's name), so the running total starts at 0 in 2013. Labelled as such in
the UI so it is never read as a statement balance.
(e) Sign convention (from `transform_bank.py`): positive = ingreso,
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
CANCELADO/VOID) — voids are excluded from both the income and expense sides.
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers → - Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
properties → policies → transactions → prune → bank → blobs in order (all idempotent); properties → policies → transactions → prune → bank → blobs in order (all idempotent);
add `--stage` to re-extract from the Access files first. Verified end-to-end against dev. add `--stage` to re-extract from the Access files first. Verified end-to-end against dev.
@@ -331,21 +368,18 @@ for what's actually next.
`deploy/jorgecuadros-db.stack.yml` deploys prod from the same file as `deploy/jorgecuadros-db.stack.yml` deploys prod from the same file as
`jorgecuadros-prod-db` on :3306. MinIO for documents deployed as `jorgecuadros-dev-minio`. `jorgecuadros-prod-db` on :3306. MinIO for documents deployed as `jorgecuadros-dev-minio`.
6. **Staff web UI****DONE** for all four modules (§8 step 4: clientes, polizas, servicios, 6. **Staff web UI****DONE** for all five modules (§8 step 4 + step 7: clientes, polizas,
estado-cuenta). Spanish-first, session-cookie auth against the API, verified against real servicios, estado-cuenta, banco). Spanish-first, session-cookie auth against the API,
migrated data. verified against real migrated data. **All app-layer feature work is complete.**
--- ---
**NEXT — where to pick up:** **NEXT — where to pick up:**
- **Plan step 7: bank register module.** SCOTHIA data is already migrated (22354
`bank_transactions` + 66 `business_line_categories`) and has **no customer FK**, so it is
self-contained and low-risk — API + `/banco` browser only. Blocked on nothing.
Ties into open item §6.4 (`categoryId` is null on every row).
- **Plan step 89: VPS + sync worker.** Blocked on VPS provisioning (§6.1) — the only real - **Plan step 89: VPS + sync worker.** Blocked on VPS provisioning (§6.1) — the only real
external dependency left. external dependency left. Pure ops: provider, size, Tailscale, MySQL replica.
- **Plan step 10: reports / email campaigns / admin.** - **Plan step 10: reports / email campaigns / admin.**
- **Uncommitted work:** the billing module and the ledger de-dup fix are written, built and - **Small / open:** (a) `TRASPASOS PAYPAL` clearing account still tops the adeudo worklist
verified but **not committed**`git status` is dirty at the time of writing. The browser (§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's
visual pass on `/estado-cuenta` was also never completed (it needs an interactive login). exposed MySQL password. (c) The `/estado-cuenta` browser visual pass — `/banco` was verified
in-browser this session; `/estado-cuenta` still worth a look.
+2
View File
@@ -7,6 +7,7 @@ import { CustomersModule } from "./customers/customers.module";
import { PoliciesModule } from "./policies/policies.module"; import { PoliciesModule } from "./policies/policies.module";
import { PropertiesModule } from "./properties/properties.module"; import { PropertiesModule } from "./properties/properties.module";
import { BillingModule } from "./billing/billing.module"; import { BillingModule } from "./billing/billing.module";
import { BankModule } from "./bank/bank.module";
import { AppController } from "./app.controller"; import { AppController } from "./app.controller";
@Module({ @Module({
@@ -19,6 +20,7 @@ import { AppController } from "./app.controller";
PoliciesModule, PoliciesModule,
PropertiesModule, PropertiesModule,
BillingModule, BillingModule,
BankModule,
], ],
controllers: [AppController], controllers: [AppController],
}) })
+78
View File
@@ -0,0 +1,78 @@
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import {
BankCleared,
BankDirection,
BankService,
BankSort,
} from "./bank.service";
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
const CLEARED: BankCleared[] = ["cleared", "pending"];
const SORTS: BankSort[] = [
"date_desc",
"date_asc",
"amount_desc",
"amount_asc",
"reference",
];
function one<T>(allowed: T[], value: string | undefined): T | undefined {
return allowed.includes(value as T) ? (value as T) : undefined;
}
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
if (!v) return undefined;
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? undefined : d;
}
@UseGuards(AuthenticatedGuard)
@Controller("bank")
export class BankController {
constructor(private readonly bank: BankService) {}
@Get("stats")
stats() {
return this.bank.stats();
}
@Get("facets")
facets() {
return this.bank.facets();
}
/** Year and month rollups with a running net-movement figure. */
@Get("summary")
summary(@Query("year") year?: string) {
const y = Number(year);
return this.bank.summary(
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
);
}
/** The register browser. */
@Get()
list(
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("direction") direction?: string,
@Query("cleared") cleared?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("sort") sort?: string,
) {
return this.bank.list({
query,
page: Math.max(1, Number(page) || 1),
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
direction: one(DIRECTIONS, direction),
cleared: one(CLEARED, cleared),
from: parseDate(from),
to: parseDate(to, true),
sort: one(SORTS, sort) ?? "date_desc",
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { BankController } from "./bank.controller";
import { BankService } from "./bank.service";
@Module({
controllers: [BankController],
providers: [BankService],
})
export class BankModule {}
+354
View File
@@ -0,0 +1,354 @@
import { Injectable } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
/**
* Bank register (chequera) module — plan step 7.
*
* This is the office's OWN operating checking account, migrated from SCOTHIA's
* `DATOS I` (ingresos) / `DATOS E` (egresos) into one signed-amount table. It
* carries no customer FK and is deliberately NOT part of `/estado-cuenta`: that
* ledger is what customers owe the office, this one is the office's own money.
* The two must never be added together or shown in the same total.
*
* SIGN CONVENTION (set by migration/transform_bank.py):
* - positive = ingreso (a deposit into the account)
* - negative = egreso (a payment out of it)
* - exactly zero = a cancelled/void cheque. 787 of the 791 zero rows say
* CANCELADO or VOID in the concept; they are neither an income nor an
* expense and are excluded from both sides, the way the ~193 zero rows are
* in the customer ledger.
*
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here:
* `bank_transactions` has none, and every `amountInWords` on the egreso side is
* spelled out in PESOS. All figures in this module are MXN.
*
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
* rows and this module does not filter or group by it, because the data cannot
* support it:
* - `DATOS E` / `DATOS I` have no ramo column at all — the only columns are
* fecha, tipo, num, concepto, ingreso/egreso, operado, notas and (egresos)
* cantidad en letra. There is no key to migrate.
* - `concepto` is a *payee* name (PAYPAL, CFE, TELEFONOS DEL NOROESTE, and
* ~1,900 individual people), not a classification. Zero of the 22,354
* concepts match a `business_line_categories` name.
* - the 66 categories in TABLA RAMODOS are a property-management expense
* chart of accounts (Payroll, Pool (Labor), Gardening, Trash Coll) plus
* owner names with property numbers — not the insurance/servicios/
* fideicomiso split. Classifying concepts into them would not produce a
* business-line breakdown even if it worked.
* A concept->ramo classifier would therefore be invented data, so the register
* is browsable by date, payee, amount and cheque number instead.
*/
/** Which side of the register a movement is on. */
export type BankDirection = "income" | "expense" | "void";
/** `operado` in the source: whether the bank has cleared the movement. */
export type BankCleared = "cleared" | "pending";
export type BankSort =
| "date_desc"
| "date_asc"
| "amount_desc"
| "amount_asc"
| "reference";
export interface BankListParams {
query?: string;
page: number;
pageSize: number;
direction?: BankDirection;
cleared?: BankCleared;
/** Inclusive bounds on `transactionDate`. */
from?: Date;
to?: Date;
sort: BankSort;
}
/** Raw shape of a year/month rollup row. */
interface PeriodRow {
period: number;
count: bigint | number | string;
income: Prisma.Decimal | null;
expense: Prisma.Decimal | null;
net: Prisma.Decimal | null;
}
function num(v: bigint | number | string | null | undefined): number {
if (v === null || v === undefined) return 0;
return typeof v === "number" ? v : Number(v);
}
function dec(v: Prisma.Decimal | null | undefined): string {
return (v ?? new Prisma.Decimal(0)).toFixed(2);
}
@Injectable()
export class BankService {
constructor(private readonly prisma: PrismaService) {}
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
const and: Prisma.BankTransactionWhereInput[] = [];
if (p.query && p.query.trim()) {
const q = p.query.trim();
and.push({
OR: [
{ concept: { contains: q } },
{ reference: { contains: q } },
{ notes: { contains: q } },
{ amountInWords: { contains: q } },
],
});
}
if (p.direction === "income") and.push({ amount: { gt: 0 } });
if (p.direction === "expense") and.push({ amount: { lt: 0 } });
if (p.direction === "void") and.push({ amount: 0 });
if (p.cleared) and.push({ cleared: p.cleared === "cleared" });
if (p.from || p.to) {
and.push({
transactionDate: {
...(p.from ? { gte: p.from } : {}),
...(p.to ? { lte: p.to } : {}),
},
});
}
return and.length ? { AND: and } : {};
}
private orderBy(
sort: BankSort,
): Prisma.BankTransactionOrderByWithRelationInput[] {
switch (sort) {
case "date_asc":
return [{ transactionDate: "asc" }, { reference: "asc" }];
case "amount_desc":
return [{ amount: "desc" }];
case "amount_asc":
return [{ amount: "asc" }];
case "reference":
// `reference` is the cheque number on egresos and the deposit slip on
// ingresos; it is a string column, so this is a lexical sort.
return [{ reference: "asc" }];
default:
return [{ transactionDate: "desc" }, { reference: "desc" }];
}
}
/** The register itself: every deposit and payment, filterable. */
async list(params: BankListParams) {
const where = this.where(params);
const [total, rows] = await this.prisma.$transaction([
this.prisma.bankTransaction.count({ where }),
this.prisma.bankTransaction.findMany({
where,
skip: (params.page - 1) * params.pageSize,
take: params.pageSize,
orderBy: this.orderBy(params.sort),
select: {
id: true,
transactionDate: true,
transactionType: true,
reference: true,
concept: true,
amount: true,
cleared: true,
transferred: true,
notes: true,
amountInWords: true,
legacySourceTable: true,
},
}),
]);
// Totals cover the whole filtered set, not just the page — the figure staff
// read off a filtered view ("what did we pay CFE in 2025") has to.
const totals = await this.totalsFor(where);
return {
items: rows.map((r) => ({
id: r.id,
transactionDate: r.transactionDate,
transactionType: r.transactionType,
reference: r.reference,
concept: r.concept,
amount: r.amount,
direction: directionOf(r.amount),
cleared: r.cleared,
transferred: r.transferred,
notes: r.notes,
amountInWords: r.amountInWords,
source: r.legacySourceTable,
})),
total,
page: params.page,
pageSize: params.pageSize,
pageCount: Math.ceil(total / params.pageSize),
totals,
};
}
/** Income / expense / void split over an arbitrary filter. */
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
const [income, expense, voided] = await Promise.all([
this.prisma.bankTransaction.aggregate({
where: { AND: [where, { amount: { gt: 0 } }] },
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.bankTransaction.aggregate({
where: { AND: [where, { amount: { lt: 0 } }] },
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.bankTransaction.count({
where: { AND: [where, { amount: 0 }] },
}),
]);
const inSum = income._sum.amount ?? new Prisma.Decimal(0);
const outSum = expense._sum.amount ?? new Prisma.Decimal(0);
return {
income: inSum.toFixed(2),
incomeCount: income._count._all,
expense: outSum.toFixed(2),
expenseCount: expense._count._all,
net: inSum.plus(outSum).toFixed(2),
voidCount: voided,
};
}
/** Top-line figures for the bank page header. */
async stats() {
const [count, bounds, pending, transferred, totals] = await Promise.all([
this.prisma.bankTransaction.count(),
this.prisma.bankTransaction.aggregate({
_min: { transactionDate: true },
_max: { transactionDate: true },
}),
this.prisma.bankTransaction.count({ where: { cleared: false } }),
this.prisma.bankTransaction.count({ where: { transferred: true } }),
this.totalsFor({}),
]);
return {
movements: count,
firstMovement: bounds._min.transactionDate,
lastMovement: bounds._max.transactionDate,
pending,
transferred,
...totals,
};
}
/** Year list for the period filter, newest first. */
async facets() {
const years = await this.prisma.$queryRaw<
{ year: number; count: bigint | number | string }[]
>`
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
FROM bank_transactions
GROUP BY year
ORDER BY year DESC
`;
return {
years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })),
};
}
/**
* Period rollup for the "Resumen" view: one row per year, plus one row per
* month when a year is selected.
*
* `cumulative` is the running sum of every movement from the start of the
* register — NOT the bank balance. SCOTHIA carries no opening balance (its
* `BAN` table holds only the bank's name), so the register starts at zero on
* its first row in 2013 and the running figure is the net movement since
* then. Labelled as such in the UI so it is never read as a statement balance.
*/
async summary(year?: number) {
const years = await this.prisma.$queryRaw<PeriodRow[]>`
SELECT
YEAR(transactionDate) AS period,
COUNT(*) AS count,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net
FROM bank_transactions
GROUP BY period
ORDER BY period ASC
`;
const months = year
? await this.prisma.$queryRaw<PeriodRow[]>`
SELECT
MONTH(transactionDate) AS period,
COUNT(*) AS count,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net
FROM bank_transactions
WHERE YEAR(transactionDate) = ${year}
GROUP BY period
ORDER BY period ASC
`
: [];
// Cumulative across years runs from the first row of the register; the
// monthly cumulative opens at the selected year's opening figure so the two
// tables agree.
let running = new Prisma.Decimal(0);
const yearRows = years.map((r) => {
const net = r.net ?? new Prisma.Decimal(0);
const opening = running;
running = running.plus(net);
return {
period: Number(r.period),
count: num(r.count),
income: dec(r.income),
expense: dec(r.expense),
net: net.toFixed(2),
opening: opening.toFixed(2),
cumulative: running.toFixed(2),
};
});
const opening =
year === undefined
? new Prisma.Decimal(0)
: new Prisma.Decimal(
yearRows.find((y) => y.period === year)?.opening ?? "0",
);
let monthRunning = opening;
const monthRows = months.map((r) => {
const net = r.net ?? new Prisma.Decimal(0);
monthRunning = monthRunning.plus(net);
return {
period: Number(r.period),
count: num(r.count),
income: dec(r.income),
expense: dec(r.expense),
net: net.toFixed(2),
cumulative: monthRunning.toFixed(2),
};
});
return {
year: year ?? null,
years: yearRows,
months: monthRows,
opening: opening.toFixed(2),
};
}
}
function directionOf(amount: Prisma.Decimal): BankDirection {
if (amount.greaterThan(0)) return "income";
return amount.lessThan(0) ? "expense" : "void";
}
+748
View File
@@ -0,0 +1,748 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import {
getBankFacets,
getBankStats,
getBankSummary,
listBankMovements,
} from "@/lib/api";
import {
bankDirectionLabel,
bankSourceLabel,
bankTone,
formatDate,
formatMoney,
formatNumber,
monthName,
} from "@/lib/labels";
import type {
BankCleared,
BankDirection,
BankFacets,
BankListItem,
BankListResponse,
BankSort,
BankStats,
BankSummary,
BankTotals,
} from "@/lib/types";
/**
* Bank register (chequera) browser — plan step 7.
*
* This is the office's OWN checking account, not customer money. It is a
* separate page from /estado-cuenta on purpose: nothing here belongs in a
* customer's statement and the two sets of figures are never combined.
*
* Two views:
* - "Movimientos": the register itself — every deposit and payment, by date,
* payee, cheque number or amount.
* - "Resumen": ingresos vs egresos per year, and per month inside a year,
* with the running net movement since the register opened in 2013.
*
* Single currency (MXN) — the source has no currency column. See the module
* header in `bank.service.ts` for why there is no category/ramo filter.
*/
type View = "movimientos" | "resumen";
const DIRECTIONS: { key: BankDirection | ""; label: string }[] = [
{ key: "", label: "Ingresos y egresos" },
{ key: "income", label: "Sólo ingresos" },
{ key: "expense", label: "Sólo egresos" },
{ key: "void", label: "Sólo cancelados" },
];
const CLEARED: { key: BankCleared | ""; label: string }[] = [
{ key: "", label: "Operados y pendientes" },
{ key: "cleared", label: "Sólo operados" },
{ key: "pending", label: "Sólo pendientes" },
];
const SORTS: { key: BankSort; label: string }[] = [
{ key: "date_desc", label: "Fecha (más reciente)" },
{ key: "date_asc", label: "Fecha (más antigua)" },
{ key: "amount_desc", label: "Ingreso más grande" },
{ key: "amount_asc", label: "Egreso más grande" },
{ key: "reference", label: "Número de cheque" },
];
export default function BancoPage() {
return (
<AppShell>
<BankBrowser />
</AppShell>
);
}
function BankBrowser() {
const [stats, setStats] = useState<BankStats | null>(null);
const [facets, setFacets] = useState<BankFacets | null>(null);
const [view, setView] = useState<View>("movimientos");
const [query, setQuery] = useState("");
const [direction, setDirection] = useState<BankDirection | "">("");
const [cleared, setCleared] = useState<BankCleared | "">("");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [sort, setSort] = useState<BankSort>("date_desc");
const [movements, setMovements] = useState<BankListResponse | null>(null);
const [summary, setSummary] = useState<BankSummary | null>(null);
const [summaryYear, setSummaryYear] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
getBankStats().then(setStats).catch(() => setStats(null));
getBankFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const runSearch = useCallback(
(p: number) => {
setLoading(true);
setError(null);
listBankMovements({
query: query || undefined,
direction: direction || undefined,
cleared: cleared || undefined,
from: from || undefined,
to: to || undefined,
sort,
page: p,
pageSize: 25,
})
.then((res) => {
setMovements(res);
setLoading(false);
})
.catch((e) => {
setError(e?.message ?? "No se pudieron cargar los movimientos.");
setLoading(false);
});
},
[query, direction, cleared, from, to, sort],
);
useEffect(() => {
if (view !== "movimientos") return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(1), 280);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [runSearch, view]);
useEffect(() => {
if (view !== "resumen") return;
setLoading(true);
setError(null);
getBankSummary(summaryYear ?? undefined)
.then((res) => {
setSummary(res);
setLoading(false);
})
.catch((e) => {
setError(e?.message ?? "No se pudo cargar el resumen.");
setLoading(false);
});
}, [view, summaryYear]);
function goToPage(p: number) {
runSearch(p);
if (typeof window !== "undefined")
window.scrollTo({ top: 0, behavior: "smooth" });
}
/** Clicking a year in the resumen drills into its months. */
function pickYear(year: number) {
setSummaryYear((prev) => (prev === year ? null : year));
}
/** Jumping from a headline figure lands on the matching filtered register. */
function pickDirection(d: BankDirection) {
setView("movimientos");
setDirection(d);
setSort(d === "income" ? "amount_desc" : "amount_asc");
}
const filtered =
query !== "" ||
direction !== "" ||
cleared !== "" ||
from !== "" ||
to !== "" ||
sort !== "date_desc";
function clearFilters() {
setQuery("");
setDirection("");
setCleared("");
setFrom("");
setTo("");
setSort("date_desc");
}
return (
<>
<div className="page-head rise">
<p className="eyebrow">Cuenta propia de la oficina</p>
<h1 className="page-title">Chequera</h1>
<BankStatStrip
stats={stats}
direction={view === "movimientos" ? direction : ""}
onPickDirection={pickDirection}
/>
<p className="section-note">
Movimientos de la cuenta bancaria de la oficina, en pesos. No forma
parte del estado de cuenta de los clientes y sus cifras no se suman
con las de ellos.
</p>
</div>
<div className="toolbar">
<div className="search-box">
<span className="search-icon" aria-hidden>
</span>
<input
className="input search-input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por beneficiario, cheque, nota…"
aria-label="Buscar en la chequera"
disabled={view === "resumen"}
/>
</div>
<div className="seg" role="tablist" aria-label="Vista">
{(
[
{ key: "movimientos" as View, label: "Movimientos" },
{ key: "resumen" as View, label: "Resumen por periodo" },
]
).map((v) => (
<button
key={v.key}
type="button"
role="tab"
aria-selected={view === v.key}
className={`seg-btn ${view === v.key ? "active" : ""}`}
onClick={() => setView(v.key)}
>
{v.label}
</button>
))}
</div>
</div>
{view === "movimientos" && (
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Movimiento</span>
<select
className="input select"
value={direction}
onChange={(e) =>
setDirection(e.target.value as BankDirection | "")
}
>
{DIRECTIONS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Estatus</span>
<select
className="input select"
value={cleared}
onChange={(e) => setCleared(e.target.value as BankCleared | "")}
>
{CLEARED.map((c) => (
<option key={c.key} value={c.key}>
{c.label}
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Desde</span>
<input
className="input"
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
/>
</label>
<label className="filter-field">
<span className="filter-label">Hasta</span>
<input
className="input"
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
/>
</label>
<label className="filter-field">
<span className="filter-label">Ordenar por</span>
<select
className="input select"
value={sort}
onChange={(e) => setSort(e.target.value as BankSort)}
>
{SORTS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</label>
{filtered && (
<button
type="button"
className="btn btn-ghost filter-clear"
onClick={clearFilters}
>
Limpiar filtros
</button>
)}
</div>
)}
{view === "resumen" && facets && facets.years.length > 0 && (
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Año</span>
<select
className="input select"
value={summaryYear ?? ""}
onChange={(e) =>
setSummaryYear(e.target.value ? Number(e.target.value) : null)
}
>
<option value="">Todos los años</option>
{facets.years.map((y) => (
<option key={y.year} value={y.year}>
{y.year} ({formatNumber(y.count)})
</option>
))}
</select>
</label>
</div>
)}
{view === "movimientos" && movements && !loading && !error && (
<div className="result-meta" aria-live="polite">
{movements.total === 0
? "Sin resultados"
: `${formatNumber(movements.total)} ${
movements.total === 1 ? "movimiento" : "movimientos"
}`}
{query ? ` para “${query}` : ""}
</div>
)}
{view === "movimientos" && movements && !loading && (
<FilteredTotals totals={movements.totals} />
)}
{error ? (
<div className="state-error" role="alert">
{error}
</div>
) : loading ? (
<ListSkeleton />
) : view === "resumen" ? (
<SummaryView
summary={summary}
year={summaryYear}
onPickYear={pickYear}
/>
) : movements && movements.total === 0 ? (
<EmptyState query={query} />
) : (
<>
<div className="card">
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Cheque / ref.</th>
<th>Beneficiario / concepto</th>
<th>Origen</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{movements?.items.map((m) => (
<BankRow key={m.id} m={m} />
))}
</tbody>
</table>
</div>
</div>
{movements && movements.pageCount > 1 && (
<Pager
page={movements.page}
pageCount={movements.pageCount}
onChange={goToPage}
/>
)}
</>
)}
</>
);
}
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */
function BankStatStrip({
stats,
direction,
onPickDirection,
}: {
stats: BankStats | null;
direction: BankDirection | "";
onPickDirection: (d: BankDirection) => void;
}) {
if (!stats) {
return (
<div className="stat-strip" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<div className="stat-cell" key={i}>
<div className="skeleton" style={{ height: 25, width: "60%" }} />
<div
className="skeleton"
style={{ height: 11, width: "80%", marginTop: 8 }}
/>
</div>
))}
</div>
);
}
return (
<div className="stat-strip">
<button
type="button"
className={`stat-cell stat-cell-btn accent${
direction === "income" ? " selected" : ""
}`}
onClick={() => onPickDirection("income")}
aria-pressed={direction === "income"}
>
<div className="stat-value tx-amount pos">
{formatMoney(stats.income, "MXN")}
</div>
<div className="stat-label">
En ingresos · {formatNumber(stats.incomeCount)} movimientos
</div>
</button>
<button
type="button"
className={`stat-cell stat-cell-btn${
direction === "expense" ? " selected" : ""
}`}
onClick={() => onPickDirection("expense")}
aria-pressed={direction === "expense"}
>
<div className="stat-value tx-amount neg">
{formatMoney(stats.expense, "MXN")}
</div>
<div className="stat-label">
En egresos · {formatNumber(stats.expenseCount)} movimientos
</div>
</button>
<div className="stat-cell">
<div className="stat-value">{formatMoney(stats.net, "MXN")}</div>
{/* Not the bank balance: the register carries no opening balance. */}
<div className="stat-label">Movimiento neto acumulado</div>
</div>
<div className="stat-cell">
<div className="stat-value">{formatNumber(stats.movements)}</div>
<div className="stat-label">
Movimientos · {formatDate(stats.firstMovement)} a{" "}
{formatDate(stats.lastMovement)}
</div>
</div>
<button
type="button"
className={`stat-cell stat-cell-btn${
direction === "void" ? " selected" : ""
}`}
onClick={() => onPickDirection("void")}
aria-pressed={direction === "void"}
>
<div className="stat-value">{formatNumber(stats.voidCount)}</div>
<div className="stat-label">
Cheques cancelados · {formatNumber(stats.pending)} sin operar
</div>
</button>
</div>
);
}
/** Totals for everything the current filter matched, not just the page. */
function FilteredTotals({ totals }: { totals: BankTotals }) {
if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0)
return null;
return (
<div className="filtered-totals">
<div className="filtered-total">
<span className="filtered-total-cur">MXN</span>
<span>
<strong className="tx-amount pos">
{formatMoney(totals.income, "MXN")}
</strong>{" "}
en ingresos · {formatNumber(totals.incomeCount)}
</span>
<span>
<strong className="tx-amount neg">
{formatMoney(totals.expense, "MXN")}
</strong>{" "}
en egresos · {formatNumber(totals.expenseCount)}
</span>
<span className="filtered-total-net">
Neto <strong>{formatMoney(totals.net, "MXN")}</strong>
</span>
{totals.voidCount > 0 && (
<span>{formatNumber(totals.voidCount)} cancelados</span>
)}
</div>
</div>
);
}
function BankRow({ m }: { m: BankListItem }) {
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
<td className="tx-ref">
{m.reference || "—"}
{!m.cleared && (
<div className="tx-concept">Sin operar</div>
)}
</td>
<td>
{m.concept || <span className="muted">Sin concepto</span>}
{m.notes && <div className="tx-concept">Nota: {m.notes}</div>}
</td>
<td>{bankSourceLabel(m.source)}</td>
<td className="num">
<span className={`tx-amount ${bankTone(m.direction)}`}>
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")}
</span>
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
</td>
</tr>
);
}
/**
* Ingresos vs egresos per period. `cumulative` is the net movement since the
* register opened in 2013, not a bank balance — SCOTHIA has no opening figure.
*/
function SummaryView({
summary,
year,
onPickYear,
}: {
summary: BankSummary | null;
year: number | null;
onPickYear: (y: number) => void;
}) {
if (!summary) return null;
return (
<>
<div className="card">
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Año</th>
<th className="num">Movimientos</th>
<th className="num">Ingresos</th>
<th className="num">Egresos</th>
<th className="num">Neto</th>
<th className="num">Acumulado</th>
</tr>
</thead>
<tbody>
{summary.years.map((r) => (
<tr
key={r.period}
onClick={() => onPickYear(r.period)}
style={{ cursor: "pointer" }}
className={year === r.period ? "selected" : undefined}
>
<td className="mono">
<strong>{r.period}</strong>
</td>
<td className="num">{formatNumber(r.count)}</td>
<td className="num">
<span className="tx-amount pos">
{formatMoney(r.income, "MXN")}
</span>
</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(r.expense, "MXN")}
</span>
</td>
<td className="num">
<span
className={`tx-amount ${
Number(r.net) < 0 ? "neg" : "pos"
}`}
>
{formatMoney(r.net, "MXN")}
</span>
</td>
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<p className="section-note">
El acumulado es el movimiento neto desde que inicia el registro; la
chequera heredada no trae saldo inicial, así que no equivale al saldo
del banco. Selecciona un año para ver sus meses.
</p>
{year && summary.months.length > 0 && (
<div className="section">
<div className="section-head">
<span className="section-rule cuenta" />
<h2 className="section-title">Meses de {year}</h2>
<span className="section-count">
abre en {formatMoney(summary.opening, "MXN")}
</span>
</div>
<div className="card">
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Mes</th>
<th className="num">Movimientos</th>
<th className="num">Ingresos</th>
<th className="num">Egresos</th>
<th className="num">Neto</th>
<th className="num">Acumulado</th>
</tr>
</thead>
<tbody>
{summary.months.map((r) => (
<tr key={r.period}>
<td>{monthName(r.period)}</td>
<td className="num">{formatNumber(r.count)}</td>
<td className="num">
<span className="tx-amount pos">
{formatMoney(r.income, "MXN")}
</span>
</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(r.expense, "MXN")}
</span>
</td>
<td className="num">
<span
className={`tx-amount ${
Number(r.net) < 0 ? "neg" : "pos"
}`}
>
{formatMoney(r.net, "MXN")}
</span>
</td>
<td className="num mono">
{formatMoney(r.cumulative, "MXN")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</>
);
}
function Pager({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (p: number) => void;
}) {
return (
<nav className="pager" aria-label="Paginación">
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page - 1)}
disabled={page <= 1}
>
Anterior
</button>
<span className="pager-info">
Página <strong>{page}</strong> de {pageCount}
</span>
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page + 1)}
disabled={page >= pageCount}
>
Siguiente
</button>
</nav>
);
}
function ListSkeleton() {
return (
<div className="cust-list" aria-hidden>
{Array.from({ length: 8 }).map((_, i) => (
<div className="skeleton skel-row" key={i} />
))}
</div>
);
}
function EmptyState({ query }: { query: string }) {
return (
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin resultados</h3>
<p>
{query
? `No encontramos movimientos para “${query}”.`
: "No hay movimientos que coincidan con los filtros."}
</p>
</div>
);
}
+5
View File
@@ -1344,6 +1344,11 @@ button {
.tx-table tr:hover td { .tx-table tr:hover td {
background: var(--surface-2); background: var(--surface-2);
} }
/* Drill-down rows (the chequera's yearly summary) read as pressable. */
.tx-table tr.selected td {
background: var(--surface-2);
box-shadow: inset 2px 0 0 var(--brand-600);
}
.tx-domain-cell { .tx-domain-cell {
white-space: nowrap; white-space: nowrap;
} }
+1
View File
@@ -16,6 +16,7 @@ const NAV = [
{ href: "/servicios", label: "Propiedades" }, { href: "/servicios", label: "Propiedades" },
{ href: "/polizas", label: "Pólizas" }, { href: "/polizas", label: "Pólizas" },
{ href: "/estado-cuenta", label: "Estado de cuenta" }, { href: "/estado-cuenta", label: "Estado de cuenta" },
{ href: "/banco", label: "Chequera" },
]; ];
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
+47
View File
@@ -6,6 +6,13 @@ import type {
BalanceFilter, BalanceFilter,
BalanceListResponse, BalanceListResponse,
BalanceSort, BalanceSort,
BankCleared,
BankDirection,
BankFacets,
BankListResponse,
BankSort,
BankStats,
BankSummary,
BillingFacets, BillingFacets,
BillingStats, BillingStats,
BusinessLine, BusinessLine,
@@ -294,3 +301,43 @@ export function getBillingFacets(): Promise<BillingFacets> {
export function getStatement(customerId: string): Promise<Statement> { export function getStatement(customerId: string): Promise<Statement> {
return apiFetch<Statement>(`/billing/customers/${customerId}`); 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. // Spanish label maps + formatting helpers. Single source of truth for i18n.
import type { import type {
BankDirection,
LedgerDirection, LedgerDirection,
PolicyStatus, PolicyStatus,
ServiceKind, ServiceKind,
@@ -219,6 +220,59 @@ export function balanceTone(balance: string | number): "owing" | "credit" | "fla
return n < 0 ? "owing" : "credit"; 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 ----- // ----- formatting -----
export function formatMoney( export function formatMoney(
+88
View File
@@ -671,3 +671,91 @@ export interface CustomerDetail {
transactions: Transaction[]; transactions: Transaction[];
transactionSummary: TransactionSummaryRow[]; 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;
}