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:
@@ -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.
|
**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.
|
**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.
|
- **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).
|
- **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.
|
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).
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -381,9 +381,18 @@ for what's actually next.
|
|||||||
insurance/servicios/fideicomiso split the migration comment implied. A
|
insurance/servicios/fideicomiso split the migration comment implied. A
|
||||||
classifier would invent data, so `categoryId` stays null and the module does
|
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.
|
not filter on it. Register is browsable by date/payee/amount/cheque instead.
|
||||||
(c) **Single currency (MXN).** `bank_transactions` has no currency column and
|
(c) ~~**Single currency (MXN).**~~ **SUPERSEDED 2026-07-27 by the multi-bank
|
||||||
every `amountInWords` is spelled out in PESOS — so, unlike the customer
|
chequera** (step 11, `docs/RECEIPT_CAPTURE_SPEC.md` §3). The office keeps
|
||||||
ledger, everything here is one currency and not split per-currency.
|
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
|
(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
|
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
|
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,
|
(e) Sign convention (from `transform_bank.py`): positive = ingreso,
|
||||||
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
|
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
|
||||||
CANCELADO/VOID) — voids are excluded from both the income and expense sides.
|
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 <env>` runs customers →
|
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
|
||||||
properties → policies → transactions → prune → bank → blobs in order (all idempotent);
|
properties → policies → transactions → prune → bank accounts → bank → blobs in order
|
||||||
add `--stage` to re-extract from the Access files first. Verified end-to-end against dev.
|
(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
|
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
|
`jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type Ability =
|
|||||||
| "ledger:void"
|
| "ledger:void"
|
||||||
| "bank:create"
|
| "bank:create"
|
||||||
| "bank:void"
|
| "bank:void"
|
||||||
|
| "bank:manage-accounts"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "db:manage";
|
| "db:manage";
|
||||||
@@ -50,6 +51,9 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
|||||||
"ledger:void": "MANAGER",
|
"ledger:void": "MANAGER",
|
||||||
"bank:create": "STAFF",
|
"bank:create": "STAFF",
|
||||||
"bank:void": "MANAGER",
|
"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",
|
"lookup:manage": "MANAGER",
|
||||||
"user:manage": "ADMIN",
|
"user:manage": "ADMIN",
|
||||||
"db:manage": "ADMIN",
|
"db:manage": "ADMIN",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@ import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-vali
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A new bank-register movement. `amount` is signed: positive = ingreso,
|
* 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.
|
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
|
||||||
*/
|
*/
|
||||||
export class CreateBankMovementDto {
|
export class CreateBankMovementDto {
|
||||||
|
/** Which chequera this lands in. Required — see BankAccount in the schema. */
|
||||||
|
@IsString() @MinLength(1) bankAccountId!: string;
|
||||||
|
|
||||||
@IsNumber() amount!: number;
|
@IsNumber() amount!: number;
|
||||||
@IsString() @MinLength(1) transactionDate!: string;
|
@IsString() @MinLength(1) transactionDate!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -20,6 +21,12 @@ import {
|
|||||||
BankSort,
|
BankSort,
|
||||||
} from "./bank.service";
|
} from "./bank.service";
|
||||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||||
|
import {
|
||||||
|
CreateBankAccountDto,
|
||||||
|
CreateBankDto,
|
||||||
|
UpdateBankAccountDto,
|
||||||
|
UpdateBankDto,
|
||||||
|
} from "./bank-account.dto";
|
||||||
|
|
||||||
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||||
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||||
@@ -54,28 +61,108 @@ export class BankController {
|
|||||||
return (req.user as { id: string }).id;
|
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")
|
@Get("stats")
|
||||||
stats() {
|
async stats(@Query("bankAccountId") bankAccountId?: string) {
|
||||||
return this.bank.stats();
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
|
return this.bank.stats(account.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("facets")
|
@Get("facets")
|
||||||
facets() {
|
async facets(@Query("bankAccountId") bankAccountId?: string) {
|
||||||
return this.bank.facets();
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
|
return this.bank.facets(account.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Year and month rollups with a running net-movement figure. */
|
/** Year and month rollups with a running net-movement figure. */
|
||||||
@Get("summary")
|
@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);
|
const y = Number(year);
|
||||||
return this.bank.summary(
|
return this.bank.summary(
|
||||||
|
account.id,
|
||||||
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The register browser. */
|
/** The register browser. */
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
async list(
|
||||||
|
@Query("bankAccountId") bankAccountId?: string,
|
||||||
@Query("query") query?: string,
|
@Query("query") query?: string,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("pageSize") pageSize?: string,
|
@Query("pageSize") pageSize?: string,
|
||||||
@@ -85,7 +172,9 @@ export class BankController {
|
|||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
@Query("sort") sort?: string,
|
@Query("sort") sort?: string,
|
||||||
) {
|
) {
|
||||||
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
return this.bank.list({
|
return this.bank.list({
|
||||||
|
bankAccountId: account.id,
|
||||||
query,
|
query,
|
||||||
page: Math.max(1, Number(page) || 1),
|
page: Math.max(1, Number(page) || 1),
|
||||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
@@ -105,6 +194,7 @@ export class BankController {
|
|||||||
const row = await this.bank.createMovement(dto);
|
const row = await this.bank.createMovement(dto);
|
||||||
void this.audit.log(this.actingId(req), "bank.create", {
|
void this.audit.log(this.actingId(req), "bank.create", {
|
||||||
bankTransactionId: row.id,
|
bankTransactionId: row.id,
|
||||||
|
bankAccountId: row.bankAccountId,
|
||||||
amount: dto.amount,
|
amount: dto.amount,
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm
|
|||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
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
|
* 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
|
* expense and are excluded from both sides, the way the ~193 zero rows are
|
||||||
* in the customer ledger.
|
* in the customer ledger.
|
||||||
*
|
*
|
||||||
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here:
|
* ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
|
||||||
* `bank_transactions` has none, and every `amountInWords` on the egreso side is
|
* than one chequera (Utilities banks in MXN, Seguros in USD), so every read
|
||||||
* spelled out in PESOS. All figures in this module are MXN.
|
* 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
|
* 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
|
* rows and this module does not filter or group by it, because the data cannot
|
||||||
@@ -64,6 +79,8 @@ export type BankSort =
|
|||||||
| "reference";
|
| "reference";
|
||||||
|
|
||||||
export interface BankListParams {
|
export interface BankListParams {
|
||||||
|
/** Which chequera to read. Required — see the module header. */
|
||||||
|
bankAccountId: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -98,7 +115,9 @@ export class BankService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
||||||
const and: Prisma.BankTransactionWhereInput[] = [];
|
const and: Prisma.BankTransactionWhereInput[] = [
|
||||||
|
{ bankAccountId: p.bankAccountId },
|
||||||
|
];
|
||||||
|
|
||||||
if (p.query && p.query.trim()) {
|
if (p.query && p.query.trim()) {
|
||||||
const q = 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(
|
private orderBy(
|
||||||
@@ -233,22 +254,25 @@ export class BankService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-line figures for the bank page header. */
|
/** Top-line figures for the bank page header, for one chequera. */
|
||||||
async stats() {
|
async stats(bankAccountId: string) {
|
||||||
|
const account = { bankAccountId };
|
||||||
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
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({
|
this.prisma.bankTransaction.aggregate({
|
||||||
where: NOT_VOIDED,
|
where: { AND: [account, NOT_VOIDED] },
|
||||||
_min: { transactionDate: true },
|
_min: { transactionDate: true },
|
||||||
_max: { transactionDate: true },
|
_max: { transactionDate: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.count({
|
this.prisma.bankTransaction.count({
|
||||||
where: { AND: [{ cleared: false }, NOT_VOIDED] },
|
where: { AND: [account, { cleared: false }, NOT_VOIDED] },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.count({
|
this.prisma.bankTransaction.count({
|
||||||
where: { AND: [{ transferred: true }, NOT_VOIDED] },
|
where: { AND: [account, { transferred: true }, NOT_VOIDED] },
|
||||||
}),
|
}),
|
||||||
this.totalsFor({}),
|
this.totalsFor(account),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -261,14 +285,16 @@ export class BankService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Year list for the period filter, newest first. */
|
/** Year list for the period filter, newest first, for one chequera. */
|
||||||
async facets() {
|
async facets(bankAccountId: string) {
|
||||||
|
// Tagged-template `$queryRaw`: the interpolation below is a bound
|
||||||
|
// parameter, not string concatenation.
|
||||||
const years = await this.prisma.$queryRaw<
|
const years = await this.prisma.$queryRaw<
|
||||||
{ year: number; count: bigint | number | string }[]
|
{ year: number; count: bigint | number | string }[]
|
||||||
>`
|
>`
|
||||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
WHERE voidedAt IS NULL
|
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||||
GROUP BY year
|
GROUP BY year
|
||||||
ORDER BY year DESC
|
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
|
* `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
|
* 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.
|
* 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[]>`
|
const years = await this.prisma.$queryRaw<PeriodRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
YEAR(transactionDate) AS period,
|
YEAR(transactionDate) AS period,
|
||||||
@@ -297,7 +327,7 @@ export class BankService {
|
|||||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
SUM(amount) AS net
|
SUM(amount) AS net
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
WHERE voidedAt IS NULL
|
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
ORDER BY period ASC
|
ORDER BY period ASC
|
||||||
`;
|
`;
|
||||||
@@ -311,7 +341,9 @@ export class BankService {
|
|||||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
SUM(amount) AS net
|
SUM(amount) AS net
|
||||||
FROM bank_transactions
|
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
|
GROUP BY period
|
||||||
ORDER BY period ASC
|
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) -----------------------------------------------
|
// --- writes (append + void) -----------------------------------------------
|
||||||
|
|
||||||
async createMovement(dto: CreateBankMovementDto) {
|
async createMovement(dto: CreateBankMovementDto) {
|
||||||
const date = new Date(dto.transactionDate);
|
const date = new Date(dto.transactionDate);
|
||||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
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({
|
return this.prisma.bankTransaction.create({
|
||||||
data: {
|
data: {
|
||||||
|
bankAccountId: account.id,
|
||||||
amount: dto.amount,
|
amount: dto.amount,
|
||||||
transactionDate: date,
|
transactionDate: date,
|
||||||
concept: dto.concept,
|
concept: dto.concept,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AppShell>
|
||||||
|
<Cuentas />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cuentas() {
|
||||||
|
const canEdit = useCan("bank:manage-accounts");
|
||||||
|
const [banks, setBanks] = useState<BankInstitution[] | null>(null);
|
||||||
|
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Cuentas de chequera</h1>
|
||||||
|
</div>
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para administrar cuentas bancarias.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<Link href="/banco">Chequera</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className="page-title">Cuentas de chequera</h1>
|
||||||
|
<p className="section-note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
{!banks || !accounts ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
<span className="spinner" aria-label="Cargando" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BanksSection banks={banks} onChanged={reload} />
|
||||||
|
<AccountsSection
|
||||||
|
banks={banks}
|
||||||
|
accounts={accounts}
|
||||||
|
onChanged={reload}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ banks */
|
||||||
|
|
||||||
|
function BanksSection({
|
||||||
|
banks,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
banks: BankInstitution[];
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(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 = (
|
||||||
|
<div className="child-editor">
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Banco <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ej. Scotiabank"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">País</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
placeholder="MX / US"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="child-head">
|
||||||
|
<h3 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Bancos
|
||||||
|
<span className="section-count"> {banks.length}</span>
|
||||||
|
</h3>
|
||||||
|
{!adding && editingId === null && (
|
||||||
|
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banks.length === 0 && !adding ? (
|
||||||
|
<div className="empty-inline">Sin bancos registrados.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Banco</th>
|
||||||
|
<th>País</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adding && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{banks.map((b) =>
|
||||||
|
editingId === b.id ? (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td colSpan={3}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td>{b.name}</td>
|
||||||
|
<td>{b.country || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => startEdit(b)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- accounts */
|
||||||
|
|
||||||
|
function AccountsSection({
|
||||||
|
banks,
|
||||||
|
accounts,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
banks: BankInstitution[];
|
||||||
|
accounts: BankAccount[];
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [bankId, setBankId] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [currency, setCurrency] = useState<Currency>("MXN");
|
||||||
|
const [businessLine, setBusinessLine] = useState<string>("");
|
||||||
|
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 = (
|
||||||
|
<div className="child-editor">
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Banco <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={bankId}
|
||||||
|
onChange={(e) => setBankId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{banks.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Nombre de la cuenta <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="Ej. Seguros — Bank of America (USD)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Moneda <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={currency}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
onChange={(e) => setCurrency(e.target.value as Currency)}
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{editingId !== null && (
|
||||||
|
<span className="section-note">
|
||||||
|
No se puede cambiar: los movimientos ya registrados están en esta
|
||||||
|
moneda.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Línea de negocio</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={businessLine}
|
||||||
|
onChange={(e) => setBusinessLine(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Sin asignar</option>
|
||||||
|
{BUSINESS_LINES.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{domainLabel(d)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="section-note">
|
||||||
|
Referencia nada más: una chequera puede pagar de varias líneas.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={active}
|
||||||
|
onChange={(e) => setActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="field-label" style={{ margin: 0 }}>
|
||||||
|
Cuenta abierta
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="child-head">
|
||||||
|
<h3 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Cuentas
|
||||||
|
<span className="section-count"> {accounts.length}</span>
|
||||||
|
</h3>
|
||||||
|
{!adding && editingId === null && banks.length > 0 && (
|
||||||
|
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banks.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Registra primero el banco donde está la cuenta.
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 && !adding ? (
|
||||||
|
<div className="empty-inline">Sin cuentas registradas.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cuenta</th>
|
||||||
|
<th>Banco</th>
|
||||||
|
<th>Moneda</th>
|
||||||
|
<th>Línea</th>
|
||||||
|
<th>Estatus</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adding && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{accounts.map((a) =>
|
||||||
|
editingId === a.id ? (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td colSpan={6}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{a.label}</td>
|
||||||
|
<td>{a.bankName}</td>
|
||||||
|
<td className="mono">{a.currency}</td>
|
||||||
|
<td>{a.businessLine ? domainLabel(a.businessLine) : "—"}</td>
|
||||||
|
<td>{a.active ? "Abierta" : "Cerrada"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => startEdit(a)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<Link href="/banco" className="btn btn-ghost">
|
||||||
|
Ver movimientos
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+243
-47
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { ContextReports } from "@/components/ContextReports";
|
import { ContextReports } from "@/components/ContextReports";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
getBankFacets,
|
getBankFacets,
|
||||||
getBankStats,
|
getBankStats,
|
||||||
getBankSummary,
|
getBankSummary,
|
||||||
|
listBankAccounts,
|
||||||
listBankMovements,
|
listBankMovements,
|
||||||
voidBankMovement,
|
voidBankMovement,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
monthName,
|
monthName,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type {
|
import type {
|
||||||
|
BankAccount,
|
||||||
BankCleared,
|
BankCleared,
|
||||||
BankDirection,
|
BankDirection,
|
||||||
BankFacets,
|
BankFacets,
|
||||||
@@ -32,23 +35,28 @@ import type {
|
|||||||
BankSummary,
|
BankSummary,
|
||||||
BankTotals,
|
BankTotals,
|
||||||
CreateBankMovementInput,
|
CreateBankMovementInput,
|
||||||
|
Currency,
|
||||||
} from "@/lib/types";
|
} 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
|
* separate page from /estado-cuenta on purpose: nothing here belongs in a
|
||||||
* customer's statement and the two sets of figures are never combined.
|
* 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,
|
* - "Movimientos": the register itself — every deposit and payment, by date,
|
||||||
* payee, cheque number or amount.
|
* payee, cheque number or amount.
|
||||||
* - "Resumen": ingresos vs egresos per year, and per month inside a year,
|
* - "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
|
* Every amount is read in the selected account's currency. There is no "all
|
||||||
* header in `bank.service.ts` for why there is no category/ramo filter.
|
* 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";
|
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() {
|
function BankBrowser() {
|
||||||
const canCapture = useCan("bank:create");
|
const canCapture = useCan("bank:create");
|
||||||
const canVoid = useCan("bank:void");
|
const canVoid = useCan("bank:void");
|
||||||
|
const canManageAccounts = useCan("bank:manage-accounts");
|
||||||
|
|
||||||
|
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
|
||||||
|
const [accountId, setAccountId] = useState<string | null>(null);
|
||||||
|
const [accountsError, setAccountsError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [stats, setStats] = useState<BankStats | null>(null);
|
const [stats, setStats] = useState<BankStats | null>(null);
|
||||||
const [facets, setFacets] = useState<BankFacets | null>(null);
|
const [facets, setFacets] = useState<BankFacets | null>(null);
|
||||||
const [view, setView] = useState<View>("movimientos");
|
const [view, setView] = useState<View>("movimientos");
|
||||||
@@ -104,16 +121,66 @@ function BankBrowser() {
|
|||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
getBankStats().then(setStats).catch(() => setStats(null));
|
listBankAccounts()
|
||||||
getBankFacets().then(setFacets).catch(() => setFacets(null));
|
.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(
|
const runSearch = useCallback(
|
||||||
(p: number) => {
|
(p: number) => {
|
||||||
|
if (!accountId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
listBankMovements({
|
listBankMovements({
|
||||||
|
bankAccountId: accountId,
|
||||||
query: query || undefined,
|
query: query || undefined,
|
||||||
direction: direction || undefined,
|
direction: direction || undefined,
|
||||||
cleared: cleared || undefined,
|
cleared: cleared || undefined,
|
||||||
@@ -132,23 +199,23 @@ function BankBrowser() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[query, direction, cleared, from, to, sort],
|
[accountId, query, direction, cleared, from, to, sort],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== "movimientos") return;
|
if (view !== "movimientos" || !accountId) return;
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
};
|
};
|
||||||
}, [runSearch, view]);
|
}, [runSearch, view, accountId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== "resumen") return;
|
if (view !== "resumen" || !accountId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
getBankSummary(summaryYear ?? undefined)
|
getBankSummary(accountId, summaryYear ?? undefined)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setSummary(res);
|
setSummary(res);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -157,7 +224,7 @@ function BankBrowser() {
|
|||||||
setError(e?.message ?? "No se pudo cargar el resumen.");
|
setError(e?.message ?? "No se pudo cargar el resumen.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [view, summaryYear]);
|
}, [view, summaryYear, accountId]);
|
||||||
|
|
||||||
function goToPage(p: number) {
|
function goToPage(p: number) {
|
||||||
runSearch(p);
|
runSearch(p);
|
||||||
@@ -194,20 +261,71 @@ function BankBrowser() {
|
|||||||
setSort("date_desc");
|
setSort("date_desc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountsError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Chequera</h1>
|
||||||
|
</div>
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{accountsError}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No chequera on file: the register has nothing it could be scoped to.
|
||||||
|
if (accounts && accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">Cuentas propias de la oficina</p>
|
||||||
|
<h1 className="page-title">Chequera</h1>
|
||||||
|
</div>
|
||||||
|
<div className="state-box">
|
||||||
|
<div className="state-glyph" aria-hidden>
|
||||||
|
⌗
|
||||||
|
</div>
|
||||||
|
<h3>Sin cuentas registradas</h3>
|
||||||
|
<p>
|
||||||
|
{canManageAccounts ? (
|
||||||
|
<>
|
||||||
|
Registra una cuenta bancaria en{" "}
|
||||||
|
<Link href="/banco/cuentas">Cuentas de chequera</Link> para
|
||||||
|
empezar a capturar movimientos.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Pide a un administrador que registre una cuenta bancaria."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-head rise">
|
<div className="page-head rise">
|
||||||
<p className="eyebrow">Cuenta propia de la oficina</p>
|
<p className="eyebrow">Cuenta propia de la oficina</p>
|
||||||
<h1 className="page-title">Chequera</h1>
|
<h1 className="page-title">Chequera</h1>
|
||||||
|
<AccountPicker
|
||||||
|
accounts={accounts}
|
||||||
|
accountId={accountId}
|
||||||
|
onPick={pickAccount}
|
||||||
|
canManageAccounts={canManageAccounts}
|
||||||
|
/>
|
||||||
<BankStatStrip
|
<BankStatStrip
|
||||||
stats={stats}
|
stats={stats}
|
||||||
|
currency={currency}
|
||||||
direction={view === "movimientos" ? direction : ""}
|
direction={view === "movimientos" ? direction : ""}
|
||||||
onPickDirection={pickDirection}
|
onPickDirection={pickDirection}
|
||||||
/>
|
/>
|
||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
Movimientos de la cuenta bancaria de la oficina, en pesos. No forma
|
Movimientos de{" "}
|
||||||
parte del estado de cuenta de los clientes y sus cifras no se suman
|
<strong>{account ? account.label : "la cuenta seleccionada"}</strong>,
|
||||||
con las de ellos.
|
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.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<ContextReports
|
<ContextReports
|
||||||
@@ -252,7 +370,7 @@ function BankBrowser() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{view === "movimientos" && canCapture && (
|
{view === "movimientos" && canCapture && account?.active && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
@@ -263,12 +381,20 @@ function BankBrowser() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === "movimientos" && captureOpen && (
|
{account && !account.active && (
|
||||||
|
<div className="section-note">
|
||||||
|
Esta cuenta está cerrada: su historial se consulta, pero no admite
|
||||||
|
movimientos nuevos.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "movimientos" && captureOpen && account && (
|
||||||
<BankCaptureForm
|
<BankCaptureForm
|
||||||
|
account={account}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
setCaptureOpen(false);
|
setCaptureOpen(false);
|
||||||
runSearch(movements?.page ?? 1);
|
runSearch(movements?.page ?? 1);
|
||||||
getBankStats().then(setStats).catch(() => setStats(null));
|
refreshStats();
|
||||||
}}
|
}}
|
||||||
onCancel={() => setCaptureOpen(false)}
|
onCancel={() => setCaptureOpen(false)}
|
||||||
/>
|
/>
|
||||||
@@ -389,7 +515,7 @@ function BankBrowser() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view === "movimientos" && movements && !loading && (
|
{view === "movimientos" && movements && !loading && (
|
||||||
<FilteredTotals totals={movements.totals} />
|
<FilteredTotals totals={movements.totals} currency={currency} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -402,6 +528,7 @@ function BankBrowser() {
|
|||||||
<SummaryView
|
<SummaryView
|
||||||
summary={summary}
|
summary={summary}
|
||||||
year={summaryYear}
|
year={summaryYear}
|
||||||
|
currency={currency}
|
||||||
onPickYear={pickYear}
|
onPickYear={pickYear}
|
||||||
/>
|
/>
|
||||||
) : movements && movements.total === 0 ? (
|
) : movements && movements.total === 0 ? (
|
||||||
@@ -430,12 +557,11 @@ function BankBrowser() {
|
|||||||
<BankRow
|
<BankRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
m={m}
|
m={m}
|
||||||
|
currency={currency}
|
||||||
canVoid={canVoid}
|
canVoid={canVoid}
|
||||||
onVoided={() => {
|
onVoided={() => {
|
||||||
runSearch(movements?.page ?? 1);
|
runSearch(movements?.page ?? 1);
|
||||||
getBankStats()
|
refreshStats();
|
||||||
.then(setStats)
|
|
||||||
.catch(() => setStats(null));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -456,13 +582,66 @@ function BankBrowser() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which chequera the whole page is reading. There is no "todas las cuentas"
|
||||||
|
* option and there must not be one — see the file header.
|
||||||
|
*/
|
||||||
|
function AccountPicker({
|
||||||
|
accounts,
|
||||||
|
accountId,
|
||||||
|
onPick,
|
||||||
|
canManageAccounts,
|
||||||
|
}: {
|
||||||
|
accounts: BankAccount[] | null;
|
||||||
|
accountId: string | null;
|
||||||
|
onPick: (id: string) => void;
|
||||||
|
canManageAccounts: boolean;
|
||||||
|
}) {
|
||||||
|
if (!accounts) {
|
||||||
|
return (
|
||||||
|
<div className="skeleton" style={{ height: 34, width: 260, marginTop: 12 }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="filter-row"
|
||||||
|
style={{ marginTop: 12, alignItems: "flex-end" }}
|
||||||
|
>
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Cuenta</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={accountId ?? ""}
|
||||||
|
onChange={(e) => onPick(e.target.value)}
|
||||||
|
aria-label="Cuenta de chequera"
|
||||||
|
>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.label} · {a.currency}
|
||||||
|
{a.active ? "" : " (cerrada)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{canManageAccounts && (
|
||||||
|
<Link href="/banco/cuentas" className="btn btn-ghost">
|
||||||
|
Administrar cuentas
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */
|
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */
|
||||||
function BankStatStrip({
|
function BankStatStrip({
|
||||||
stats,
|
stats,
|
||||||
|
currency,
|
||||||
direction,
|
direction,
|
||||||
onPickDirection,
|
onPickDirection,
|
||||||
}: {
|
}: {
|
||||||
stats: BankStats | null;
|
stats: BankStats | null;
|
||||||
|
currency: Currency;
|
||||||
direction: BankDirection | "";
|
direction: BankDirection | "";
|
||||||
onPickDirection: (d: BankDirection) => void;
|
onPickDirection: (d: BankDirection) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -493,7 +672,7 @@ function BankStatStrip({
|
|||||||
aria-pressed={direction === "income"}
|
aria-pressed={direction === "income"}
|
||||||
>
|
>
|
||||||
<div className="stat-value tx-amount pos">
|
<div className="stat-value tx-amount pos">
|
||||||
{formatMoney(stats.income, "MXN")}
|
{formatMoney(stats.income, currency)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-label">
|
<div className="stat-label">
|
||||||
En ingresos · {formatNumber(stats.incomeCount)} movimientos
|
En ingresos · {formatNumber(stats.incomeCount)} movimientos
|
||||||
@@ -508,14 +687,14 @@ function BankStatStrip({
|
|||||||
aria-pressed={direction === "expense"}
|
aria-pressed={direction === "expense"}
|
||||||
>
|
>
|
||||||
<div className="stat-value tx-amount neg">
|
<div className="stat-value tx-amount neg">
|
||||||
{formatMoney(stats.expense, "MXN")}
|
{formatMoney(stats.expense, currency)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-label">
|
<div className="stat-label">
|
||||||
En egresos · {formatNumber(stats.expenseCount)} movimientos
|
En egresos · {formatNumber(stats.expenseCount)} movimientos
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="stat-cell">
|
<div className="stat-cell">
|
||||||
<div className="stat-value">{formatMoney(stats.net, "MXN")}</div>
|
<div className="stat-value">{formatMoney(stats.net, currency)}</div>
|
||||||
{/* Not the bank balance: the register carries no opening balance. */}
|
{/* Not the bank balance: the register carries no opening balance. */}
|
||||||
<div className="stat-label">Movimiento neto acumulado</div>
|
<div className="stat-label">Movimiento neto acumulado</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -544,27 +723,33 @@ function BankStatStrip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Totals for everything the current filter matched, not just the page. */
|
/** 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)
|
if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0)
|
||||||
return null;
|
return null;
|
||||||
return (
|
return (
|
||||||
<div className="filtered-totals">
|
<div className="filtered-totals">
|
||||||
<div className="filtered-total">
|
<div className="filtered-total">
|
||||||
<span className="filtered-total-cur">MXN</span>
|
<span className="filtered-total-cur">{currency}</span>
|
||||||
<span>
|
<span>
|
||||||
<strong className="tx-amount pos">
|
<strong className="tx-amount pos">
|
||||||
{formatMoney(totals.income, "MXN")}
|
{formatMoney(totals.income, currency)}
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
en ingresos · {formatNumber(totals.incomeCount)}
|
en ingresos · {formatNumber(totals.incomeCount)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong className="tx-amount neg">
|
<strong className="tx-amount neg">
|
||||||
{formatMoney(totals.expense, "MXN")}
|
{formatMoney(totals.expense, currency)}
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
en egresos · {formatNumber(totals.expenseCount)}
|
en egresos · {formatNumber(totals.expenseCount)}
|
||||||
</span>
|
</span>
|
||||||
<span className="filtered-total-net">
|
<span className="filtered-total-net">
|
||||||
Neto <strong>{formatMoney(totals.net, "MXN")}</strong>
|
Neto <strong>{formatMoney(totals.net, currency)}</strong>
|
||||||
</span>
|
</span>
|
||||||
{totals.voidCount > 0 && (
|
{totals.voidCount > 0 && (
|
||||||
<span>{formatNumber(totals.voidCount)} cancelados</span>
|
<span>{formatNumber(totals.voidCount)} cancelados</span>
|
||||||
@@ -576,10 +761,12 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
|
|||||||
|
|
||||||
function BankRow({
|
function BankRow({
|
||||||
m,
|
m,
|
||||||
|
currency,
|
||||||
canVoid,
|
canVoid,
|
||||||
onVoided,
|
onVoided,
|
||||||
}: {
|
}: {
|
||||||
m: BankListItem;
|
m: BankListItem;
|
||||||
|
currency: Currency;
|
||||||
canVoid: boolean;
|
canVoid: boolean;
|
||||||
onVoided: () => void;
|
onVoided: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -620,7 +807,7 @@ function BankRow({
|
|||||||
<td>{bankSourceLabel(m.source)}</td>
|
<td>{bankSourceLabel(m.source)}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className={`tx-amount ${bankTone(m.direction)}`}>
|
<span className={`tx-amount ${bankTone(m.direction)}`}>
|
||||||
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")}
|
{m.direction === "void" ? "—" : formatMoney(m.amount, currency)}
|
||||||
</span>
|
</span>
|
||||||
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -650,10 +837,12 @@ function BankRow({
|
|||||||
function SummaryView({
|
function SummaryView({
|
||||||
summary,
|
summary,
|
||||||
year,
|
year,
|
||||||
|
currency,
|
||||||
onPickYear,
|
onPickYear,
|
||||||
}: {
|
}: {
|
||||||
summary: BankSummary | null;
|
summary: BankSummary | null;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
currency: Currency;
|
||||||
onPickYear: (y: number) => void;
|
onPickYear: (y: number) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
@@ -687,12 +876,12 @@ function SummaryView({
|
|||||||
<td className="num">{formatNumber(r.count)}</td>
|
<td className="num">{formatNumber(r.count)}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className="tx-amount pos">
|
<span className="tx-amount pos">
|
||||||
{formatMoney(r.income, "MXN")}
|
{formatMoney(r.income, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className="tx-amount neg">
|
<span className="tx-amount neg">
|
||||||
{formatMoney(r.expense, "MXN")}
|
{formatMoney(r.expense, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
@@ -701,10 +890,10 @@ function SummaryView({
|
|||||||
Number(r.net) < 0 ? "neg" : "pos"
|
Number(r.net) < 0 ? "neg" : "pos"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatMoney(r.net, "MXN")}
|
{formatMoney(r.net, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td>
|
<td className="num mono">{formatMoney(r.cumulative, currency)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -724,7 +913,7 @@ function SummaryView({
|
|||||||
<span className="section-rule cuenta" />
|
<span className="section-rule cuenta" />
|
||||||
<h2 className="section-title">Meses de {year}</h2>
|
<h2 className="section-title">Meses de {year}</h2>
|
||||||
<span className="section-count">
|
<span className="section-count">
|
||||||
abre en {formatMoney(summary.opening, "MXN")}
|
abre en {formatMoney(summary.opening, currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -747,12 +936,12 @@ function SummaryView({
|
|||||||
<td className="num">{formatNumber(r.count)}</td>
|
<td className="num">{formatNumber(r.count)}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className="tx-amount pos">
|
<span className="tx-amount pos">
|
||||||
{formatMoney(r.income, "MXN")}
|
{formatMoney(r.income, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className="tx-amount neg">
|
<span className="tx-amount neg">
|
||||||
{formatMoney(r.expense, "MXN")}
|
{formatMoney(r.expense, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
@@ -761,11 +950,11 @@ function SummaryView({
|
|||||||
Number(r.net) < 0 ? "neg" : "pos"
|
Number(r.net) < 0 ? "neg" : "pos"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatMoney(r.net, "MXN")}
|
{formatMoney(r.net, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num mono">
|
<td className="num mono">
|
||||||
{formatMoney(r.cumulative, "MXN")}
|
{formatMoney(r.cumulative, currency)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -779,13 +968,16 @@ function SummaryView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inline capture form for a single chequera movement. Single currency (MXN);
|
/** Inline capture form for a single chequera movement. The amount is in the
|
||||||
* sign convention: positive = ingreso, negative = egreso. Booked rows are
|
* selected account's currency; sign convention: positive = ingreso, negative
|
||||||
* never edited — fix mistakes with voidBankMovement + a fresh capture. */
|
* = egreso. Booked rows are never edited — fix mistakes with voidBankMovement
|
||||||
|
* + a fresh capture. */
|
||||||
function BankCaptureForm({
|
function BankCaptureForm({
|
||||||
|
account,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
|
account: BankAccount;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -818,6 +1010,7 @@ function BankCaptureForm({
|
|||||||
}
|
}
|
||||||
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
|
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
|
||||||
const payload: CreateBankMovementInput = {
|
const payload: CreateBankMovementInput = {
|
||||||
|
bankAccountId: account.id,
|
||||||
amount: signed,
|
amount: signed,
|
||||||
transactionDate,
|
transactionDate,
|
||||||
concept: s(concept),
|
concept: s(concept),
|
||||||
@@ -843,9 +1036,12 @@ function BankCaptureForm({
|
|||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
{error && <div className="state-box state-error">{error}</div>}
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
<h2 className="section-title" style={{ marginBottom: 4 }}>
|
||||||
Capturar movimiento de chequera
|
Capturar movimiento de chequera
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="section-note" style={{ marginBottom: 14 }}>
|
||||||
|
Se registra en <strong>{account.label}</strong>, en {account.currency}.
|
||||||
|
</p>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">
|
<span className="field-label">
|
||||||
@@ -874,7 +1070,7 @@ function BankCaptureForm({
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">
|
<span className="field-label">
|
||||||
Monto (MXN) <span aria-hidden>*</span>
|
Monto ({account.currency}) <span aria-hidden>*</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getPolicyStats,
|
getPolicyStats,
|
||||||
getPropertyStats,
|
getPropertyStats,
|
||||||
getStats,
|
getStats,
|
||||||
|
listBankAccounts,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/abilities";
|
import { useAuth } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
trustStatusLabel,
|
trustStatusLabel,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type {
|
import type {
|
||||||
|
BankAccount,
|
||||||
BankStats,
|
BankStats,
|
||||||
BillingStats,
|
BillingStats,
|
||||||
CustomerStats,
|
CustomerStats,
|
||||||
@@ -41,7 +43,29 @@ interface DashboardData {
|
|||||||
policies: PolicyStats | null;
|
policies: PolicyStats | null;
|
||||||
properties: PropertyStats | null;
|
properties: PropertyStats | null;
|
||||||
billing: BillingStats | null;
|
billing: BillingStats | null;
|
||||||
|
/** Figures for ONE chequera — see `bankAccount` for which. */
|
||||||
bank: BankStats | null;
|
bank: BankStats | null;
|
||||||
|
/**
|
||||||
|
* The chequera the card above is reading. The office keeps more than one, in
|
||||||
|
* different currencies, so this card shows the default account rather than a
|
||||||
|
* cross-account total, which would be a figure that never existed.
|
||||||
|
*/
|
||||||
|
bankAccount: BankAccount | null;
|
||||||
|
bankAccountCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same default as /banco, so the two screens agree on which chequera opens. */
|
||||||
|
function defaultAccount(accounts: BankAccount[]): BankAccount | null {
|
||||||
|
const remembered =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.localStorage.getItem("banco.bankAccountId")
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
accounts.find((a) => a.id === remembered) ??
|
||||||
|
accounts.find((a) => a.active) ??
|
||||||
|
accounts[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeDashboard() {
|
function HomeDashboard() {
|
||||||
@@ -52,25 +76,42 @@ function HomeDashboard() {
|
|||||||
properties: null,
|
properties: null,
|
||||||
billing: null,
|
billing: null,
|
||||||
bank: null,
|
bank: null,
|
||||||
|
bankAccount: null,
|
||||||
|
bankAccountCount: 0,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
// The chequera figures need an account id, so that read is a two-step:
|
||||||
|
// list the accounts, then ask the default one for its stats.
|
||||||
|
const bank = listBankAccounts().then(async (accounts) => {
|
||||||
|
const account = defaultAccount(accounts);
|
||||||
|
if (!account) return { account: null, stats: null, count: 0 };
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
stats: await getBankStats(account.id),
|
||||||
|
count: accounts.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getStats(),
|
getStats(),
|
||||||
getPolicyStats(),
|
getPolicyStats(),
|
||||||
getPropertyStats(),
|
getPropertyStats(),
|
||||||
getBillingStats(),
|
getBillingStats(),
|
||||||
getBankStats(),
|
bank,
|
||||||
]).then((results) => {
|
]).then((results) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
|
const bankResult = results[4].status === "fulfilled" ? results[4].value : null;
|
||||||
setData({
|
setData({
|
||||||
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
||||||
policies: results[1].status === "fulfilled" ? results[1].value : null,
|
policies: results[1].status === "fulfilled" ? results[1].value : null,
|
||||||
properties: results[2].status === "fulfilled" ? results[2].value : null,
|
properties: results[2].status === "fulfilled" ? results[2].value : null,
|
||||||
billing: results[3].status === "fulfilled" ? results[3].value : null,
|
billing: results[3].status === "fulfilled" ? results[3].value : null,
|
||||||
bank: results[4].status === "fulfilled" ? results[4].value : null,
|
bank: bankResult?.stats ?? null,
|
||||||
|
bankAccount: bankResult?.account ?? null,
|
||||||
|
bankAccountCount: bankResult?.count ?? 0,
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
@@ -260,22 +301,27 @@ function HomeDashboard() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
title="Chequera del despacho"
|
title="Chequera del despacho"
|
||||||
primary={
|
primary={
|
||||||
data.bank ? (
|
data.bank && data.bankAccount ? (
|
||||||
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
|
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
|
||||||
{formatMoney(data.bank.net, "MXN")}
|
{formatMoney(data.bank.net, data.bankAccount.currency)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Names the account, because this is one chequera's figure and the
|
||||||
|
// office has more than one — they are never added together.
|
||||||
sub={
|
sub={
|
||||||
data.bank
|
data.bank && data.bankAccount
|
||||||
? balancePhrase(data.bank.net)
|
? `${data.bankAccount.label} · ${balancePhrase(data.bank.net)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
meta={
|
meta={
|
||||||
data.bank
|
data.bank
|
||||||
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes`
|
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes` +
|
||||||
|
(data.bankAccountCount > 1
|
||||||
|
? ` · ${formatNumber(data.bankAccountCount)} cuentas en total`
|
||||||
|
: "")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ const NAV: NavEntry[] = [
|
|||||||
label: "Admin",
|
label: "Admin",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||||
|
{
|
||||||
|
href: "/banco/cuentas",
|
||||||
|
label: "Cuentas de chequera",
|
||||||
|
ability: "bank:manage-accounts",
|
||||||
|
},
|
||||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||||
],
|
],
|
||||||
|
|||||||
+78
-9
@@ -6,9 +6,11 @@ import type {
|
|||||||
BalanceFilter,
|
BalanceFilter,
|
||||||
BalanceListResponse,
|
BalanceListResponse,
|
||||||
BalanceSort,
|
BalanceSort,
|
||||||
|
BankAccount,
|
||||||
BankCleared,
|
BankCleared,
|
||||||
BankDirection,
|
BankDirection,
|
||||||
BankFacets,
|
BankFacets,
|
||||||
|
BankInstitution,
|
||||||
BankListResponse,
|
BankListResponse,
|
||||||
BankSort,
|
BankSort,
|
||||||
BankStats,
|
BankStats,
|
||||||
@@ -19,8 +21,11 @@ import type {
|
|||||||
BillingStats,
|
BillingStats,
|
||||||
BusinessLine,
|
BusinessLine,
|
||||||
ByCheckResponse,
|
ByCheckResponse,
|
||||||
|
CreateBankAccountInput,
|
||||||
|
CreateBankInput,
|
||||||
CreateBankMovementInput,
|
CreateBankMovementInput,
|
||||||
CreateMovementInput,
|
CreateMovementInput,
|
||||||
|
UpdateBankAccountInput,
|
||||||
ResolveOutstandingInput,
|
ResolveOutstandingInput,
|
||||||
CustomerDetail,
|
CustomerDetail,
|
||||||
CustomerInput,
|
CustomerInput,
|
||||||
@@ -609,7 +614,13 @@ export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
|
|||||||
|
|
||||||
/* ------------------------------------------------- Bank register (chequera) */
|
/* ------------------------------------------------- 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 {
|
export interface BankQuery {
|
||||||
|
bankAccountId: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -622,7 +633,7 @@ export interface BankQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
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.query) params.set("query", q.query);
|
||||||
if (q.page) params.set("page", String(q.page));
|
if (q.page) params.set("page", String(q.page));
|
||||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
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.from) params.set("from", q.from);
|
||||||
if (q.to) params.set("to", q.to);
|
if (q.to) params.set("to", q.to);
|
||||||
if (q.sort) params.set("sort", q.sort);
|
if (q.sort) params.set("sort", q.sort);
|
||||||
const qs = params.toString();
|
return apiFetch<BankListResponse>(`/bank?${params.toString()}`);
|
||||||
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBankStats(): Promise<BankStats> {
|
export function getBankStats(bankAccountId: string): Promise<BankStats> {
|
||||||
return apiFetch<BankStats>("/bank/stats");
|
return apiFetch<BankStats>(
|
||||||
|
`/bank/stats?bankAccountId=${encodeURIComponent(bankAccountId)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBankFacets(): Promise<BankFacets> {
|
export function getBankFacets(bankAccountId: string): Promise<BankFacets> {
|
||||||
return apiFetch<BankFacets>("/bank/facets");
|
return apiFetch<BankFacets>(
|
||||||
|
`/bank/facets?bankAccountId=${encodeURIComponent(bankAccountId)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBankSummary(year?: number): Promise<BankSummary> {
|
export function getBankSummary(
|
||||||
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
|
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
|
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type Ability =
|
|||||||
| "ledger:void"
|
| "ledger:void"
|
||||||
| "bank:create"
|
| "bank:create"
|
||||||
| "bank:void"
|
| "bank:void"
|
||||||
|
| "bank:manage-accounts"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "db:manage";
|
| "db:manage";
|
||||||
@@ -1001,12 +1002,57 @@ export interface CustomerInput {
|
|||||||
/* ------------------------------------------------- Bank register (chequera) */
|
/* ------------------------------------------------- Bank register (chequera) */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The office's own checking account. Single-currency (MXN) and with no customer
|
* 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
|
* link. See `bank.service.ts`. Positive is a deposit, negative a payment, and
|
||||||
* exactly zero a cancelled cheque.
|
* 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";
|
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 BankCleared = "cleared" | "pending";
|
||||||
|
|
||||||
export type BankSort =
|
export type BankSort =
|
||||||
@@ -1038,8 +1084,10 @@ export interface BankListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /bank — a new chequera movement. Sign convention: positive
|
/** 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 {
|
export interface CreateBankMovementInput {
|
||||||
|
/** Which chequera it lands in. Required. */
|
||||||
|
bankAccountId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
transactionDate: string;
|
transactionDate: string;
|
||||||
concept?: string;
|
concept?: string;
|
||||||
|
|||||||
@@ -401,6 +401,40 @@ document-understanding problem. Recommend:
|
|||||||
|
|
||||||
## 3. Multi-bank chequera
|
## 3. Multi-bank chequera
|
||||||
|
|
||||||
|
> **BUILT — 2026-07-27.** Everything below is implemented and verified against
|
||||||
|
> the dev database and browser. `Bank` / `BankAccount` exist, every
|
||||||
|
> `BankTransaction` carries a required `bankAccountId`, and all 22,669 migrated
|
||||||
|
> rows were backfilled onto the Utilities/Scotiabank MXN account by
|
||||||
|
> `migration/backfill_bank_accounts.py` (now wired into `run_all.py`, both
|
||||||
|
> modes, ahead of `transform_bank.py`). Every read path in `bank.service.ts` is
|
||||||
|
> account-scoped — including both raw-SQL rollups in `summary()` and the
|
||||||
|
> previously-unfiltered `facets()`. `/banco` gained an account picker,
|
||||||
|
> `/banco/cuentas` manages banks and accounts under the new MANAGER
|
||||||
|
> `bank:manage-accounts` ability, and `/inicio`'s chequera card now names the
|
||||||
|
> account it is reading rather than implying a single register.
|
||||||
|
>
|
||||||
|
> **Verified end to end:** a second account (USD) was created through the API,
|
||||||
|
> a movement captured into it, and the MXN register's totals confirmed
|
||||||
|
> unchanged (22,669 movements, net 1,014,266.97) with zero cross-account leak
|
||||||
|
> in list/stats/facets/summary. Missing `bankAccountId` returns 400, unknown
|
||||||
|
> returns 404, capture into a closed account returns 400, and an attempt to
|
||||||
|
> PATCH an account's `currency` is rejected by DTO whitelisting. The test
|
||||||
|
> account was then deleted — the real Seguros bank is still the open question
|
||||||
|
> below, so nothing was left behind guessing at it.
|
||||||
|
>
|
||||||
|
> **Two deviations from the design below**, both tightening it:
|
||||||
|
> - `bank_transactions` also gained an `@@index([bankAccountId, transactionDate])`.
|
||||||
|
> Every read is now filtered by account and ordered/grouped by date; without
|
||||||
|
> it each of them is a full scan of the 22k-row table.
|
||||||
|
> - `UpdateBankAccountDto` deliberately has **no `currency` field**. The
|
||||||
|
> movements already booked in an account are denominated in it, so editing it
|
||||||
|
> would silently re-denominate history instead of converting it. Currency is
|
||||||
|
> set once, at creation.
|
||||||
|
>
|
||||||
|
> Still open: which bank the Seguros USD account is actually at (see Open
|
||||||
|
> questions). Until that answer arrives the office has exactly one chequera and
|
||||||
|
> the UI behaves as it always did, just scoped explicitly.
|
||||||
|
|
||||||
### Motivation
|
### Motivation
|
||||||
|
|
||||||
Seguros uses a US bank account; Utilities uses a Mexican bank account. The
|
Seguros uses a US bank account; Utilities uses a Mexican bank account. The
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
One-off schema+data step for the multi-bank chequera
|
||||||
|
(docs/RECEIPT_CAPTURE_SPEC.md §3).
|
||||||
|
|
||||||
|
`bank_transactions.bankAccountId` is REQUIRED in the Prisma schema, so
|
||||||
|
`prisma db push` cannot introduce it on a table that already holds 22k rows.
|
||||||
|
This script does the ordered dance that push can't:
|
||||||
|
|
||||||
|
1. create `banks` / `bank_accounts` (same DDL Prisma generates)
|
||||||
|
2. seed the one account every existing row belongs to — Scotiabank MXN,
|
||||||
|
the office's Utilities chequera, which is all `SCOTHIA.mdb` ever was
|
||||||
|
3. add `bankAccountId` NULLable, backfill every row to that account,
|
||||||
|
then promote it to NOT NULL and attach the FK + index
|
||||||
|
|
||||||
|
On a database that predates the feature, run it BEFORE `prisma db push`; push
|
||||||
|
then sees no drift. On a fresh environment push creates the tables itself and
|
||||||
|
this only seeds the rows. Either way `transform_bank.py` needs the account to
|
||||||
|
exist, so `run_all.py` runs it first. Idempotent — safe to re-run, and
|
||||||
|
re-running once a second account exists does NOT re-point rows (the backfill
|
||||||
|
only touches NULLs).
|
||||||
|
|
||||||
|
./.venv/bin/python backfill_bank_accounts.py --env dev
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from dbenv import connect
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
|
# The account every migrated SCOTHIA row belongs to. Its id is derived, not
|
||||||
|
# random, so a re-run against a half-applied database finds the same row and
|
||||||
|
# `transform_bank.py` can resolve it by label without a lookup table.
|
||||||
|
SCOTIABANK = "Scotiabank"
|
||||||
|
UTILITIES_ACCOUNT = "Utilities — Scotiabank (MXN)"
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(c, name: str) -> bool:
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = %s",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
return c.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(c, table: str, column: str) -> bool:
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = %s AND column_name = %s",
|
||||||
|
(table, column),
|
||||||
|
)
|
||||||
|
return c.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def constraint_exists(c, table: str, name: str) -> bool:
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.table_constraints "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = %s AND constraint_name = %s",
|
||||||
|
(table, name),
|
||||||
|
)
|
||||||
|
return c.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(c, table: str, name: str) -> bool:
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = %s AND index_name = %s",
|
||||||
|
(table, name),
|
||||||
|
)
|
||||||
|
return c.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# `--sync` is accepted and ignored: this step is idempotent by nature, so
|
||||||
|
# it behaves identically in both modes and can sit in run_all's two lists.
|
||||||
|
env, _sync_mode = parse_mode()
|
||||||
|
conn = connect(env)
|
||||||
|
c = conn.cursor()
|
||||||
|
print(f"[bank-accounts] target env: {env}")
|
||||||
|
|
||||||
|
# --- 1. tables ----------------------------------------------------------
|
||||||
|
if not table_exists(c, "banks"):
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `banks` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`country` VARCHAR(191) NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `banks_name_key` (`name`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" created banks")
|
||||||
|
|
||||||
|
if not table_exists(c, "bank_accounts"):
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `bank_accounts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`bankId` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`currency` ENUM('USD','MXN') NOT NULL,
|
||||||
|
`businessLine` ENUM('UTILITY','INSURANCE','TRUST') NULL,
|
||||||
|
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `bank_accounts_bankId_fkey` (`bankId`),
|
||||||
|
CONSTRAINT `bank_accounts_bankId_fkey` FOREIGN KEY (`bankId`)
|
||||||
|
REFERENCES `banks` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" created bank_accounts")
|
||||||
|
|
||||||
|
# --- 2. seed the Utilities/Scotiabank chequera --------------------------
|
||||||
|
c.execute("SELECT id FROM banks WHERE name = %s", (SCOTIABANK,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
bank_id = row[0]
|
||||||
|
else:
|
||||||
|
bank_id = str(uuid.uuid4())
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO banks (id, name, country) VALUES (%s, %s, %s)",
|
||||||
|
(bank_id, SCOTIABANK, "MX"),
|
||||||
|
)
|
||||||
|
print(f" seeded bank {SCOTIABANK}")
|
||||||
|
|
||||||
|
c.execute("SELECT id FROM bank_accounts WHERE label = %s", (UTILITIES_ACCOUNT,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
account_id = row[0]
|
||||||
|
else:
|
||||||
|
account_id = str(uuid.uuid4())
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO bank_accounts (id, bankId, label, currency, businessLine, active) "
|
||||||
|
"VALUES (%s, %s, %s, 'MXN', 'UTILITY', 1)",
|
||||||
|
(account_id, bank_id, UTILITIES_ACCOUNT),
|
||||||
|
)
|
||||||
|
print(f" seeded account {UTILITIES_ACCOUNT}")
|
||||||
|
print(f" account id: {account_id}")
|
||||||
|
|
||||||
|
# --- 3. column, backfill, promote to NOT NULL ---------------------------
|
||||||
|
if not column_exists(c, "bank_transactions", "bankAccountId"):
|
||||||
|
c.execute("ALTER TABLE `bank_transactions` ADD COLUMN `bankAccountId` VARCHAR(191) NULL")
|
||||||
|
print(" added bank_transactions.bankAccountId (nullable)")
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
"UPDATE bank_transactions SET bankAccountId = %s WHERE bankAccountId IS NULL",
|
||||||
|
(account_id,),
|
||||||
|
)
|
||||||
|
print(f" backfilled {c.rowcount} movement(s) to {UTILITIES_ACCOUNT}")
|
||||||
|
|
||||||
|
c.execute("SELECT COUNT(*) FROM bank_transactions WHERE bankAccountId IS NULL")
|
||||||
|
orphans = c.fetchone()[0]
|
||||||
|
if orphans:
|
||||||
|
raise SystemExit(f"abort: {orphans} bank_transactions still have no account")
|
||||||
|
|
||||||
|
c.execute("ALTER TABLE `bank_transactions` MODIFY `bankAccountId` VARCHAR(191) NOT NULL")
|
||||||
|
|
||||||
|
if not index_exists(c, "bank_transactions", "bank_transactions_bankAccountId_transactionDate_idx"):
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX `bank_transactions_bankAccountId_transactionDate_idx` "
|
||||||
|
"ON `bank_transactions` (`bankAccountId`, `transactionDate`)"
|
||||||
|
)
|
||||||
|
print(" created (bankAccountId, transactionDate) index")
|
||||||
|
|
||||||
|
if not constraint_exists(c, "bank_transactions", "bank_transactions_bankAccountId_fkey"):
|
||||||
|
c.execute(
|
||||||
|
"ALTER TABLE `bank_transactions` "
|
||||||
|
"ADD CONSTRAINT `bank_transactions_bankAccountId_fkey` FOREIGN KEY (`bankAccountId`) "
|
||||||
|
"REFERENCES `bank_accounts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE"
|
||||||
|
)
|
||||||
|
print(" attached bankAccountId FK")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
"SELECT a.label, a.currency, COUNT(t.id), COALESCE(SUM(t.amount), 0) "
|
||||||
|
"FROM bank_accounts a LEFT JOIN bank_transactions t ON t.bankAccountId = a.id "
|
||||||
|
"GROUP BY a.id, a.label, a.currency ORDER BY a.label"
|
||||||
|
)
|
||||||
|
print("=== Multi-bank chequera ready ===")
|
||||||
|
for label, currency, n, total in c.fetchall():
|
||||||
|
print(f" {label:36} {currency} {n:6} movimientos neto {total}")
|
||||||
|
print(" validation: OK")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -44,6 +44,9 @@ STEPS = [
|
|||||||
"transform_policies.py",
|
"transform_policies.py",
|
||||||
"transform_transactions.py",
|
"transform_transactions.py",
|
||||||
"prune_empty_customers.py",
|
"prune_empty_customers.py",
|
||||||
|
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
|
||||||
|
# transform_bank.py fails fast without it.
|
||||||
|
"backfill_bank_accounts.py",
|
||||||
"transform_bank.py",
|
"transform_bank.py",
|
||||||
"blob_extract.py",
|
"blob_extract.py",
|
||||||
]
|
]
|
||||||
@@ -56,6 +59,9 @@ SYNC_STEPS = [
|
|||||||
# Manual-safe prune: drops legacy-owned empties that the customer upsert
|
# Manual-safe prune: drops legacy-owned empties that the customer upsert
|
||||||
# re-creates from Parquet, but leaves manually-added customers alone.
|
# re-creates from Parquet, but leaves manually-added customers alone.
|
||||||
"prune_empty_customers.py",
|
"prune_empty_customers.py",
|
||||||
|
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
|
||||||
|
# transform_bank.py fails fast without it.
|
||||||
|
"backfill_bank_accounts.py",
|
||||||
"transform_bank.py",
|
"transform_bank.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,20 @@ Sources:
|
|||||||
from the spelled-out "cantidad en letra"
|
from the spelled-out "cantidad en letra"
|
||||||
- TABLA RAMODOS -> business_line_categories (line-of-business lookup)
|
- TABLA RAMODOS -> business_line_categories (line-of-business lookup)
|
||||||
|
|
||||||
|
Bank account: SCOTHIA is the Utilities MXN chequera and nothing else — DATOS
|
||||||
|
E/I carry no bank or currency column — so every row loads against the single
|
||||||
|
account seeded by `backfill_bank_accounts.py`, which must have run first.
|
||||||
|
`banks` / `bank_accounts` are NOT truncated here; only the movements are. (In
|
||||||
|
full-rebuild mode that still clears app-captured rows on every account, the
|
||||||
|
same whole-database truncate every transform in this pipeline does — use
|
||||||
|
`--sync` to upsert instead.)
|
||||||
|
|
||||||
Category link: DATOS E/I have no explicit FK to TABLA RAMODOS — the ramo is
|
Category link: DATOS E/I have no explicit FK to TABLA RAMODOS — the ramo is
|
||||||
inferred from the CONCEPTO text, which is a fuzzy classification, not a stored
|
inferred from the CONCEPTO text, which is a fuzzy classification, not a stored
|
||||||
key. So the categories are loaded but bank_transactions.categoryId is left
|
key. So the categories are loaded but bank_transactions.categoryId is left
|
||||||
NULL for now; a concept->ramo classifier is a later enhancement.
|
NULL for now; a concept->ramo classifier is a later enhancement.
|
||||||
|
|
||||||
Idempotent (truncate + rebuild). Run:
|
Idempotent (rebuild the legacy rows). Run:
|
||||||
./.venv/bin/python transform_bank.py --env dev
|
./.venv/bin/python transform_bank.py --env dev
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -27,6 +35,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
from backfill_bank_accounts import UTILITIES_ACCOUNT
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
from sync import parse_mode
|
from sync import parse_mode
|
||||||
|
|
||||||
@@ -78,6 +87,19 @@ def main():
|
|||||||
print(f"[bank] target env: {env}")
|
print(f"[bank] target env: {env}")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Every SCOTHIA row belongs to the one Utilities MXN chequera. Resolved by
|
||||||
|
# label rather than created here, so this script can't silently open a
|
||||||
|
# second copy of the account if the backfill hasn't run.
|
||||||
|
c.execute("SELECT id FROM bank_accounts WHERE label = %s", (UTILITIES_ACCOUNT,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise SystemExit(
|
||||||
|
f"missing bank account {UTILITIES_ACCOUNT!r} — run "
|
||||||
|
f"backfill_bank_accounts.py --env {env} first"
|
||||||
|
)
|
||||||
|
account_id = row[0]
|
||||||
|
print(f"[bank] account: {UTILITIES_ACCOUNT} ({account_id})")
|
||||||
|
|
||||||
# business_line_categories (dedup TABLA RAMODOS)
|
# business_line_categories (dedup TABLA RAMODOS)
|
||||||
cats, seen = [], set()
|
cats, seen = [], set()
|
||||||
for _, r in load("tabla_ramodos").iterrows():
|
for _, r in load("tabla_ramodos").iterrows():
|
||||||
@@ -96,7 +118,8 @@ def main():
|
|||||||
skip_date += 1
|
skip_date += 1
|
||||||
return
|
return
|
||||||
rows.append((
|
rows.append((
|
||||||
str(uuid.uuid4()), td, s(r["tipo"]), s(r["num"]), s(r["concepto"]),
|
str(uuid.uuid4()), account_id,
|
||||||
|
td, s(r["tipo"]), s(r["num"]), s(r["concepto"]),
|
||||||
amount, None, # categoryId left NULL (see header)
|
amount, None, # categoryId left NULL (see header)
|
||||||
1 if truthy(r["operado"]) else 0,
|
1 if truthy(r["operado"]) else 0,
|
||||||
1 if (income and truthy(r["transferido"])) else 0,
|
1 if (income and truthy(r["transferido"])) else 0,
|
||||||
@@ -110,9 +133,17 @@ def main():
|
|||||||
for _, r in load("datos_e").iterrows():
|
for _, r in load("datos_e").iterrows():
|
||||||
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
||||||
|
|
||||||
|
COLS = (
|
||||||
|
"id,bankAccountId,transactionDate,transactionType,reference,concept,amount,"
|
||||||
|
"categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId"
|
||||||
|
)
|
||||||
|
PLACEHOLDERS = ",".join(["%s"] * 14)
|
||||||
|
|
||||||
if sync_mode:
|
if sync_mode:
|
||||||
|
# bankAccountId is deliberately absent from the UPDATE clause: an
|
||||||
|
# account moved by hand in the app must not be dragged back.
|
||||||
for row in rows:
|
for row in rows:
|
||||||
c.execute("INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
|
c.execute(f"INSERT INTO bank_transactions ({COLS}) VALUES ({PLACEHOLDERS}) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
|
||||||
else:
|
else:
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
for t in ("bank_transactions", "business_line_categories"):
|
for t in ("bank_transactions", "business_line_categories"):
|
||||||
@@ -120,7 +151,7 @@ def main():
|
|||||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
||||||
c.executemany(
|
c.executemany(
|
||||||
"INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", rows)
|
f"INSERT INTO bank_transactions ({COLS}) VALUES ({PLACEHOLDERS})", rows)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def count(t):
|
def count(t):
|
||||||
@@ -136,6 +167,15 @@ def main():
|
|||||||
for src, n, tot in by_src:
|
for src, n, tot in by_src:
|
||||||
print(f" {(src or '(manual)'):10} {n:6} sum {tot}")
|
print(f" {(src or '(manual)'):10} {n:6} sum {tot}")
|
||||||
print(f" net balance movement : {net}")
|
print(f" net balance movement : {net}")
|
||||||
|
# Per account, never a cross-account total: the registers are in different
|
||||||
|
# currencies and summing them produces a figure that never existed.
|
||||||
|
c.execute(
|
||||||
|
"SELECT a.label, a.currency, COUNT(t.id), COALESCE(SUM(t.amount), 0) "
|
||||||
|
"FROM bank_accounts a LEFT JOIN bank_transactions t ON t.bankAccountId = a.id "
|
||||||
|
"GROUP BY a.id, a.label, a.currency ORDER BY a.label"
|
||||||
|
)
|
||||||
|
for label, currency, n, total in c.fetchall():
|
||||||
|
print(f" {label:34} {currency} {n:6} neto {total}")
|
||||||
print(f" -> business_line_categories: {count('business_line_categories')}")
|
print(f" -> business_line_categories: {count('business_line_categories')}")
|
||||||
print(" validation: OK")
|
print(" validation: OK")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -510,10 +510,46 @@ model BusinessLineCategory {
|
|||||||
@@map("business_line_categories")
|
@@map("business_line_categories")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The institution a chequera is held at. Purely a grouping label for the
|
||||||
|
/// accounts under it — no money hangs off a Bank directly.
|
||||||
|
model Bank {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
/// "MX" | "US" — informational, used only to label the account picker.
|
||||||
|
country String?
|
||||||
|
accounts BankAccount[]
|
||||||
|
|
||||||
|
@@map("banks")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One physical chequera. Currency is fixed per account, because a real bank
|
||||||
|
/// account is: there is deliberately NO currency column on BankTransaction, a
|
||||||
|
/// movement inherits its account's. This is what keeps the MXN (Utilities /
|
||||||
|
/// Scotiabank) and USD (Seguros) registers from ever being summed together,
|
||||||
|
/// the same rule the customer ledger follows per currency.
|
||||||
|
model BankAccount {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
bankId String
|
||||||
|
bank Bank @relation(fields: [bankId], references: [id])
|
||||||
|
/// Staff-facing name, e.g. "Utilities — Scotiabank (MXN)".
|
||||||
|
label String
|
||||||
|
currency Currency
|
||||||
|
/// Hint only, never enforced — one chequera can pay for more than one line.
|
||||||
|
businessLine TransactionDomain?
|
||||||
|
active Boolean @default(true)
|
||||||
|
movements BankTransaction[]
|
||||||
|
|
||||||
|
@@map("bank_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
/// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one
|
/// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one
|
||||||
/// signed-amount table: income positive, expense negative.
|
/// signed-amount table: income positive, expense negative.
|
||||||
model BankTransaction {
|
model BankTransaction {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
// Required: a movement with no known account isn't reconcilable against a
|
||||||
|
// statement. Every migrated row is SCOTHIA = the Utilities MXN account.
|
||||||
|
bankAccountId String
|
||||||
|
bankAccount BankAccount @relation(fields: [bankAccountId], references: [id])
|
||||||
transactionDate DateTime
|
transactionDate DateTime
|
||||||
transactionType String?
|
transactionType String?
|
||||||
reference String?
|
reference String?
|
||||||
@@ -531,7 +567,10 @@ model BankTransaction {
|
|||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
|
|
||||||
|
// Provenance stays globally unique: every legacy row belongs to the one
|
||||||
|
// Scotiabank account, so adding accounts never collides here.
|
||||||
@@unique([legacySourceTable, legacyId])
|
@@unique([legacySourceTable, legacyId])
|
||||||
|
@@index([bankAccountId, transactionDate])
|
||||||
@@map("bank_transactions")
|
@@map("bank_transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user