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>
211 lines
5.9 KiB
TypeScript
211 lines
5.9 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Query,
|
|
Req,
|
|
UseGuards,
|
|
} from "@nestjs/common";
|
|
import { Request } from "express";
|
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
|
import { AbilityGuard } from "../auth/ability.guard";
|
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
|
import { AuditService } from "../common/audit.service";
|
|
import {
|
|
BankCleared,
|
|
BankDirection,
|
|
BankService,
|
|
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"];
|
|
const SORTS: BankSort[] = [
|
|
"date_desc",
|
|
"date_asc",
|
|
"amount_desc",
|
|
"amount_asc",
|
|
"reference",
|
|
];
|
|
|
|
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
|
return allowed.includes(value as T) ? (value as T) : undefined;
|
|
}
|
|
|
|
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
|
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
|
if (!v) return undefined;
|
|
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
}
|
|
|
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
|
@Controller("bank")
|
|
export class BankController {
|
|
constructor(
|
|
private readonly bank: BankService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
private actingId(req: Request): string {
|
|
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")
|
|
async stats(@Query("bankAccountId") bankAccountId?: string) {
|
|
const account = await this.bank.requireAccount(bankAccountId);
|
|
return this.bank.stats(account.id);
|
|
}
|
|
|
|
@Get("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")
|
|
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()
|
|
async list(
|
|
@Query("bankAccountId") bankAccountId?: string,
|
|
@Query("query") query?: string,
|
|
@Query("page") page?: string,
|
|
@Query("pageSize") pageSize?: string,
|
|
@Query("direction") direction?: string,
|
|
@Query("cleared") cleared?: string,
|
|
@Query("from") from?: string,
|
|
@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)),
|
|
direction: one(DIRECTIONS, direction),
|
|
cleared: one(CLEARED, cleared),
|
|
from: parseDate(from),
|
|
to: parseDate(to, true),
|
|
sort: one(SORTS, sort) ?? "date_desc",
|
|
});
|
|
}
|
|
|
|
// --- writes ---------------------------------------------------------------
|
|
|
|
@Post()
|
|
@RequireAbility("bank:create")
|
|
async create(@Body() dto: CreateBankMovementDto, @Req() req: Request) {
|
|
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;
|
|
}
|
|
|
|
@Post(":id/void")
|
|
@RequireAbility("bank:void")
|
|
async void(@Param("id") id: string, @Req() req: Request) {
|
|
const row = await this.bank.voidMovement(id, this.actingId(req));
|
|
void this.audit.log(this.actingId(req), "bank.void", { bankTransactionId: id });
|
|
return row;
|
|
}
|
|
}
|