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>
526 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|