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>
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user