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
+78 -9
View File
@@ -6,9 +6,11 @@ import type {
BalanceFilter,
BalanceListResponse,
BalanceSort,
BankAccount,
BankCleared,
BankDirection,
BankFacets,
BankInstitution,
BankListResponse,
BankSort,
BankStats,
@@ -19,8 +21,11 @@ import type {
BillingStats,
BusinessLine,
ByCheckResponse,
CreateBankAccountInput,
CreateBankInput,
CreateBankMovementInput,
CreateMovementInput,
UpdateBankAccountInput,
ResolveOutstandingInput,
CustomerDetail,
CustomerInput,
@@ -609,7 +614,13 @@ export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
/* ------------------------------------------------- Bank register (chequera) */
/**
* Every read below is scoped to one chequera. `bankAccountId` is required, not
* defaulted to "all accounts": the office's registers are in different
* currencies, and a combined total would be a figure that never existed.
*/
export interface BankQuery {
bankAccountId: string;
query?: string;
page?: number;
pageSize?: number;
@@ -622,7 +633,7 @@ export interface BankQuery {
}
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
const params = new URLSearchParams();
const params = new URLSearchParams({ bankAccountId: q.bankAccountId });
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
@@ -631,20 +642,78 @@ export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
if (q.from) params.set("from", q.from);
if (q.to) params.set("to", q.to);
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
return apiFetch<BankListResponse>(`/bank?${params.toString()}`);
}
export function getBankStats(): Promise<BankStats> {
return apiFetch<BankStats>("/bank/stats");
export function getBankStats(bankAccountId: string): Promise<BankStats> {
return apiFetch<BankStats>(
`/bank/stats?bankAccountId=${encodeURIComponent(bankAccountId)}`,
);
}
export function getBankFacets(): Promise<BankFacets> {
return apiFetch<BankFacets>("/bank/facets");
export function getBankFacets(bankAccountId: string): Promise<BankFacets> {
return apiFetch<BankFacets>(
`/bank/facets?bankAccountId=${encodeURIComponent(bankAccountId)}`,
);
}
export function getBankSummary(year?: number): Promise<BankSummary> {
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
export function getBankSummary(
bankAccountId: string,
year?: number,
): Promise<BankSummary> {
const params = new URLSearchParams({ bankAccountId });
if (year) params.set("year", String(year));
return apiFetch<BankSummary>(`/bank/summary?${params.toString()}`);
}
/* --------------------------------------------- Chequera accounts (catalog) */
/** The account picker's source. Includes closed accounts, which stay readable. */
export function listBankAccounts(): Promise<BankAccount[]> {
return apiFetch<BankAccount[]>("/bank/accounts");
}
export function listBankInstitutions(): Promise<BankInstitution[]> {
return apiFetch<BankInstitution[]>("/bank/banks");
}
export function createBankInstitution(
input: CreateBankInput,
): Promise<BankInstitution> {
return apiFetch<BankInstitution>("/bank/banks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateBankInstitution(
id: string,
input: Partial<CreateBankInput>,
): Promise<BankInstitution> {
return apiFetch<BankInstitution>(`/bank/banks/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function createBankAccount(
input: CreateBankAccountInput,
): Promise<unknown> {
return apiFetch("/bank/accounts", {
method: "POST",
body: JSON.stringify(input),
});
}
/** No `currency` — an account's booked movements are denominated in it. */
export function updateBankAccount(
id: string,
input: UpdateBankAccountInput,
): Promise<unknown> {
return apiFetch(`/bank/accounts/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
+52 -4
View File
@@ -19,6 +19,7 @@ export type Ability =
| "ledger:void"
| "bank:create"
| "bank:void"
| "bank:manage-accounts"
| "lookup:manage"
| "user:manage"
| "db:manage";
@@ -1001,12 +1002,57 @@ export interface CustomerInput {
/* ------------------------------------------------- Bank register (chequera) */
/**
* The office's own checking account. Single-currency (MXN) and with no customer
* link — see `bank.service.ts`. Positive is a deposit, negative a payment, and
* exactly zero a cancelled cheque.
* The office's own checking accounts — one register per chequera, no customer
* link. See `bank.service.ts`. Positive is a deposit, negative a payment, and
* exactly zero a cancelled cheque. Every figure below belongs to exactly one
* `BankAccount` and is denominated in that account's currency; two accounts'
* figures are never combined.
*/
export type BankDirection = "income" | "expense" | "void";
/** A bank the office holds chequeras at. */
export interface BankInstitution {
id: string;
name: string;
/** "MX" | "US" — informational. */
country: string | null;
}
/** One chequera. Its `currency` is what every figure on the page is read in. */
export interface BankAccount {
id: string;
label: string;
currency: Currency;
/** Soft hint about which line of business it serves; never enforced. */
businessLine: TransactionDomain | null;
/** Closed accounts stay readable but take no new movements. */
active: boolean;
bankId: string;
bankName: string;
bankCountry: string | null;
}
export interface CreateBankInput {
name: string;
country?: string;
}
export interface CreateBankAccountInput {
bankId: string;
label: string;
/** Fixed at creation — an account's booked history is denominated in it. */
currency: Currency;
businessLine?: TransactionDomain;
active?: boolean;
}
export interface UpdateBankAccountInput {
bankId?: string;
label?: string;
businessLine?: TransactionDomain;
active?: boolean;
}
export type BankCleared = "cleared" | "pending";
export type BankSort =
@@ -1038,8 +1084,10 @@ export interface BankListItem {
}
/** Payload for POST /bank — a new chequera movement. Sign convention: positive
* = ingreso, negative = egreso. MXN only. */
* = ingreso, negative = egreso. The currency comes from the account. */
export interface CreateBankMovementInput {
/** Which chequera it lands in. Required. */
bankAccountId: string;
amount: number;
transactionDate: string;
concept?: string;