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:
+78
-9
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user