diff --git a/PLAN.md b/PLAN.md index 347d0d4..0474eb8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -135,7 +135,7 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult **Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows. **OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled. - **PDF/OCR auto-capture** — ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff currently key in by hand. Posts through the capture module above. Matching logic was checked field-by-field against `migration/transform_properties.py`'s actual output and found three real gaps to close first: no `TELEPHONE` service kind exists yet, `PROPERTY_TAX.accountNumber` was migrated from `PREDIAL` not `CLAVE` (needs verification against a real predial statement), and `GAS.meterNumber` was never populated by the migration at all. - - **Multi-bank chequera** — `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register; today's `bank_transactions` is hardcoded single-account/MXN-only by design (see step 7 above) and needs a required `bankAccountId` plus scoping added to every read path in `bank.service.ts`, including two raw-SQL queries in `summary()`. + - **Multi-bank chequera — DONE** (2026-07-27). `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register. `bank_transactions` gained a **required** `bankAccountId` (plus an `(bankAccountId, transactionDate)` index, since every read is now filtered by account and ordered by date), and all 22,669 existing rows were backfilled onto a seeded "Utilities — Scotiabank (MXN)" account by `migration/backfill_bank_accounts.py` — a standalone step because `prisma db push` cannot add a required column to a populated table. It is idempotent and now runs inside `run_all.py` (both normal and `--sync`) ahead of `transform_bank.py`, which fails fast if the account is missing. Every read path in `bank.service.ts` is account-scoped, including `facets()` (which had no filter at all) and *both* raw-SQL rollups in `summary()`. API: `?bankAccountId=` is required on `list`/`stats`/`facets`/`summary` — **not** optional-with-an-all-accounts-default, since summing an MXN and a USD register repeats exactly the currency-collapsing mistake the billing module exists to prevent — plus a new `bank/accounts` + `bank/banks` sub-resource under a MANAGER `bank:manage-accounts` ability. Web: `/banco` gained an account picker (remembered per browser) and reads every figure in the selected account's currency, `/banco/cuentas` manages banks and accounts, and `/inicio`'s chequera card names the account it is showing instead of implying one register. An account's `currency` is immutable after creation by design — its booked movements are denominated in it. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged. - **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s). Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section. @@ -185,7 +185,7 @@ Unlike the ops items above, these block design decisions, not just infrastructur - OCR provider/budget for the statement auto-capture pipeline (self-hosted vs. a paid per-page API, given 300+ statements/month/service provider). - Whether `PROPERTY_TAX.accountNumber` (migrated from `DATMEX.PREDIAL`) is actually the same number as "Clave Catastral" (`DATMEX.CLAVE`) — blocks OCR matching for predial statements until confirmed against a real bill. -- The actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate. +- The actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate. (Multi-bank support itself is **built** — this is now only the missing content: staff can open the account in `/banco/cuentas` the moment the answer arrives, and it starts empty unless a historical register turns up.) - The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility. - Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention. diff --git a/RESUME.md b/RESUME.md index 519cb7c..0dd6375 100644 --- a/RESUME.md +++ b/RESUME.md @@ -381,9 +381,18 @@ for what's actually next. insurance/servicios/fideicomiso split the migration comment implied. A classifier would invent data, so `categoryId` stays null and the module does not filter on it. Register is browsable by date/payee/amount/cheque instead. - (c) **Single currency (MXN).** `bank_transactions` has no currency column and - every `amountInWords` is spelled out in PESOS — so, unlike the customer - ledger, everything here is one currency and not split per-currency. + (c) ~~**Single currency (MXN).**~~ **SUPERSEDED 2026-07-27 by the multi-bank + chequera** (step 11, `docs/RECEIPT_CAPTURE_SPEC.md` §3). The office keeps + more than one register, so `bank_transactions` now carries a **required** + `bankAccountId` and every read in the module is scoped to exactly one + `BankAccount`, whose `currency` the movements inherit — there is still no + currency column on the movement itself, because a real bank account doesn't + mix currencies. All 22,669 migrated rows are the Utilities/Scotiabank MXN + account (backfilled by `migration/backfill_bank_accounts.py`, which + `run_all.py` runs before `transform_bank.py`), which is why every + `amountInWords` is still spelled out in PESOS. There is deliberately no + "all accounts" option: summing an MXN and a USD register would repeat the + currency-collapsing mistake the billing module warns against. (d) **The "acumulado" is net movement since the register opened, not a bank balance** — SCOTHIA carries no opening balance (its `ban` table holds only the bank's name), so the running total starts at 0 in 2013. Labelled as such in @@ -391,9 +400,18 @@ for what's actually next. (e) Sign convention (from `transform_bank.py`): positive = ingreso, negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say CANCELADO/VOID) — voids are excluded from both the income and expense sides. + (f) **Multi-account since 2026-07-27.** `/banco` opens on an account picker + (the last account is remembered per browser) and reads every figure in that + account's currency; `/banco/cuentas` manages banks and accounts under a new + MANAGER `bank:manage-accounts` ability. Accounts are never deleted — the + `bankAccountId` FK is required, so a used account can only be *closed* + (`active: false`), which hides it from new captures but keeps its history + readable. An account's currency is immutable after creation, since its + booked movements are denominated in it. - Full pipeline reproducible in one command: `run_all.py --env ` runs customers → - properties → policies → transactions → prune → bank → blobs in order (all idempotent); - add `--stage` to re-extract from the Access files first. Verified end-to-end against dev. + properties → policies → transactions → prune → bank accounts → bank → blobs in order + (all idempotent); add `--stage` to re-extract from the Access files first. Verified + end-to-end against dev. 5. **Infra** — **DONE.** Dev MySQL deployed to the cubex Swarm via the Portainer API as stack `jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled diff --git a/apps/api/src/auth/abilities.ts b/apps/api/src/auth/abilities.ts index 9ec2460..a112822 100644 --- a/apps/api/src/auth/abilities.ts +++ b/apps/api/src/auth/abilities.ts @@ -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 = { "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", diff --git a/apps/api/src/bank/bank-account.dto.ts b/apps/api/src/bank/bank-account.dto.ts new file mode 100644 index 0000000..a3c790a --- /dev/null +++ b/apps/api/src/bank/bank-account.dto.ts @@ -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; +} diff --git a/apps/api/src/bank/bank-movement.dto.ts b/apps/api/src/bank/bank-movement.dto.ts index b5d01ba..758e41a 100644 --- a/apps/api/src/bank/bank-movement.dto.ts +++ b/apps/api/src/bank/bank-movement.dto.ts @@ -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; diff --git a/apps/api/src/bank/bank.controller.ts b/apps/api/src/bank/bank.controller.ts index 65f3b7f..a792d0d 100644 --- a/apps/api/src/bank/bank.controller.ts +++ b/apps/api/src/bank/bank.controller.ts @@ -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; diff --git a/apps/api/src/bank/bank.service.ts b/apps/api/src/bank/bank.service.ts index 8b3df10..6b8502c 100644 --- a/apps/api/src/bank/bank.service.ts +++ b/apps/api/src/bank/bank.service.ts @@ -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` 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, diff --git a/apps/web/src/app/banco/cuentas/page.tsx b/apps/web/src/app/banco/cuentas/page.tsx new file mode 100644 index 0000000..780eea4 --- /dev/null +++ b/apps/web/src/app/banco/cuentas/page.tsx @@ -0,0 +1,525 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { AppShell } from "@/components/AppShell"; +import { useCan } from "@/lib/abilities"; +import { + createBankAccount, + createBankInstitution, + listBankAccounts, + listBankInstitutions, + updateBankAccount, + updateBankInstitution, +} from "@/lib/api"; +import { domainLabel } from "@/lib/labels"; +import type { + BankAccount, + BankInstitution, + Currency, + TransactionDomain, +} from "@/lib/types"; + +/** + * Chequera accounts admin — docs/RECEIPT_CAPTURE_SPEC.md §3. + * + * Two levels: the bank (institution) and the accounts held at it. Opening an + * account is rare and consequential — its currency is what every movement + * booked into it is denominated in, and it can't be changed afterwards without + * silently re-denominating history, so the edit form deliberately has no + * currency field. + * + * Accounts are never deleted: `bank_transactions.bankAccountId` is a required + * FK, so a used account can't be removed without destroying its register. + * Closing one (`active: false`) hides it from new captures while leaving the + * history readable, matching this app's never-hard-delete convention. + */ +const CURRENCIES: Currency[] = ["MXN", "USD"]; +const BUSINESS_LINES: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"]; + +export default function CuentasChequeraPage() { + return ( + + + + ); +} + +function Cuentas() { + const canEdit = useCan("bank:manage-accounts"); + const [banks, setBanks] = useState(null); + const [accounts, setAccounts] = useState(null); + const [error, setError] = useState(null); + + function reload() { + Promise.all([listBankInstitutions(), listBankAccounts()]) + .then(([b, a]) => { + setBanks(b); + setAccounts(a); + }) + .catch((e) => setError(e?.message ?? "No se pudieron cargar las cuentas.")); + } + useEffect(reload, []); + + if (!canEdit) { + return ( + <> +
+

Cuentas de chequera

+
+
+ No tiene permisos para administrar cuentas bancarias. +
+ + ); + } + + return ( + <> +
+

+ Chequera +

+

Cuentas de chequera

+

+ Cada cuenta es una chequera física y se lleva por separado. La moneda + se fija al darla de alta porque todos sus movimientos quedan + registrados en ella; para cambiarla hay que abrir otra cuenta. Las + cuentas no se eliminan: se cierran, y su historial sigue consultable. +

+
+ + {error &&
{error}
} + + {!banks || !accounts ? ( +
+ +
+ ) : ( + <> + + + + )} + + ); +} + +/* ------------------------------------------------------------------ banks */ + +function BanksSection({ + banks, + onChanged, +}: { + banks: BankInstitution[]; + onChanged: () => void; +}) { + const [adding, setAdding] = useState(false); + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [country, setCountry] = useState(""); + const [busy, setBusy] = useState(false); + + function startAdd() { + setEditingId(null); + setAdding(true); + setName(""); + setCountry(""); + } + function startEdit(b: BankInstitution) { + setAdding(false); + setEditingId(b.id); + setName(b.name); + setCountry(b.country ?? ""); + } + function cancel() { + setAdding(false); + setEditingId(null); + } + + async function submit() { + if (!name.trim()) { + window.alert("El nombre del banco es obligatorio."); + return; + } + setBusy(true); + try { + const payload = { name: name.trim(), country: country.trim() }; + if (editingId) await updateBankInstitution(editingId, payload); + else await createBankInstitution(payload); + cancel(); + onChanged(); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo guardar el banco."); + } finally { + setBusy(false); + } + } + + const editor = ( +
+
+ + +
+
+ + +
+
+ ); + + return ( +
+
+

+ Bancos + {banks.length} +

+ {!adding && editingId === null && ( + + )} +
+ + {banks.length === 0 && !adding ? ( +
Sin bancos registrados.
+ ) : ( +
+ + + + + + + + + + {adding && ( + + + + )} + {banks.map((b) => + editingId === b.id ? ( + + + + ) : ( + + + + + + ), + )} + +
BancoPaísAcciones
{editor}
{editor}
{b.name}{b.country || "—"} +
+ +
+
+
+ )} +
+ ); +} + +/* --------------------------------------------------------------- accounts */ + +function AccountsSection({ + banks, + accounts, + onChanged, +}: { + banks: BankInstitution[]; + accounts: BankAccount[]; + onChanged: () => void; +}) { + const [adding, setAdding] = useState(false); + const [editingId, setEditingId] = useState(null); + const [bankId, setBankId] = useState(""); + const [label, setLabel] = useState(""); + const [currency, setCurrency] = useState("MXN"); + const [businessLine, setBusinessLine] = useState(""); + const [active, setActive] = useState(true); + const [busy, setBusy] = useState(false); + + function startAdd() { + setEditingId(null); + setAdding(true); + setBankId(banks[0]?.id ?? ""); + setLabel(""); + setCurrency("MXN"); + setBusinessLine(""); + setActive(true); + } + function startEdit(a: BankAccount) { + setAdding(false); + setEditingId(a.id); + setBankId(a.bankId); + setLabel(a.label); + setCurrency(a.currency); + setBusinessLine(a.businessLine ?? ""); + setActive(a.active); + } + function cancel() { + setAdding(false); + setEditingId(null); + } + + async function submit() { + if (!bankId) { + window.alert("Selecciona el banco de la cuenta."); + return; + } + if (!label.trim()) { + window.alert("El nombre de la cuenta es obligatorio."); + return; + } + setBusy(true); + try { + const line = businessLine + ? (businessLine as TransactionDomain) + : undefined; + if (editingId) { + // No `currency`: see the file header. + await updateBankAccount(editingId, { + bankId, + label: label.trim(), + businessLine: line, + active, + }); + } else { + await createBankAccount({ + bankId, + label: label.trim(), + currency, + businessLine: line, + active, + }); + } + cancel(); + onChanged(); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo guardar la cuenta."); + } finally { + setBusy(false); + } + } + + const editor = ( +
+
+ + + + + +
+
+ + +
+
+ ); + + return ( +
+
+

+ Cuentas + {accounts.length} +

+ {!adding && editingId === null && banks.length > 0 && ( + + )} +
+ + {banks.length === 0 ? ( +
+ Registra primero el banco donde está la cuenta. +
+ ) : accounts.length === 0 && !adding ? ( +
Sin cuentas registradas.
+ ) : ( +
+ + + + + + + + + + + + + {adding && ( + + + + )} + {accounts.map((a) => + editingId === a.id ? ( + + + + ) : ( + + + + + + + + + ), + )} + +
CuentaBancoMonedaLíneaEstatusAcciones
{editor}
{editor}
{a.label}{a.bankName}{a.currency}{a.businessLine ? domainLabel(a.businessLine) : "—"}{a.active ? "Abierta" : "Cerrada"} +
+ + + Ver movimientos + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/banco/page.tsx b/apps/web/src/app/banco/page.tsx index 6cf0935..12e167f 100644 --- a/apps/web/src/app/banco/page.tsx +++ b/apps/web/src/app/banco/page.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { ContextReports } from "@/components/ContextReports"; @@ -8,6 +9,7 @@ import { getBankFacets, getBankStats, getBankSummary, + listBankAccounts, listBankMovements, voidBankMovement, } from "@/lib/api"; @@ -22,6 +24,7 @@ import { monthName, } from "@/lib/labels"; import type { + BankAccount, BankCleared, BankDirection, BankFacets, @@ -32,23 +35,28 @@ import type { BankSummary, BankTotals, CreateBankMovementInput, + Currency, } from "@/lib/types"; /** - * Bank register (chequera) browser — plan step 7. + * Bank register (chequera) browser — plan step 7, multi-account since the + * step-11 multi-bank work. * - * This is the office's OWN checking account, not customer money. It is a + * This is the office's OWN checking accounts, not customer money. It is a * separate page from /estado-cuenta on purpose: nothing here belongs in a * customer's statement and the two sets of figures are never combined. * - * Two views: + * Two views, both scoped to the ONE account picked at the top: * - "Movimientos": the register itself — every deposit and payment, by date, * payee, cheque number or amount. * - "Resumen": ingresos vs egresos per year, and per month inside a year, - * with the running net movement since the register opened in 2013. + * with the running net movement since the register opened. * - * Single currency (MXN) — the source has no currency column. See the module - * header in `bank.service.ts` for why there is no category/ramo filter. + * Every amount is read in the selected account's currency. There is no "all + * accounts" option on purpose — Utilities banks in MXN and Seguros in USD, so + * one combined figure would be a number that never existed, exactly what + * /estado-cuenta's per-currency rule avoids. See the module header in + * `bank.service.ts` for why there is no category/ramo filter. */ type View = "movimientos" | "resumen"; @@ -81,9 +89,18 @@ export default function BancoPage() { ); } +/** Remembers the last chequera a person looked at, per browser. */ +const ACCOUNT_KEY = "banco.bankAccountId"; + function BankBrowser() { const canCapture = useCan("bank:create"); const canVoid = useCan("bank:void"); + const canManageAccounts = useCan("bank:manage-accounts"); + + const [accounts, setAccounts] = useState(null); + const [accountId, setAccountId] = useState(null); + const [accountsError, setAccountsError] = useState(null); + const [stats, setStats] = useState(null); const [facets, setFacets] = useState(null); const [view, setView] = useState("movimientos"); @@ -104,16 +121,66 @@ function BankBrowser() { const debounceRef = useRef>(); + const account = accounts?.find((a) => a.id === accountId) ?? null; + const currency = account?.currency ?? "MXN"; + + // Accounts load first: nothing else on this page can be requested until one + // is selected, because every read is scoped to exactly one chequera. useEffect(() => { - getBankStats().then(setStats).catch(() => setStats(null)); - getBankFacets().then(setFacets).catch(() => setFacets(null)); + listBankAccounts() + .then((rows) => { + setAccounts(rows); + const remembered = + typeof window !== "undefined" + ? window.localStorage.getItem(ACCOUNT_KEY) + : null; + const pick = + rows.find((a) => a.id === remembered) ?? + rows.find((a) => a.active) ?? + rows[0]; + setAccountId(pick?.id ?? null); + if (rows.length === 0) setLoading(false); + }) + .catch((e) => { + setAccountsError(e?.message ?? "No se pudieron cargar las cuentas."); + setLoading(false); + }); }, []); + function pickAccount(id: string) { + setAccountId(id); + if (typeof window !== "undefined") + window.localStorage.setItem(ACCOUNT_KEY, id); + // The previous account's figures must not linger while the new ones load. + setStats(null); + setFacets(null); + setMovements(null); + setSummary(null); + setSummaryYear(null); + } + + const refreshStats = useCallback(() => { + if (!accountId) return; + getBankStats(accountId) + .then(setStats) + .catch(() => setStats(null)); + }, [accountId]); + + useEffect(() => { + if (!accountId) return; + refreshStats(); + getBankFacets(accountId) + .then(setFacets) + .catch(() => setFacets(null)); + }, [accountId, refreshStats]); + const runSearch = useCallback( (p: number) => { + if (!accountId) return; setLoading(true); setError(null); listBankMovements({ + bankAccountId: accountId, query: query || undefined, direction: direction || undefined, cleared: cleared || undefined, @@ -132,23 +199,23 @@ function BankBrowser() { setLoading(false); }); }, - [query, direction, cleared, from, to, sort], + [accountId, query, direction, cleared, from, to, sort], ); useEffect(() => { - if (view !== "movimientos") return; + if (view !== "movimientos" || !accountId) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => runSearch(1), 280); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [runSearch, view]); + }, [runSearch, view, accountId]); useEffect(() => { - if (view !== "resumen") return; + if (view !== "resumen" || !accountId) return; setLoading(true); setError(null); - getBankSummary(summaryYear ?? undefined) + getBankSummary(accountId, summaryYear ?? undefined) .then((res) => { setSummary(res); setLoading(false); @@ -157,7 +224,7 @@ function BankBrowser() { setError(e?.message ?? "No se pudo cargar el resumen."); setLoading(false); }); - }, [view, summaryYear]); + }, [view, summaryYear, accountId]); function goToPage(p: number) { runSearch(p); @@ -194,20 +261,71 @@ function BankBrowser() { setSort("date_desc"); } + if (accountsError) { + return ( + <> +
+

Chequera

+
+
+ {accountsError} +
+ + ); + } + + // No chequera on file: the register has nothing it could be scoped to. + if (accounts && accounts.length === 0) { + return ( + <> +
+

Cuentas propias de la oficina

+

Chequera

+
+
+
+ ⌗ +
+

Sin cuentas registradas

+

+ {canManageAccounts ? ( + <> + Registra una cuenta bancaria en{" "} + Cuentas de chequera para + empezar a capturar movimientos. + + ) : ( + "Pide a un administrador que registre una cuenta bancaria." + )} +

+
+ + ); + } + return ( <>

Cuenta propia de la oficina

Chequera

+

- Movimientos de la cuenta bancaria de la oficina, en pesos. No forma - parte del estado de cuenta de los clientes y sus cifras no se suman - con las de ellos. + Movimientos de{" "} + {account ? account.label : "la cuenta seleccionada"}, + en {currency}. Cada cuenta se lee por separado: las cifras de dos + chequeras nunca se suman, igual que los saldos por moneda del estado + de cuenta. Tampoco forman parte del estado de cuenta de los clientes.

))}
- {view === "movimientos" && canCapture && ( + {view === "movimientos" && canCapture && account?.active && (
-
{formatMoney(stats.net, "MXN")}
+
{formatMoney(stats.net, currency)}
{/* Not the bank balance: the register carries no opening balance. */}
Movimiento neto acumulado
@@ -544,27 +723,33 @@ function BankStatStrip({ } /** Totals for everything the current filter matched, not just the page. */ -function FilteredTotals({ totals }: { totals: BankTotals }) { +function FilteredTotals({ + totals, + currency, +}: { + totals: BankTotals; + currency: Currency; +}) { if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0) return null; return (
- MXN + {currency} - {formatMoney(totals.income, "MXN")} + {formatMoney(totals.income, currency)} {" "} en ingresos · {formatNumber(totals.incomeCount)} - {formatMoney(totals.expense, "MXN")} + {formatMoney(totals.expense, currency)} {" "} en egresos · {formatNumber(totals.expenseCount)} - Neto {formatMoney(totals.net, "MXN")} + Neto {formatMoney(totals.net, currency)} {totals.voidCount > 0 && ( {formatNumber(totals.voidCount)} cancelados @@ -576,10 +761,12 @@ function FilteredTotals({ totals }: { totals: BankTotals }) { function BankRow({ m, + currency, canVoid, onVoided, }: { m: BankListItem; + currency: Currency; canVoid: boolean; onVoided: () => void; }) { @@ -620,7 +807,7 @@ function BankRow({ {bankSourceLabel(m.source)} - {m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")} + {m.direction === "void" ? "—" : formatMoney(m.amount, currency)}
{bankDirectionLabel(m.direction)}
@@ -650,10 +837,12 @@ function BankRow({ function SummaryView({ summary, year, + currency, onPickYear, }: { summary: BankSummary | null; year: number | null; + currency: Currency; onPickYear: (y: number) => void; }) { if (!summary) return null; @@ -687,12 +876,12 @@ function SummaryView({ {formatNumber(r.count)} - {formatMoney(r.income, "MXN")} + {formatMoney(r.income, currency)} - {formatMoney(r.expense, "MXN")} + {formatMoney(r.expense, currency)} @@ -701,10 +890,10 @@ function SummaryView({ Number(r.net) < 0 ? "neg" : "pos" }`} > - {formatMoney(r.net, "MXN")} + {formatMoney(r.net, currency)} - {formatMoney(r.cumulative, "MXN")} + {formatMoney(r.cumulative, currency)} ))} @@ -724,7 +913,7 @@ function SummaryView({

Meses de {year}

- abre en {formatMoney(summary.opening, "MXN")} + abre en {formatMoney(summary.opening, currency)}
@@ -747,12 +936,12 @@ function SummaryView({ {formatNumber(r.count)} - {formatMoney(r.income, "MXN")} + {formatMoney(r.income, currency)} - {formatMoney(r.expense, "MXN")} + {formatMoney(r.expense, currency)} @@ -761,11 +950,11 @@ function SummaryView({ Number(r.net) < 0 ? "neg" : "pos" }`} > - {formatMoney(r.net, "MXN")} + {formatMoney(r.net, currency)} - {formatMoney(r.cumulative, "MXN")} + {formatMoney(r.cumulative, currency)} ))} @@ -779,13 +968,16 @@ function SummaryView({ ); } -/** Inline capture form for a single chequera movement. Single currency (MXN); - * sign convention: positive = ingreso, negative = egreso. Booked rows are - * never edited — fix mistakes with voidBankMovement + a fresh capture. */ +/** Inline capture form for a single chequera movement. The amount is in the + * selected account's currency; sign convention: positive = ingreso, negative + * = egreso. Booked rows are never edited — fix mistakes with voidBankMovement + * + a fresh capture. */ function BankCaptureForm({ + account, onSaved, onCancel, }: { + account: BankAccount; onSaved: () => void; onCancel: () => void; }) { @@ -818,6 +1010,7 @@ function BankCaptureForm({ } const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs); const payload: CreateBankMovementInput = { + bankAccountId: account.id, amount: signed, transactionDate, concept: s(concept), @@ -843,9 +1036,12 @@ function BankCaptureForm({
{error &&
{error}
}
-

+

Capturar movimiento de chequera

+

+ Se registra en {account.label}, en {account.currency}. +