From db862df8fe92da37030598d621ac98a660d924a3 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 23 Jul 2026 11:32:05 -0700 Subject: [PATCH] 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 --- RESUME.md | 62 ++- apps/api/src/app.module.ts | 2 + apps/api/src/bank/bank.controller.ts | 78 +++ apps/api/src/bank/bank.module.ts | 9 + apps/api/src/bank/bank.service.ts | 354 +++++++++++++ apps/web/src/app/banco/page.tsx | 748 +++++++++++++++++++++++++++ apps/web/src/app/globals.css | 5 + apps/web/src/components/AppShell.tsx | 1 + apps/web/src/lib/api.ts | 47 ++ apps/web/src/lib/labels.ts | 54 ++ apps/web/src/lib/types.ts | 88 ++++ 11 files changed, 1434 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/bank/bank.controller.ts create mode 100644 apps/api/src/bank/bank.module.ts create mode 100644 apps/api/src/bank/bank.service.ts create mode 100644 apps/web/src/app/banco/page.tsx diff --git a/RESUME.md b/RESUME.md index cfd2a89..1351726 100644 --- a/RESUME.md +++ b/RESUME.md @@ -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 plaintext MySQL password committed to git history. Not carried into the new platform, but rotate it regardless; it is already exposed. -4. **`bank_transactions.categoryId` is null on all 22354 rows** — the concept→ramo - classifier was deferred. Needed before any "insurance vs utilities vs trust" split of - the office's own bank activity. +4. **`bank_transactions.categoryId` is null on all 22354 rows — RESOLVED as won't-build** + (plan step 7). The concept→ramo classifier was investigated and dropped: `concepto` is a + 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 309 movements and therefore tops the adeudo worklist. Deliberately not special-cased in 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) — 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. + - **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 ` runs customers → 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. @@ -331,21 +368,18 @@ for what's actually next. `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`. -6. **Staff web UI** — **DONE** for all four modules (§8 step 4: clientes, polizas, servicios, - estado-cuenta). Spanish-first, session-cookie auth against the API, verified against real - migrated data. +6. **Staff web UI** — **DONE** for all five modules (§8 step 4 + step 7: clientes, polizas, + servicios, estado-cuenta, banco). Spanish-first, session-cookie auth against the API, + verified against real migrated data. **All app-layer feature work is complete.** --- ⏭ **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 8–9: 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.** -- **Uncommitted work:** the billing module and the ledger de-dup fix are written, built and - verified but **not committed** — `git status` is dirty at the time of writing. The browser - visual pass on `/estado-cuenta` was also never completed (it needs an interactive login). +- **Small / open:** (a) `TRASPASOS PAYPAL` clearing account still tops the adeudo worklist + (§6.4d) — a business modelling call, not code. (b) Credential rotation on the old repo's + exposed MySQL password. (c) The `/estado-cuenta` browser visual pass — `/banco` was verified + in-browser this session; `/estado-cuenta` still worth a look. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1f3b586..ce04fa0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { CustomersModule } from "./customers/customers.module"; import { PoliciesModule } from "./policies/policies.module"; import { PropertiesModule } from "./properties/properties.module"; import { BillingModule } from "./billing/billing.module"; +import { BankModule } from "./bank/bank.module"; import { AppController } from "./app.controller"; @Module({ @@ -19,6 +20,7 @@ import { AppController } from "./app.controller"; PoliciesModule, PropertiesModule, BillingModule, + BankModule, ], controllers: [AppController], }) diff --git a/apps/api/src/bank/bank.controller.ts b/apps/api/src/bank/bank.controller.ts new file mode 100644 index 0000000..eb030a7 --- /dev/null +++ b/apps/api/src/bank/bank.controller.ts @@ -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(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", + }); + } +} diff --git a/apps/api/src/bank/bank.module.ts b/apps/api/src/bank/bank.module.ts new file mode 100644 index 0000000..f21e804 --- /dev/null +++ b/apps/api/src/bank/bank.module.ts @@ -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 {} diff --git a/apps/api/src/bank/bank.service.ts b/apps/api/src/bank/bank.service.ts new file mode 100644 index 0000000..0dafccc --- /dev/null +++ b/apps/api/src/bank/bank.service.ts @@ -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` + 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` + 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"; +} diff --git a/apps/web/src/app/banco/page.tsx b/apps/web/src/app/banco/page.tsx new file mode 100644 index 0000000..ff2794f --- /dev/null +++ b/apps/web/src/app/banco/page.tsx @@ -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 ( + + + + ); +} + +function BankBrowser() { + const [stats, setStats] = useState(null); + const [facets, setFacets] = useState(null); + const [view, setView] = useState("movimientos"); + + const [query, setQuery] = useState(""); + const [direction, setDirection] = useState(""); + const [cleared, setCleared] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [sort, setSort] = useState("date_desc"); + + const [movements, setMovements] = useState(null); + const [summary, setSummary] = useState(null); + const [summaryYear, setSummaryYear] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const debounceRef = useRef>(); + + 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 ( + <> +
+

Cuenta propia de la oficina

+

Chequera

+ +

+ 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. +

+
+ +
+
+ + ⌕ + + setQuery(e.target.value)} + placeholder="Buscar por beneficiario, cheque, nota…" + aria-label="Buscar en la chequera" + disabled={view === "resumen"} + /> +
+
+ {( + [ + { key: "movimientos" as View, label: "Movimientos" }, + { key: "resumen" as View, label: "Resumen por periodo" }, + ] + ).map((v) => ( + + ))} +
+
+ + {view === "movimientos" && ( +
+ + + + + + + + + + + {filtered && ( + + )} +
+ )} + + {view === "resumen" && facets && facets.years.length > 0 && ( +
+ +
+ )} + + {view === "movimientos" && movements && !loading && !error && ( +
+ {movements.total === 0 + ? "Sin resultados" + : `${formatNumber(movements.total)} ${ + movements.total === 1 ? "movimiento" : "movimientos" + }`} + {query ? ` para “${query}”` : ""} +
+ )} + + {view === "movimientos" && movements && !loading && ( + + )} + + {error ? ( +
+ {error} +
+ ) : loading ? ( + + ) : view === "resumen" ? ( + + ) : movements && movements.total === 0 ? ( + + ) : ( + <> +
+
+ + + + + + + + + + + + {movements?.items.map((m) => ( + + ))} + +
FechaCheque / ref.Beneficiario / conceptoOrigenMonto
+
+
+ {movements && movements.pageCount > 1 && ( + + )} + + )} + + ); +} + +/** 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 ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+ + +
+
{formatMoney(stats.net, "MXN")}
+ {/* Not the bank balance: the register carries no opening balance. */} +
Movimiento neto acumulado
+
+
+
{formatNumber(stats.movements)}
+
+ Movimientos · {formatDate(stats.firstMovement)} a{" "} + {formatDate(stats.lastMovement)} +
+
+ +
+ ); +} + +/** 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 ( +
+
+ MXN + + + {formatMoney(totals.income, "MXN")} + {" "} + en ingresos · {formatNumber(totals.incomeCount)} + + + + {formatMoney(totals.expense, "MXN")} + {" "} + en egresos · {formatNumber(totals.expenseCount)} + + + Neto {formatMoney(totals.net, "MXN")} + + {totals.voidCount > 0 && ( + {formatNumber(totals.voidCount)} cancelados + )} +
+
+ ); +} + +function BankRow({ m }: { m: BankListItem }) { + return ( + + + {formatDate(m.transactionDate)} + + + {m.reference || "—"} + {!m.cleared && ( +
Sin operar
+ )} + + + {m.concept || Sin concepto} + {m.notes &&
Nota: {m.notes}
} + + {bankSourceLabel(m.source)} + + + {m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")} + +
{bankDirectionLabel(m.direction)}
+ + + ); +} + +/** + * 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 ( + <> +
+
+ + + + + + + + + + + + + {summary.years.map((r) => ( + onPickYear(r.period)} + style={{ cursor: "pointer" }} + className={year === r.period ? "selected" : undefined} + > + + + + + + + + ))} + +
AñoMovimientosIngresosEgresosNetoAcumulado
+ {r.period} + {formatNumber(r.count)} + + {formatMoney(r.income, "MXN")} + + + + {formatMoney(r.expense, "MXN")} + + + + {formatMoney(r.net, "MXN")} + + {formatMoney(r.cumulative, "MXN")}
+
+
+ +

+ 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. +

+ + {year && summary.months.length > 0 && ( +
+
+ +

Meses de {year}

+ + abre en {formatMoney(summary.opening, "MXN")} + +
+
+
+ + + + + + + + + + + + + {summary.months.map((r) => ( + + + + + + + + + ))} + +
MesMovimientosIngresosEgresosNetoAcumulado
{monthName(r.period)}{formatNumber(r.count)} + + {formatMoney(r.income, "MXN")} + + + + {formatMoney(r.expense, "MXN")} + + + + {formatMoney(r.net, "MXN")} + + + {formatMoney(r.cumulative, "MXN")} +
+
+
+
+ )} + + ); +} + +function Pager({ + page, + pageCount, + onChange, +}: { + page: number; + pageCount: number; + onChange: (p: number) => void; +}) { + return ( + + ); +} + +function ListSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); +} + +function EmptyState({ query }: { query: string }) { + return ( +
+
+ ⌕ +
+

Sin resultados

+

+ {query + ? `No encontramos movimientos para “${query}”.` + : "No hay movimientos que coincidan con los filtros."} +

+
+ ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index f8f6505..63bc03d 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1344,6 +1344,11 @@ button { .tx-table tr:hover td { 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 { white-space: nowrap; } diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index dbfe530..485e45c 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -16,6 +16,7 @@ const NAV = [ { href: "/servicios", label: "Propiedades" }, { href: "/polizas", label: "Pólizas" }, { href: "/estado-cuenta", label: "Estado de cuenta" }, + { href: "/banco", label: "Chequera" }, ]; export function AppShell({ children }: { children: ReactNode }) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c00a804..e65d1b4 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 { export function getStatement(customerId: string): Promise { return apiFetch(`/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 { + 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(`/bank${qs ? `?${qs}` : ""}`); +} + +export function getBankStats(): Promise { + return apiFetch("/bank/stats"); +} + +export function getBankFacets(): Promise { + return apiFetch("/bank/facets"); +} + +export function getBankSummary(year?: number): Promise { + return apiFetch(`/bank/summary${year ? `?year=${year}` : ""}`); +} diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index b71e5d7..dea0cf5 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -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 = { + 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 = { + "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( diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index a369800..1c9aefa 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -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 1–12 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; +}