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
+4
View File
@@ -31,6 +31,7 @@ export type Ability =
| "ledger:void"
| "bank:create"
| "bank:void"
| "bank:manage-accounts"
| "lookup:manage"
| "user:manage"
| "db:manage";
@@ -50,6 +51,9 @@ export const ABILITY_MIN: Record<Ability, Role> = {
"ledger:void": "MANAGER",
"bank:create": "STAFF",
"bank:void": "MANAGER",
// Opening or renaming a chequera is rarer and higher-stakes than posting a
// movement into one — a wrong account silently mixes two sets of books.
"bank:manage-accounts": "MANAGER",
"lookup:manage": "MANAGER",
"user:manage": "ADMIN",
"db:manage": "ADMIN",
+47
View File
@@ -0,0 +1,47 @@
import {
IsBoolean,
IsIn,
IsOptional,
IsString,
MinLength,
} from "class-validator";
/** Mirrors the Prisma `Currency` enum; a chequera's is fixed at creation. */
export const BANK_CURRENCIES = ["MXN", "USD"] as const;
export type BankAccountCurrency = (typeof BANK_CURRENCIES)[number];
/** Mirrors `TransactionDomain`. A soft hint on the account, never enforced. */
export const BANK_BUSINESS_LINES = ["UTILITY", "INSURANCE", "TRUST"] as const;
export type BankBusinessLine = (typeof BANK_BUSINESS_LINES)[number];
export class CreateBankDto {
@IsString() @MinLength(1) name!: string;
/** "MX" | "US" — free text, informational only. */
@IsOptional() @IsString() country?: string;
}
export class UpdateBankDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsString() country?: string;
}
export class CreateBankAccountDto {
@IsString() @MinLength(1) bankId!: string;
@IsString() @MinLength(1) label!: string;
/**
* Immutable after creation (no field for it on the update DTO): every
* movement already booked into the account is denominated in it, so
* changing it would silently re-denominate history.
*/
@IsIn(BANK_CURRENCIES) currency!: BankAccountCurrency;
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
@IsOptional() @IsBoolean() active?: boolean;
}
export class UpdateBankAccountDto {
@IsOptional() @IsString() @MinLength(1) bankId?: string;
@IsOptional() @IsString() @MinLength(1) label?: string;
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
/** Closing an account hides it from the picker; its movements stay readable. */
@IsOptional() @IsBoolean() active?: boolean;
}
+5 -1
View File
@@ -2,10 +2,14 @@ import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-vali
/**
* A new bank-register movement. `amount` is signed: positive = ingreso,
* negative = egreso (the module's sign convention). Single currency (MXN).
* negative = egreso (the module's sign convention). The currency is the
* account's, not the movement's — `bankAccountId` decides it.
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
*/
export class CreateBankMovementDto {
/** Which chequera this lands in. Required — see BankAccount in the schema. */
@IsString() @MinLength(1) bankAccountId!: string;
@IsNumber() amount!: number;
@IsString() @MinLength(1) transactionDate!: string;
+96 -6
View File
@@ -3,6 +3,7 @@ import {
Controller,
Get,
Param,
Patch,
Post,
Query,
Req,
@@ -20,6 +21,12 @@ import {
BankSort,
} from "./bank.service";
import { CreateBankMovementDto } from "./bank-movement.dto";
import {
CreateBankAccountDto,
CreateBankDto,
UpdateBankAccountDto,
UpdateBankDto,
} from "./bank-account.dto";
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
const CLEARED: BankCleared[] = ["cleared", "pending"];
@@ -54,28 +61,108 @@ export class BankController {
return (req.user as { id: string }).id;
}
// --- accounts -------------------------------------------------------------
// Declared before the parameterised routes below so `/bank/accounts` can
// never be swallowed by a `:id`-shaped path.
/**
* The account picker. Readable by any authenticated user, VIEWER included —
* nothing else on this page can render until an account is chosen.
*/
@Get("accounts")
accounts() {
return this.bank.listAccounts();
}
@Get("banks")
banks() {
return this.bank.listBanks();
}
@Post("banks")
@RequireAbility("bank:manage-accounts")
async createBank(@Body() dto: CreateBankDto, @Req() req: Request) {
const row = await this.bank.createBank(dto);
void this.audit.log(this.actingId(req), "bank.bank.create", {
bankId: row.id,
name: row.name,
});
return row;
}
@Patch("banks/:id")
@RequireAbility("bank:manage-accounts")
async updateBank(
@Param("id") id: string,
@Body() dto: UpdateBankDto,
@Req() req: Request,
) {
const row = await this.bank.updateBank(id, dto);
void this.audit.log(this.actingId(req), "bank.bank.update", { bankId: id });
return row;
}
@Post("accounts")
@RequireAbility("bank:manage-accounts")
async createAccount(
@Body() dto: CreateBankAccountDto,
@Req() req: Request,
) {
const row = await this.bank.createAccount(dto);
void this.audit.log(this.actingId(req), "bank.account.create", {
bankAccountId: row.id,
label: row.label,
currency: row.currency,
});
return row;
}
@Patch("accounts/:id")
@RequireAbility("bank:manage-accounts")
async updateAccount(
@Param("id") id: string,
@Body() dto: UpdateBankAccountDto,
@Req() req: Request,
) {
const row = await this.bank.updateAccount(id, dto);
void this.audit.log(this.actingId(req), "bank.account.update", {
bankAccountId: id,
});
return row;
}
// --- register reads (all scoped to one account) ---------------------------
@Get("stats")
stats() {
return this.bank.stats();
async stats(@Query("bankAccountId") bankAccountId?: string) {
const account = await this.bank.requireAccount(bankAccountId);
return this.bank.stats(account.id);
}
@Get("facets")
facets() {
return this.bank.facets();
async facets(@Query("bankAccountId") bankAccountId?: string) {
const account = await this.bank.requireAccount(bankAccountId);
return this.bank.facets(account.id);
}
/** Year and month rollups with a running net-movement figure. */
@Get("summary")
summary(@Query("year") year?: string) {
async summary(
@Query("bankAccountId") bankAccountId?: string,
@Query("year") year?: string,
) {
const account = await this.bank.requireAccount(bankAccountId);
const y = Number(year);
return this.bank.summary(
account.id,
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
);
}
/** The register browser. */
@Get()
list(
async list(
@Query("bankAccountId") bankAccountId?: string,
@Query("query") query?: string,
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@@ -85,7 +172,9 @@ export class BankController {
@Query("to") to?: string,
@Query("sort") sort?: string,
) {
const account = await this.bank.requireAccount(bankAccountId);
return this.bank.list({
bankAccountId: account.id,
query,
page: Math.max(1, Number(page) || 1),
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
@@ -105,6 +194,7 @@ export class BankController {
const row = await this.bank.createMovement(dto);
void this.audit.log(this.actingId(req), "bank.create", {
bankTransactionId: row.id,
bankAccountId: row.bankAccountId,
amount: dto.amount,
});
return row;
+172 -18
View File
@@ -2,6 +2,12 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateBankMovementDto } from "./bank-movement.dto";
import {
CreateBankAccountDto,
CreateBankDto,
UpdateBankAccountDto,
UpdateBankDto,
} from "./bank-account.dto";
/**
* App-voided rows (voidedAt set) are reversed and must leave every
@@ -28,9 +34,18 @@ const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };
* expense and are excluded from both sides, the way the ~193 zero rows are
* in the customer ledger.
*
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here:
* `bank_transactions` has none, and every `amountInWords` on the egreso side is
* spelled out in PESOS. All figures in this module are MXN.
* ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
* than one chequera (Utilities banks in MXN, Seguros in USD), so every read
* path here is scoped to exactly one `bankAccountId` — never "all accounts".
* There is deliberately no currency column on `bank_transactions`: a movement
* inherits its account's, the way a real bank account doesn't mix currencies.
* Callers must therefore pass an account id; an unscoped total would sum MXN
* and USD into a figure that never existed, the same mistake the billing
* module's per-currency rule exists to prevent.
*
* The 22,669 migrated rows are all SCOTHIA = the Utilities MXN account
* (backfilled by `migration/backfill_bank_accounts.py`), and their
* `amountInWords` on the egreso side is spelled out in PESOS accordingly.
*
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
* rows and this module does not filter or group by it, because the data cannot
@@ -64,6 +79,8 @@ export type BankSort =
| "reference";
export interface BankListParams {
/** Which chequera to read. Required — see the module header. */
bankAccountId: string;
query?: string;
page: number;
pageSize: number;
@@ -98,7 +115,9 @@ export class BankService {
constructor(private readonly prisma: PrismaService) {}
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
const and: Prisma.BankTransactionWhereInput[] = [];
const and: Prisma.BankTransactionWhereInput[] = [
{ bankAccountId: p.bankAccountId },
];
if (p.query && p.query.trim()) {
const q = p.query.trim();
@@ -124,7 +143,9 @@ export class BankService {
});
}
return and.length ? { AND: and } : {};
// Never empty: the account clause above is always present, so no read can
// accidentally span every chequera.
return { AND: and };
}
private orderBy(
@@ -233,22 +254,25 @@ export class BankService {
};
}
/** Top-line figures for the bank page header. */
async stats() {
/** Top-line figures for the bank page header, for one chequera. */
async stats(bankAccountId: string) {
const account = { bankAccountId };
const [count, bounds, pending, transferred, totals] = await Promise.all([
this.prisma.bankTransaction.count({ where: NOT_VOIDED }),
this.prisma.bankTransaction.count({
where: { AND: [account, NOT_VOIDED] },
}),
this.prisma.bankTransaction.aggregate({
where: NOT_VOIDED,
where: { AND: [account, NOT_VOIDED] },
_min: { transactionDate: true },
_max: { transactionDate: true },
}),
this.prisma.bankTransaction.count({
where: { AND: [{ cleared: false }, NOT_VOIDED] },
where: { AND: [account, { cleared: false }, NOT_VOIDED] },
}),
this.prisma.bankTransaction.count({
where: { AND: [{ transferred: true }, NOT_VOIDED] },
where: { AND: [account, { transferred: true }, NOT_VOIDED] },
}),
this.totalsFor({}),
this.totalsFor(account),
]);
return {
@@ -261,14 +285,16 @@ export class BankService {
};
}
/** Year list for the period filter, newest first. */
async facets() {
/** Year list for the period filter, newest first, for one chequera. */
async facets(bankAccountId: string) {
// Tagged-template `$queryRaw`: the interpolation below is a bound
// parameter, not string concatenation.
const years = await this.prisma.$queryRaw<
{ year: number; count: bigint | number | string }[]
>`
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
FROM bank_transactions
WHERE voidedAt IS NULL
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
GROUP BY year
ORDER BY year DESC
`;
@@ -287,8 +313,12 @@ export class BankService {
* `BAN` table holds only the bank's name), so the register starts at zero on
* its first row in 2013 and the running figure is the net movement since
* then. Labelled as such in the UI so it is never read as a statement balance.
*
* Both rollups take the SAME `bankAccountId`. Scoping only one of them would
* leave the year list and its month drill-down describing different books —
* wrong in a way that still looks right.
*/
async summary(year?: number) {
async summary(bankAccountId: string, year?: number) {
const years = await this.prisma.$queryRaw<PeriodRow[]>`
SELECT
YEAR(transactionDate) AS period,
@@ -297,7 +327,7 @@ export class BankService {
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net
FROM bank_transactions
WHERE voidedAt IS NULL
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
GROUP BY period
ORDER BY period ASC
`;
@@ -311,7 +341,9 @@ export class BankService {
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net
FROM bank_transactions
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL
WHERE YEAR(transactionDate) = ${year}
AND voidedAt IS NULL
AND bankAccountId = ${bankAccountId}
GROUP BY period
ORDER BY period ASC
`
@@ -365,13 +397,135 @@ export class BankService {
};
}
// --- accounts -------------------------------------------------------------
/**
* Every chequera, closed ones included — a closed account still has to be
* selectable to read its history, it just isn't offered for new captures.
*/
async listAccounts() {
const rows = await this.prisma.bankAccount.findMany({
orderBy: [{ active: "desc" }, { label: "asc" }],
select: {
id: true,
label: true,
currency: true,
businessLine: true,
active: true,
bank: { select: { id: true, name: true, country: true } },
},
});
return rows.map((a) => ({
id: a.id,
label: a.label,
currency: a.currency,
businessLine: a.businessLine,
active: a.active,
bankId: a.bank.id,
bankName: a.bank.name,
bankCountry: a.bank.country,
}));
}
async listBanks() {
return this.prisma.bank.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, country: true },
});
}
/**
* Resolve an account id from a request, or reject. Every read route funnels
* through this so a bad/missing id is a 400 rather than a silently empty
* register that reads as "this account has no movements".
*/
async requireAccount(bankAccountId: string | undefined) {
if (!bankAccountId || !bankAccountId.trim())
throw new BadRequestException("Falta la cuenta bancaria (bankAccountId)");
const account = await this.prisma.bankAccount.findUnique({
where: { id: bankAccountId },
select: { id: true, label: true, currency: true, active: true },
});
if (!account)
throw new NotFoundException(`Cuenta bancaria ${bankAccountId} no existe`);
return account;
}
async createBank(dto: CreateBankDto) {
return this.prisma.bank.create({
data: { name: dto.name.trim(), country: dto.country?.trim() || null },
});
}
async updateBank(id: string, dto: UpdateBankDto) {
await this.getBankOr404(id);
return this.prisma.bank.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.country !== undefined
? { country: dto.country.trim() || null }
: {}),
},
});
}
private async getBankOr404(id: string) {
const bank = await this.prisma.bank.findUnique({
where: { id },
select: { id: true },
});
if (!bank) throw new NotFoundException(`Banco ${id} no existe`);
return bank;
}
async createAccount(dto: CreateBankAccountDto) {
await this.getBankOr404(dto.bankId);
return this.prisma.bankAccount.create({
data: {
bankId: dto.bankId,
label: dto.label.trim(),
currency: dto.currency,
businessLine: dto.businessLine ?? null,
active: dto.active ?? true,
},
});
}
/**
* `currency` is intentionally absent from the update DTO: the movements
* already booked in this account are denominated in it, so changing it would
* silently re-denominate history rather than convert it.
*/
async updateAccount(id: string, dto: UpdateBankAccountDto) {
await this.requireAccount(id);
if (dto.bankId !== undefined) await this.getBankOr404(dto.bankId);
return this.prisma.bankAccount.update({
where: { id },
data: {
...(dto.bankId !== undefined ? { bankId: dto.bankId } : {}),
...(dto.label !== undefined ? { label: dto.label.trim() } : {}),
...(dto.businessLine !== undefined
? { businessLine: dto.businessLine }
: {}),
...(dto.active !== undefined ? { active: dto.active } : {}),
},
});
}
// --- writes (append + void) -----------------------------------------------
async createMovement(dto: CreateBankMovementDto) {
const date = new Date(dto.transactionDate);
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
const account = await this.requireAccount(dto.bankAccountId);
if (!account.active)
throw new BadRequestException(
`La cuenta "${account.label}" está cerrada; no admite movimientos nuevos.`,
);
return this.prisma.bankTransaction.create({
data: {
bankAccountId: account.id,
amount: dto.amount,
transactionDate: date,
concept: dto.concept,