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
+53 -7
View File
@@ -10,6 +10,7 @@ import {
getPolicyStats,
getPropertyStats,
getStats,
listBankAccounts,
} from "@/lib/api";
import { useAuth } from "@/lib/abilities";
import {
@@ -21,6 +22,7 @@ import {
trustStatusLabel,
} from "@/lib/labels";
import type {
BankAccount,
BankStats,
BillingStats,
CustomerStats,
@@ -41,7 +43,29 @@ interface DashboardData {
policies: PolicyStats | null;
properties: PropertyStats | null;
billing: BillingStats | null;
/** Figures for ONE chequera — see `bankAccount` for which. */
bank: BankStats | null;
/**
* The chequera the card above is reading. The office keeps more than one, in
* different currencies, so this card shows the default account rather than a
* cross-account total, which would be a figure that never existed.
*/
bankAccount: BankAccount | null;
bankAccountCount: number;
}
/** Same default as /banco, so the two screens agree on which chequera opens. */
function defaultAccount(accounts: BankAccount[]): BankAccount | null {
const remembered =
typeof window !== "undefined"
? window.localStorage.getItem("banco.bankAccountId")
: null;
return (
accounts.find((a) => a.id === remembered) ??
accounts.find((a) => a.active) ??
accounts[0] ??
null
);
}
function HomeDashboard() {
@@ -52,25 +76,42 @@ function HomeDashboard() {
properties: null,
billing: null,
bank: null,
bankAccount: null,
bankAccountCount: 0,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
// The chequera figures need an account id, so that read is a two-step:
// list the accounts, then ask the default one for its stats.
const bank = listBankAccounts().then(async (accounts) => {
const account = defaultAccount(accounts);
if (!account) return { account: null, stats: null, count: 0 };
return {
account,
stats: await getBankStats(account.id),
count: accounts.length,
};
});
Promise.allSettled([
getStats(),
getPolicyStats(),
getPropertyStats(),
getBillingStats(),
getBankStats(),
bank,
]).then((results) => {
if (!alive) return;
const bankResult = results[4].status === "fulfilled" ? results[4].value : null;
setData({
customers: results[0].status === "fulfilled" ? results[0].value : null,
policies: results[1].status === "fulfilled" ? results[1].value : null,
properties: results[2].status === "fulfilled" ? results[2].value : null,
billing: results[3].status === "fulfilled" ? results[3].value : null,
bank: results[4].status === "fulfilled" ? results[4].value : null,
bank: bankResult?.stats ?? null,
bankAccount: bankResult?.account ?? null,
bankAccountCount: bankResult?.count ?? 0,
});
setLoading(false);
});
@@ -260,22 +301,27 @@ function HomeDashboard() {
loading={loading}
title="Chequera del despacho"
primary={
data.bank ? (
data.bank && data.bankAccount ? (
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
{formatMoney(data.bank.net, "MXN")}
{formatMoney(data.bank.net, data.bankAccount.currency)}
</span>
) : (
"—"
)
}
// Names the account, because this is one chequera's figure and the
// office has more than one — they are never added together.
sub={
data.bank
? balancePhrase(data.bank.net)
data.bank && data.bankAccount
? `${data.bankAccount.label} · ${balancePhrase(data.bank.net)}`
: undefined
}
meta={
data.bank
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes`
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes` +
(data.bankAccountCount > 1
? ` · ${formatNumber(data.bankAccountCount)} cuentas en total`
: "")
: undefined
}
/>