feat(bank): multi-bank chequera — required bankAccountId, per-account scoping
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m43s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m59s

The office keeps more than one operating account (Utilities banks in MXN,
Seguros in USD), but bank_transactions was a single implicit MXN register by
design. Adds Bank/BankAccount and makes every read and write in the module
scoped to exactly one account.

Schema:
- Bank / BankAccount. Currency is fixed per account and BankTransaction has
  no currency column of its own — a movement inherits its account's, the way
  a real bank account doesn't mix currencies.
- BankTransaction.bankAccountId, required. A movement with no known account
  isn't reconcilable against a statement.
- @@index([bankAccountId, transactionDate]): every read now filters by
  account and orders/groups by date.

Migration:
- backfill_bank_accounts.py seeds Scotiabank + "Utilities — Scotiabank (MXN)"
  and backfills all 22,669 existing rows onto it, then promotes the column to
  NOT NULL and attaches the FK. Standalone because prisma db push cannot add
  a required column to a populated table. Idempotent; re-running once a second
  account exists does not re-point rows.
- run_all.py runs it (both modes) before transform_bank.py, which now resolves
  the account by label and fails fast if it is missing.

API:
- ?bankAccountId= required on list/stats/facets/summary — not optional with an
  "all accounts" default, since summing an MXN and a USD register repeats the
  currency-collapsing mistake the billing module exists to prevent. Missing is
  400, unknown is 404.
- facets() had no account clause at all and summary() has two raw-SQL rollups;
  all three are now parameterised. Scoping only one of summary's queries would
  leave the year list and its drill-down describing different books.
- New bank/accounts + bank/banks sub-resource under a MANAGER
  bank:manage-accounts ability. currency is absent from the update DTO: booked
  movements are denominated in it, so editing would re-denominate history.
  Capture into a closed account is rejected.

Web:
- /banco gains an account picker (remembered per browser) and reads every
  figure in the selected account's currency; the "single currency (MXN)"
  doc-comment and the hardcoded MXN formatting are gone.
- New /banco/cuentas for banks and accounts. Accounts are closed, never
  deleted — the FK is required, so deleting one would destroy its register.
- /inicio's chequera card names the account it is reading instead of implying
  a single register.

Verified against dev + browser: a second USD account showed full read/write
isolation from the MXN register, whose totals were unchanged (22,669
movements, net 1,014,266.97).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:54:16 -07:00
co-authored by Claude Opus 5
parent c100dfa224
commit 9ba5d2d09a
18 changed files with 1620 additions and 103 deletions
+243 -47
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
@@ -8,6 +9,7 @@ import {
getBankFacets,
getBankStats,
getBankSummary,
listBankAccounts,
listBankMovements,
voidBankMovement,
} from "@/lib/api";
@@ -22,6 +24,7 @@ import {
monthName,
} from "@/lib/labels";
import type {
BankAccount,
BankCleared,
BankDirection,
BankFacets,
@@ -32,23 +35,28 @@ import type {
BankSummary,
BankTotals,
CreateBankMovementInput,
Currency,
} from "@/lib/types";
/**
* Bank register (chequera) browser — plan step 7.
* Bank register (chequera) browser — plan step 7, multi-account since the
* step-11 multi-bank work.
*
* This is the office's OWN checking account, not customer money. It is a
* This is the office's OWN checking accounts, 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:
* Two views, both scoped to the ONE account picked at the top:
* - "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.
* with the running net movement since the register opened.
*
* 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.
* Every amount is read in the selected account's currency. There is no "all
* accounts" option on purpose — Utilities banks in MXN and Seguros in USD, so
* one combined figure would be a number that never existed, exactly what
* /estado-cuenta's per-currency rule avoids. See the module header in
* `bank.service.ts` for why there is no category/ramo filter.
*/
type View = "movimientos" | "resumen";
@@ -81,9 +89,18 @@ export default function BancoPage() {
);
}
/** Remembers the last chequera a person looked at, per browser. */
const ACCOUNT_KEY = "banco.bankAccountId";
function BankBrowser() {
const canCapture = useCan("bank:create");
const canVoid = useCan("bank:void");
const canManageAccounts = useCan("bank:manage-accounts");
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
const [accountId, setAccountId] = useState<string | null>(null);
const [accountsError, setAccountsError] = useState<string | null>(null);
const [stats, setStats] = useState<BankStats | null>(null);
const [facets, setFacets] = useState<BankFacets | null>(null);
const [view, setView] = useState<View>("movimientos");
@@ -104,16 +121,66 @@ function BankBrowser() {
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const account = accounts?.find((a) => a.id === accountId) ?? null;
const currency = account?.currency ?? "MXN";
// Accounts load first: nothing else on this page can be requested until one
// is selected, because every read is scoped to exactly one chequera.
useEffect(() => {
getBankStats().then(setStats).catch(() => setStats(null));
getBankFacets().then(setFacets).catch(() => setFacets(null));
listBankAccounts()
.then((rows) => {
setAccounts(rows);
const remembered =
typeof window !== "undefined"
? window.localStorage.getItem(ACCOUNT_KEY)
: null;
const pick =
rows.find((a) => a.id === remembered) ??
rows.find((a) => a.active) ??
rows[0];
setAccountId(pick?.id ?? null);
if (rows.length === 0) setLoading(false);
})
.catch((e) => {
setAccountsError(e?.message ?? "No se pudieron cargar las cuentas.");
setLoading(false);
});
}, []);
function pickAccount(id: string) {
setAccountId(id);
if (typeof window !== "undefined")
window.localStorage.setItem(ACCOUNT_KEY, id);
// The previous account's figures must not linger while the new ones load.
setStats(null);
setFacets(null);
setMovements(null);
setSummary(null);
setSummaryYear(null);
}
const refreshStats = useCallback(() => {
if (!accountId) return;
getBankStats(accountId)
.then(setStats)
.catch(() => setStats(null));
}, [accountId]);
useEffect(() => {
if (!accountId) return;
refreshStats();
getBankFacets(accountId)
.then(setFacets)
.catch(() => setFacets(null));
}, [accountId, refreshStats]);
const runSearch = useCallback(
(p: number) => {
if (!accountId) return;
setLoading(true);
setError(null);
listBankMovements({
bankAccountId: accountId,
query: query || undefined,
direction: direction || undefined,
cleared: cleared || undefined,
@@ -132,23 +199,23 @@ function BankBrowser() {
setLoading(false);
});
},
[query, direction, cleared, from, to, sort],
[accountId, query, direction, cleared, from, to, sort],
);
useEffect(() => {
if (view !== "movimientos") return;
if (view !== "movimientos" || !accountId) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(1), 280);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [runSearch, view]);
}, [runSearch, view, accountId]);
useEffect(() => {
if (view !== "resumen") return;
if (view !== "resumen" || !accountId) return;
setLoading(true);
setError(null);
getBankSummary(summaryYear ?? undefined)
getBankSummary(accountId, summaryYear ?? undefined)
.then((res) => {
setSummary(res);
setLoading(false);
@@ -157,7 +224,7 @@ function BankBrowser() {
setError(e?.message ?? "No se pudo cargar el resumen.");
setLoading(false);
});
}, [view, summaryYear]);
}, [view, summaryYear, accountId]);
function goToPage(p: number) {
runSearch(p);
@@ -194,20 +261,71 @@ function BankBrowser() {
setSort("date_desc");
}
if (accountsError) {
return (
<>
<div className="page-head">
<h1 className="page-title">Chequera</h1>
</div>
<div className="state-error" role="alert">
{accountsError}
</div>
</>
);
}
// No chequera on file: the register has nothing it could be scoped to.
if (accounts && accounts.length === 0) {
return (
<>
<div className="page-head">
<p className="eyebrow">Cuentas propias de la oficina</p>
<h1 className="page-title">Chequera</h1>
</div>
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin cuentas registradas</h3>
<p>
{canManageAccounts ? (
<>
Registra una cuenta bancaria en{" "}
<Link href="/banco/cuentas">Cuentas de chequera</Link> para
empezar a capturar movimientos.
</>
) : (
"Pide a un administrador que registre una cuenta bancaria."
)}
</p>
</div>
</>
);
}
return (
<>
<div className="page-head rise">
<p className="eyebrow">Cuenta propia de la oficina</p>
<h1 className="page-title">Chequera</h1>
<AccountPicker
accounts={accounts}
accountId={accountId}
onPick={pickAccount}
canManageAccounts={canManageAccounts}
/>
<BankStatStrip
stats={stats}
currency={currency}
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.
Movimientos de{" "}
<strong>{account ? account.label : "la cuenta seleccionada"}</strong>,
en {currency}. Cada cuenta se lee por separado: las cifras de dos
chequeras nunca se suman, igual que los saldos por moneda del estado
de cuenta. Tampoco forman parte del estado de cuenta de los clientes.
</p>
<div style={{ marginTop: 8 }}>
<ContextReports
@@ -252,7 +370,7 @@ function BankBrowser() {
</button>
))}
</div>
{view === "movimientos" && canCapture && (
{view === "movimientos" && canCapture && account?.active && (
<button
type="button"
className="btn btn-primary"
@@ -263,12 +381,20 @@ function BankBrowser() {
)}
</div>
{view === "movimientos" && captureOpen && (
{account && !account.active && (
<div className="section-note">
Esta cuenta está cerrada: su historial se consulta, pero no admite
movimientos nuevos.
</div>
)}
{view === "movimientos" && captureOpen && account && (
<BankCaptureForm
account={account}
onSaved={() => {
setCaptureOpen(false);
runSearch(movements?.page ?? 1);
getBankStats().then(setStats).catch(() => setStats(null));
refreshStats();
}}
onCancel={() => setCaptureOpen(false)}
/>
@@ -389,7 +515,7 @@ function BankBrowser() {
)}
{view === "movimientos" && movements && !loading && (
<FilteredTotals totals={movements.totals} />
<FilteredTotals totals={movements.totals} currency={currency} />
)}
{error ? (
@@ -402,6 +528,7 @@ function BankBrowser() {
<SummaryView
summary={summary}
year={summaryYear}
currency={currency}
onPickYear={pickYear}
/>
) : movements && movements.total === 0 ? (
@@ -430,12 +557,11 @@ function BankBrowser() {
<BankRow
key={m.id}
m={m}
currency={currency}
canVoid={canVoid}
onVoided={() => {
runSearch(movements?.page ?? 1);
getBankStats()
.then(setStats)
.catch(() => setStats(null));
refreshStats();
}}
/>
))}
@@ -456,13 +582,66 @@ function BankBrowser() {
);
}
/**
* Which chequera the whole page is reading. There is no "todas las cuentas"
* option and there must not be one — see the file header.
*/
function AccountPicker({
accounts,
accountId,
onPick,
canManageAccounts,
}: {
accounts: BankAccount[] | null;
accountId: string | null;
onPick: (id: string) => void;
canManageAccounts: boolean;
}) {
if (!accounts) {
return (
<div className="skeleton" style={{ height: 34, width: 260, marginTop: 12 }} />
);
}
return (
<div
className="filter-row"
style={{ marginTop: 12, alignItems: "flex-end" }}
>
<label className="filter-field">
<span className="filter-label">Cuenta</span>
<select
className="input select"
value={accountId ?? ""}
onChange={(e) => onPick(e.target.value)}
aria-label="Cuenta de chequera"
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label} · {a.currency}
{a.active ? "" : " (cerrada)"}
</option>
))}
</select>
</label>
{canManageAccounts && (
<Link href="/banco/cuentas" className="btn btn-ghost">
Administrar cuentas
</Link>
)}
</div>
);
}
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */
function BankStatStrip({
stats,
currency,
direction,
onPickDirection,
}: {
stats: BankStats | null;
currency: Currency;
direction: BankDirection | "";
onPickDirection: (d: BankDirection) => void;
}) {
@@ -493,7 +672,7 @@ function BankStatStrip({
aria-pressed={direction === "income"}
>
<div className="stat-value tx-amount pos">
{formatMoney(stats.income, "MXN")}
{formatMoney(stats.income, currency)}
</div>
<div className="stat-label">
En ingresos · {formatNumber(stats.incomeCount)} movimientos
@@ -508,14 +687,14 @@ function BankStatStrip({
aria-pressed={direction === "expense"}
>
<div className="stat-value tx-amount neg">
{formatMoney(stats.expense, "MXN")}
{formatMoney(stats.expense, currency)}
</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>
<div className="stat-value">{formatMoney(stats.net, currency)}</div>
{/* Not the bank balance: the register carries no opening balance. */}
<div className="stat-label">Movimiento neto acumulado</div>
</div>
@@ -544,27 +723,33 @@ function BankStatStrip({
}
/** Totals for everything the current filter matched, not just the page. */
function FilteredTotals({ totals }: { totals: BankTotals }) {
function FilteredTotals({
totals,
currency,
}: {
totals: BankTotals;
currency: Currency;
}) {
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 className="filtered-total-cur">{currency}</span>
<span>
<strong className="tx-amount pos">
{formatMoney(totals.income, "MXN")}
{formatMoney(totals.income, currency)}
</strong>{" "}
en ingresos · {formatNumber(totals.incomeCount)}
</span>
<span>
<strong className="tx-amount neg">
{formatMoney(totals.expense, "MXN")}
{formatMoney(totals.expense, currency)}
</strong>{" "}
en egresos · {formatNumber(totals.expenseCount)}
</span>
<span className="filtered-total-net">
Neto <strong>{formatMoney(totals.net, "MXN")}</strong>
Neto <strong>{formatMoney(totals.net, currency)}</strong>
</span>
{totals.voidCount > 0 && (
<span>{formatNumber(totals.voidCount)} cancelados</span>
@@ -576,10 +761,12 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
function BankRow({
m,
currency,
canVoid,
onVoided,
}: {
m: BankListItem;
currency: Currency;
canVoid: boolean;
onVoided: () => void;
}) {
@@ -620,7 +807,7 @@ function BankRow({
<td>{bankSourceLabel(m.source)}</td>
<td className="num">
<span className={`tx-amount ${bankTone(m.direction)}`}>
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")}
{m.direction === "void" ? "—" : formatMoney(m.amount, currency)}
</span>
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
</td>
@@ -650,10 +837,12 @@ function BankRow({
function SummaryView({
summary,
year,
currency,
onPickYear,
}: {
summary: BankSummary | null;
year: number | null;
currency: Currency;
onPickYear: (y: number) => void;
}) {
if (!summary) return null;
@@ -687,12 +876,12 @@ function SummaryView({
<td className="num">{formatNumber(r.count)}</td>
<td className="num">
<span className="tx-amount pos">
{formatMoney(r.income, "MXN")}
{formatMoney(r.income, currency)}
</span>
</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(r.expense, "MXN")}
{formatMoney(r.expense, currency)}
</span>
</td>
<td className="num">
@@ -701,10 +890,10 @@ function SummaryView({
Number(r.net) < 0 ? "neg" : "pos"
}`}
>
{formatMoney(r.net, "MXN")}
{formatMoney(r.net, currency)}
</span>
</td>
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td>
<td className="num mono">{formatMoney(r.cumulative, currency)}</td>
</tr>
))}
</tbody>
@@ -724,7 +913,7 @@ function SummaryView({
<span className="section-rule cuenta" />
<h2 className="section-title">Meses de {year}</h2>
<span className="section-count">
abre en {formatMoney(summary.opening, "MXN")}
abre en {formatMoney(summary.opening, currency)}
</span>
</div>
<div className="card">
@@ -747,12 +936,12 @@ function SummaryView({
<td className="num">{formatNumber(r.count)}</td>
<td className="num">
<span className="tx-amount pos">
{formatMoney(r.income, "MXN")}
{formatMoney(r.income, currency)}
</span>
</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(r.expense, "MXN")}
{formatMoney(r.expense, currency)}
</span>
</td>
<td className="num">
@@ -761,11 +950,11 @@ function SummaryView({
Number(r.net) < 0 ? "neg" : "pos"
}`}
>
{formatMoney(r.net, "MXN")}
{formatMoney(r.net, currency)}
</span>
</td>
<td className="num mono">
{formatMoney(r.cumulative, "MXN")}
{formatMoney(r.cumulative, currency)}
</td>
</tr>
))}
@@ -779,13 +968,16 @@ function SummaryView({
);
}
/** Inline capture form for a single chequera movement. Single currency (MXN);
* sign convention: positive = ingreso, negative = egreso. Booked rows are
* never edited — fix mistakes with voidBankMovement + a fresh capture. */
/** Inline capture form for a single chequera movement. The amount is in the
* selected account's currency; sign convention: positive = ingreso, negative
* = egreso. Booked rows are never edited — fix mistakes with voidBankMovement
* + a fresh capture. */
function BankCaptureForm({
account,
onSaved,
onCancel,
}: {
account: BankAccount;
onSaved: () => void;
onCancel: () => void;
}) {
@@ -818,6 +1010,7 @@ function BankCaptureForm({
}
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
const payload: CreateBankMovementInput = {
bankAccountId: account.id,
amount: signed,
transactionDate,
concept: s(concept),
@@ -843,9 +1036,12 @@ function BankCaptureForm({
<form onSubmit={submit}>
{error && <div className="state-box state-error">{error}</div>}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
<h2 className="section-title" style={{ marginBottom: 4 }}>
Capturar movimiento de chequera
</h2>
<p className="section-note" style={{ marginBottom: 14 }}>
Se registra en <strong>{account.label}</strong>, en {account.currency}.
</p>
<div className="form-grid">
<label className="field">
<span className="field-label">
@@ -874,7 +1070,7 @@ function BankCaptureForm({
</label>
<label className="field">
<span className="field-label">
Monto (MXN) <span aria-hidden>*</span>
Monto ({account.currency}) <span aria-hidden>*</span>
</span>
<input
className="input"