Files
jorgecuadros-platform/apps/web/src/app/banco/cuentas/page.tsx
T
rmancinasandClaude Opus 5 9ba5d2d09a
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m43s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m59s
feat(bank): multi-bank chequera — required bankAccountId, per-account scoping
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>
2026-07-27 23:54:16 -07:00

526 lines
15 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import {
createBankAccount,
createBankInstitution,
listBankAccounts,
listBankInstitutions,
updateBankAccount,
updateBankInstitution,
} from "@/lib/api";
import { domainLabel } from "@/lib/labels";
import type {
BankAccount,
BankInstitution,
Currency,
TransactionDomain,
} from "@/lib/types";
/**
* Chequera accounts admin — docs/RECEIPT_CAPTURE_SPEC.md §3.
*
* Two levels: the bank (institution) and the accounts held at it. Opening an
* account is rare and consequential — its currency is what every movement
* booked into it is denominated in, and it can't be changed afterwards without
* silently re-denominating history, so the edit form deliberately has no
* currency field.
*
* Accounts are never deleted: `bank_transactions.bankAccountId` is a required
* FK, so a used account can't be removed without destroying its register.
* Closing one (`active: false`) hides it from new captures while leaving the
* history readable, matching this app's never-hard-delete convention.
*/
const CURRENCIES: Currency[] = ["MXN", "USD"];
const BUSINESS_LINES: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
export default function CuentasChequeraPage() {
return (
<AppShell>
<Cuentas />
</AppShell>
);
}
function Cuentas() {
const canEdit = useCan("bank:manage-accounts");
const [banks, setBanks] = useState<BankInstitution[] | null>(null);
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
const [error, setError] = useState<string | null>(null);
function reload() {
Promise.all([listBankInstitutions(), listBankAccounts()])
.then(([b, a]) => {
setBanks(b);
setAccounts(a);
})
.catch((e) => setError(e?.message ?? "No se pudieron cargar las cuentas."));
}
useEffect(reload, []);
if (!canEdit) {
return (
<>
<div className="page-head">
<h1 className="page-title">Cuentas de chequera</h1>
</div>
<div className="state-box state-error">
No tiene permisos para administrar cuentas bancarias.
</div>
</>
);
}
return (
<>
<div className="page-head">
<p className="eyebrow">
<Link href="/banco">Chequera</Link>
</p>
<h1 className="page-title">Cuentas de chequera</h1>
<p className="section-note">
Cada cuenta es una chequera física y se lleva por separado. La moneda
se fija al darla de alta porque todos sus movimientos quedan
registrados en ella; para cambiarla hay que abrir otra cuenta. Las
cuentas no se eliminan: se cierran, y su historial sigue consultable.
</p>
</div>
{error && <div className="state-box state-error">{error}</div>}
{!banks || !accounts ? (
<div className="empty-inline">
<span className="spinner" aria-label="Cargando" />
</div>
) : (
<>
<BanksSection banks={banks} onChanged={reload} />
<AccountsSection
banks={banks}
accounts={accounts}
onChanged={reload}
/>
</>
)}
</>
);
}
/* ------------------------------------------------------------------ banks */
function BanksSection({
banks,
onChanged,
}: {
banks: BankInstitution[];
onChanged: () => void;
}) {
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState("");
const [country, setCountry] = useState("");
const [busy, setBusy] = useState(false);
function startAdd() {
setEditingId(null);
setAdding(true);
setName("");
setCountry("");
}
function startEdit(b: BankInstitution) {
setAdding(false);
setEditingId(b.id);
setName(b.name);
setCountry(b.country ?? "");
}
function cancel() {
setAdding(false);
setEditingId(null);
}
async function submit() {
if (!name.trim()) {
window.alert("El nombre del banco es obligatorio.");
return;
}
setBusy(true);
try {
const payload = { name: name.trim(), country: country.trim() };
if (editingId) await updateBankInstitution(editingId, payload);
else await createBankInstitution(payload);
cancel();
onChanged();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo guardar el banco.");
} finally {
setBusy(false);
}
}
const editor = (
<div className="child-editor">
<div className="form-grid">
<label className="field">
<span className="field-label">
Banco <span aria-hidden>*</span>
</span>
<input
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ej. Scotiabank"
/>
</label>
<label className="field">
<span className="field-label">País</span>
<input
className="input"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="MX / US"
/>
</label>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-primary"
onClick={submit}
disabled={busy}
>
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
</button>
<button type="button" className="btn btn-ghost" onClick={cancel}>
Cancelar
</button>
</div>
</div>
);
return (
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
<div className="child-head">
<h3 className="section-title" style={{ margin: 0 }}>
Bancos
<span className="section-count"> {banks.length}</span>
</h3>
{!adding && editingId === null && (
<button type="button" className="btn btn-outline" onClick={startAdd}>
+ Agregar
</button>
)}
</div>
{banks.length === 0 && !adding ? (
<div className="empty-inline">Sin bancos registrados.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Banco</th>
<th>País</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{adding && (
<tr>
<td colSpan={3}>{editor}</td>
</tr>
)}
{banks.map((b) =>
editingId === b.id ? (
<tr key={b.id}>
<td colSpan={3}>{editor}</td>
</tr>
) : (
<tr key={b.id}>
<td>{b.name}</td>
<td>{b.country || "—"}</td>
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => startEdit(b)}
>
Editar
</button>
</div>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
);
}
/* --------------------------------------------------------------- accounts */
function AccountsSection({
banks,
accounts,
onChanged,
}: {
banks: BankInstitution[];
accounts: BankAccount[];
onChanged: () => void;
}) {
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [bankId, setBankId] = useState("");
const [label, setLabel] = useState("");
const [currency, setCurrency] = useState<Currency>("MXN");
const [businessLine, setBusinessLine] = useState<string>("");
const [active, setActive] = useState(true);
const [busy, setBusy] = useState(false);
function startAdd() {
setEditingId(null);
setAdding(true);
setBankId(banks[0]?.id ?? "");
setLabel("");
setCurrency("MXN");
setBusinessLine("");
setActive(true);
}
function startEdit(a: BankAccount) {
setAdding(false);
setEditingId(a.id);
setBankId(a.bankId);
setLabel(a.label);
setCurrency(a.currency);
setBusinessLine(a.businessLine ?? "");
setActive(a.active);
}
function cancel() {
setAdding(false);
setEditingId(null);
}
async function submit() {
if (!bankId) {
window.alert("Selecciona el banco de la cuenta.");
return;
}
if (!label.trim()) {
window.alert("El nombre de la cuenta es obligatorio.");
return;
}
setBusy(true);
try {
const line = businessLine
? (businessLine as TransactionDomain)
: undefined;
if (editingId) {
// No `currency`: see the file header.
await updateBankAccount(editingId, {
bankId,
label: label.trim(),
businessLine: line,
active,
});
} else {
await createBankAccount({
bankId,
label: label.trim(),
currency,
businessLine: line,
active,
});
}
cancel();
onChanged();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo guardar la cuenta.");
} finally {
setBusy(false);
}
}
const editor = (
<div className="child-editor">
<div className="form-grid">
<label className="field">
<span className="field-label">
Banco <span aria-hidden>*</span>
</span>
<select
className="select"
value={bankId}
onChange={(e) => setBankId(e.target.value)}
>
<option value=""></option>
{banks.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">
Nombre de la cuenta <span aria-hidden>*</span>
</span>
<input
className="input"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ej. Seguros — Bank of America (USD)"
/>
</label>
<label className="field">
<span className="field-label">
Moneda <span aria-hidden>*</span>
</span>
<select
className="select"
value={currency}
disabled={editingId !== null}
onChange={(e) => setCurrency(e.target.value as Currency)}
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
{editingId !== null && (
<span className="section-note">
No se puede cambiar: los movimientos ya registrados están en esta
moneda.
</span>
)}
</label>
<label className="field">
<span className="field-label">Línea de negocio</span>
<select
className="select"
value={businessLine}
onChange={(e) => setBusinessLine(e.target.value)}
>
<option value="">Sin asignar</option>
{BUSINESS_LINES.map((d) => (
<option key={d} value={d}>
{domainLabel(d)}
</option>
))}
</select>
<span className="section-note">
Referencia nada más: una chequera puede pagar de varias líneas.
</span>
</label>
<label
className="field"
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<input
type="checkbox"
checked={active}
onChange={(e) => setActive(e.target.checked)}
/>
<span className="field-label" style={{ margin: 0 }}>
Cuenta abierta
</span>
</label>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-primary"
onClick={submit}
disabled={busy}
>
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
</button>
<button type="button" className="btn btn-ghost" onClick={cancel}>
Cancelar
</button>
</div>
</div>
);
return (
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
<div className="child-head">
<h3 className="section-title" style={{ margin: 0 }}>
Cuentas
<span className="section-count"> {accounts.length}</span>
</h3>
{!adding && editingId === null && banks.length > 0 && (
<button type="button" className="btn btn-outline" onClick={startAdd}>
+ Agregar
</button>
)}
</div>
{banks.length === 0 ? (
<div className="empty-inline">
Registra primero el banco donde está la cuenta.
</div>
) : accounts.length === 0 && !adding ? (
<div className="empty-inline">Sin cuentas registradas.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cuenta</th>
<th>Banco</th>
<th>Moneda</th>
<th>Línea</th>
<th>Estatus</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{adding && (
<tr>
<td colSpan={6}>{editor}</td>
</tr>
)}
{accounts.map((a) =>
editingId === a.id ? (
<tr key={a.id}>
<td colSpan={6}>{editor}</td>
</tr>
) : (
<tr key={a.id}>
<td>{a.label}</td>
<td>{a.bankName}</td>
<td className="mono">{a.currency}</td>
<td>{a.businessLine ? domainLabel(a.businessLine) : "—"}</td>
<td>{a.active ? "Abierta" : "Cerrada"}</td>
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => startEdit(a)}
>
Editar
</button>
<Link href="/banco" className="btn btn-ghost">
Ver movimientos
</Link>
</div>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
);
}