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