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>
560 lines
19 KiB
TypeScript
560 lines
19 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
|
import { Prisma } from "@jorgecuadros/database";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { CreateBankMovementDto } from "./bank-movement.dto";
|
|
import {
|
|
CreateBankAccountDto,
|
|
CreateBankDto,
|
|
UpdateBankAccountDto,
|
|
UpdateBankDto,
|
|
} from "./bank-account.dto";
|
|
|
|
/**
|
|
* App-voided rows (voidedAt set) are reversed and must leave every
|
|
* income/expense/net total. This is distinct from the legacy zero-amount
|
|
* "void" cheques, which stay as amount-0 rows. List views still show voided
|
|
* rows struck-through.
|
|
*/
|
|
const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };
|
|
|
|
/**
|
|
* Bank register (chequera) module — plan step 7.
|
|
*
|
|
* This is the office's OWN operating checking account, migrated from SCOTHIA's
|
|
* `DATOS I` (ingresos) / `DATOS E` (egresos) into one signed-amount table. It
|
|
* carries no customer FK and is deliberately NOT part of `/estado-cuenta`: that
|
|
* ledger is what customers owe the office, this one is the office's own money.
|
|
* The two must never be added together or shown in the same total.
|
|
*
|
|
* SIGN CONVENTION (set by migration/transform_bank.py):
|
|
* - positive = ingreso (a deposit into the account)
|
|
* - negative = egreso (a payment out of it)
|
|
* - exactly zero = a cancelled/void cheque. 787 of the 791 zero rows say
|
|
* CANCELADO or VOID in the concept; they are neither an income nor an
|
|
* expense and are excluded from both sides, the way the ~193 zero rows are
|
|
* in the customer ledger.
|
|
*
|
|
* ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
|
|
* than one chequera (Utilities banks in MXN, Seguros in USD), so every read
|
|
* path here is scoped to exactly one `bankAccountId` — never "all accounts".
|
|
* There is deliberately no currency column on `bank_transactions`: a movement
|
|
* inherits its account's, the way a real bank account doesn't mix currencies.
|
|
* Callers must therefore pass an account id; an unscoped total would sum MXN
|
|
* and USD into a figure that never existed, the same mistake the billing
|
|
* module's per-currency rule exists to prevent.
|
|
*
|
|
* The 22,669 migrated rows are all SCOTHIA = the Utilities MXN account
|
|
* (backfilled by `migration/backfill_bank_accounts.py`), and their
|
|
* `amountInWords` on the egreso side is spelled out in PESOS accordingly.
|
|
*
|
|
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
|
|
* rows and this module does not filter or group by it, because the data cannot
|
|
* support it:
|
|
* - `DATOS E` / `DATOS I` have no ramo column at all — the only columns are
|
|
* fecha, tipo, num, concepto, ingreso/egreso, operado, notas and (egresos)
|
|
* cantidad en letra. There is no key to migrate.
|
|
* - `concepto` is a *payee* name (PAYPAL, CFE, TELEFONOS DEL NOROESTE, and
|
|
* ~1,900 individual people), not a classification. Zero of the 22,354
|
|
* concepts match a `business_line_categories` name.
|
|
* - the 66 categories in TABLA RAMODOS are a property-management expense
|
|
* chart of accounts (Payroll, Pool (Labor), Gardening, Trash Coll) plus
|
|
* owner names with property numbers — not the insurance/servicios/
|
|
* fideicomiso split. Classifying concepts into them would not produce a
|
|
* business-line breakdown even if it worked.
|
|
* A concept->ramo classifier would therefore be invented data, so the register
|
|
* is browsable by date, payee, amount and cheque number instead.
|
|
*/
|
|
|
|
/** Which side of the register a movement is on. */
|
|
export type BankDirection = "income" | "expense" | "void";
|
|
|
|
/** `operado` in the source: whether the bank has cleared the movement. */
|
|
export type BankCleared = "cleared" | "pending";
|
|
|
|
export type BankSort =
|
|
| "date_desc"
|
|
| "date_asc"
|
|
| "amount_desc"
|
|
| "amount_asc"
|
|
| "reference";
|
|
|
|
export interface BankListParams {
|
|
/** Which chequera to read. Required — see the module header. */
|
|
bankAccountId: string;
|
|
query?: string;
|
|
page: number;
|
|
pageSize: number;
|
|
direction?: BankDirection;
|
|
cleared?: BankCleared;
|
|
/** Inclusive bounds on `transactionDate`. */
|
|
from?: Date;
|
|
to?: Date;
|
|
sort: BankSort;
|
|
}
|
|
|
|
/** Raw shape of a year/month rollup row. */
|
|
interface PeriodRow {
|
|
period: number;
|
|
count: bigint | number | string;
|
|
income: Prisma.Decimal | null;
|
|
expense: Prisma.Decimal | null;
|
|
net: Prisma.Decimal | null;
|
|
}
|
|
|
|
function num(v: bigint | number | string | null | undefined): number {
|
|
if (v === null || v === undefined) return 0;
|
|
return typeof v === "number" ? v : Number(v);
|
|
}
|
|
|
|
function dec(v: Prisma.Decimal | null | undefined): string {
|
|
return (v ?? new Prisma.Decimal(0)).toFixed(2);
|
|
}
|
|
|
|
@Injectable()
|
|
export class BankService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
|
const and: Prisma.BankTransactionWhereInput[] = [
|
|
{ bankAccountId: p.bankAccountId },
|
|
];
|
|
|
|
if (p.query && p.query.trim()) {
|
|
const q = p.query.trim();
|
|
and.push({
|
|
OR: [
|
|
{ concept: { contains: q } },
|
|
{ reference: { contains: q } },
|
|
{ notes: { contains: q } },
|
|
{ amountInWords: { contains: q } },
|
|
],
|
|
});
|
|
}
|
|
if (p.direction === "income") and.push({ amount: { gt: 0 } });
|
|
if (p.direction === "expense") and.push({ amount: { lt: 0 } });
|
|
if (p.direction === "void") and.push({ amount: 0 });
|
|
if (p.cleared) and.push({ cleared: p.cleared === "cleared" });
|
|
if (p.from || p.to) {
|
|
and.push({
|
|
transactionDate: {
|
|
...(p.from ? { gte: p.from } : {}),
|
|
...(p.to ? { lte: p.to } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
// Never empty: the account clause above is always present, so no read can
|
|
// accidentally span every chequera.
|
|
return { AND: and };
|
|
}
|
|
|
|
private orderBy(
|
|
sort: BankSort,
|
|
): Prisma.BankTransactionOrderByWithRelationInput[] {
|
|
switch (sort) {
|
|
case "date_asc":
|
|
return [{ transactionDate: "asc" }, { reference: "asc" }];
|
|
case "amount_desc":
|
|
return [{ amount: "desc" }];
|
|
case "amount_asc":
|
|
return [{ amount: "asc" }];
|
|
case "reference":
|
|
// `reference` is the cheque number on egresos and the deposit slip on
|
|
// ingresos; it is a string column, so this is a lexical sort.
|
|
return [{ reference: "asc" }];
|
|
default:
|
|
return [{ transactionDate: "desc" }, { reference: "desc" }];
|
|
}
|
|
}
|
|
|
|
/** The register itself: every deposit and payment, filterable. */
|
|
async list(params: BankListParams) {
|
|
const where = this.where(params);
|
|
|
|
const [total, rows] = await this.prisma.$transaction([
|
|
this.prisma.bankTransaction.count({ where }),
|
|
this.prisma.bankTransaction.findMany({
|
|
where,
|
|
skip: (params.page - 1) * params.pageSize,
|
|
take: params.pageSize,
|
|
orderBy: this.orderBy(params.sort),
|
|
select: {
|
|
id: true,
|
|
transactionDate: true,
|
|
transactionType: true,
|
|
reference: true,
|
|
concept: true,
|
|
amount: true,
|
|
cleared: true,
|
|
transferred: true,
|
|
notes: true,
|
|
amountInWords: true,
|
|
legacySourceTable: true,
|
|
voidedAt: true,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
// Totals cover the whole filtered set, not just the page — the figure staff
|
|
// read off a filtered view ("what did we pay CFE in 2025") has to.
|
|
const totals = await this.totalsFor(where);
|
|
|
|
return {
|
|
items: rows.map((r) => ({
|
|
id: r.id,
|
|
transactionDate: r.transactionDate,
|
|
transactionType: r.transactionType,
|
|
reference: r.reference,
|
|
concept: r.concept,
|
|
amount: r.amount,
|
|
direction: directionOf(r.amount),
|
|
cleared: r.cleared,
|
|
transferred: r.transferred,
|
|
notes: r.notes,
|
|
amountInWords: r.amountInWords,
|
|
source: r.legacySourceTable,
|
|
voided: r.voidedAt != null,
|
|
})),
|
|
total,
|
|
page: params.page,
|
|
pageSize: params.pageSize,
|
|
pageCount: Math.ceil(total / params.pageSize),
|
|
totals,
|
|
};
|
|
}
|
|
|
|
/** Income / expense / void split over an arbitrary filter. */
|
|
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
|
|
const [income, expense, voided] = await Promise.all([
|
|
this.prisma.bankTransaction.aggregate({
|
|
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
|
|
_sum: { amount: true },
|
|
_count: { _all: true },
|
|
}),
|
|
this.prisma.bankTransaction.aggregate({
|
|
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
|
|
_sum: { amount: true },
|
|
_count: { _all: true },
|
|
}),
|
|
this.prisma.bankTransaction.count({
|
|
where: { AND: [where, { amount: 0 }, NOT_VOIDED] },
|
|
}),
|
|
]);
|
|
|
|
const inSum = income._sum.amount ?? new Prisma.Decimal(0);
|
|
const outSum = expense._sum.amount ?? new Prisma.Decimal(0);
|
|
|
|
return {
|
|
income: inSum.toFixed(2),
|
|
incomeCount: income._count._all,
|
|
expense: outSum.toFixed(2),
|
|
expenseCount: expense._count._all,
|
|
net: inSum.plus(outSum).toFixed(2),
|
|
voidCount: voided,
|
|
};
|
|
}
|
|
|
|
/** Top-line figures for the bank page header, for one chequera. */
|
|
async stats(bankAccountId: string) {
|
|
const account = { bankAccountId };
|
|
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
|
this.prisma.bankTransaction.count({
|
|
where: { AND: [account, NOT_VOIDED] },
|
|
}),
|
|
this.prisma.bankTransaction.aggregate({
|
|
where: { AND: [account, NOT_VOIDED] },
|
|
_min: { transactionDate: true },
|
|
_max: { transactionDate: true },
|
|
}),
|
|
this.prisma.bankTransaction.count({
|
|
where: { AND: [account, { cleared: false }, NOT_VOIDED] },
|
|
}),
|
|
this.prisma.bankTransaction.count({
|
|
where: { AND: [account, { transferred: true }, NOT_VOIDED] },
|
|
}),
|
|
this.totalsFor(account),
|
|
]);
|
|
|
|
return {
|
|
movements: count,
|
|
firstMovement: bounds._min.transactionDate,
|
|
lastMovement: bounds._max.transactionDate,
|
|
pending,
|
|
transferred,
|
|
...totals,
|
|
};
|
|
}
|
|
|
|
/** Year list for the period filter, newest first, for one chequera. */
|
|
async facets(bankAccountId: string) {
|
|
// Tagged-template `$queryRaw`: the interpolation below is a bound
|
|
// parameter, not string concatenation.
|
|
const years = await this.prisma.$queryRaw<
|
|
{ year: number; count: bigint | number | string }[]
|
|
>`
|
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
|
FROM bank_transactions
|
|
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
|
GROUP BY year
|
|
ORDER BY year DESC
|
|
`;
|
|
|
|
return {
|
|
years: years.map((y) => ({ year: Number(y.year), count: num(y.count) })),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Period rollup for the "Resumen" view: one row per year, plus one row per
|
|
* month when a year is selected.
|
|
*
|
|
* `cumulative` is the running sum of every movement from the start of the
|
|
* register — NOT the bank balance. SCOTHIA carries no opening balance (its
|
|
* `BAN` table holds only the bank's name), so the register starts at zero on
|
|
* its first row in 2013 and the running figure is the net movement since
|
|
* then. Labelled as such in the UI so it is never read as a statement balance.
|
|
*
|
|
* Both rollups take the SAME `bankAccountId`. Scoping only one of them would
|
|
* leave the year list and its month drill-down describing different books —
|
|
* wrong in a way that still looks right.
|
|
*/
|
|
async summary(bankAccountId: string, year?: number) {
|
|
const years = await this.prisma.$queryRaw<PeriodRow[]>`
|
|
SELECT
|
|
YEAR(transactionDate) AS period,
|
|
COUNT(*) AS count,
|
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
|
SUM(amount) AS net
|
|
FROM bank_transactions
|
|
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
|
GROUP BY period
|
|
ORDER BY period ASC
|
|
`;
|
|
|
|
const months = year
|
|
? await this.prisma.$queryRaw<PeriodRow[]>`
|
|
SELECT
|
|
MONTH(transactionDate) AS period,
|
|
COUNT(*) AS count,
|
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
|
SUM(amount) AS net
|
|
FROM bank_transactions
|
|
WHERE YEAR(transactionDate) = ${year}
|
|
AND voidedAt IS NULL
|
|
AND bankAccountId = ${bankAccountId}
|
|
GROUP BY period
|
|
ORDER BY period ASC
|
|
`
|
|
: [];
|
|
|
|
// Cumulative across years runs from the first row of the register; the
|
|
// monthly cumulative opens at the selected year's opening figure so the two
|
|
// tables agree.
|
|
let running = new Prisma.Decimal(0);
|
|
const yearRows = years.map((r) => {
|
|
const net = r.net ?? new Prisma.Decimal(0);
|
|
const opening = running;
|
|
running = running.plus(net);
|
|
return {
|
|
period: Number(r.period),
|
|
count: num(r.count),
|
|
income: dec(r.income),
|
|
expense: dec(r.expense),
|
|
net: net.toFixed(2),
|
|
opening: opening.toFixed(2),
|
|
cumulative: running.toFixed(2),
|
|
};
|
|
});
|
|
|
|
const opening =
|
|
year === undefined
|
|
? new Prisma.Decimal(0)
|
|
: new Prisma.Decimal(
|
|
yearRows.find((y) => y.period === year)?.opening ?? "0",
|
|
);
|
|
|
|
let monthRunning = opening;
|
|
const monthRows = months.map((r) => {
|
|
const net = r.net ?? new Prisma.Decimal(0);
|
|
monthRunning = monthRunning.plus(net);
|
|
return {
|
|
period: Number(r.period),
|
|
count: num(r.count),
|
|
income: dec(r.income),
|
|
expense: dec(r.expense),
|
|
net: net.toFixed(2),
|
|
cumulative: monthRunning.toFixed(2),
|
|
};
|
|
});
|
|
|
|
return {
|
|
year: year ?? null,
|
|
years: yearRows,
|
|
months: monthRows,
|
|
opening: opening.toFixed(2),
|
|
};
|
|
}
|
|
|
|
// --- accounts -------------------------------------------------------------
|
|
|
|
/**
|
|
* Every chequera, closed ones included — a closed account still has to be
|
|
* selectable to read its history, it just isn't offered for new captures.
|
|
*/
|
|
async listAccounts() {
|
|
const rows = await this.prisma.bankAccount.findMany({
|
|
orderBy: [{ active: "desc" }, { label: "asc" }],
|
|
select: {
|
|
id: true,
|
|
label: true,
|
|
currency: true,
|
|
businessLine: true,
|
|
active: true,
|
|
bank: { select: { id: true, name: true, country: true } },
|
|
},
|
|
});
|
|
return rows.map((a) => ({
|
|
id: a.id,
|
|
label: a.label,
|
|
currency: a.currency,
|
|
businessLine: a.businessLine,
|
|
active: a.active,
|
|
bankId: a.bank.id,
|
|
bankName: a.bank.name,
|
|
bankCountry: a.bank.country,
|
|
}));
|
|
}
|
|
|
|
async listBanks() {
|
|
return this.prisma.bank.findMany({
|
|
orderBy: { name: "asc" },
|
|
select: { id: true, name: true, country: true },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve an account id from a request, or reject. Every read route funnels
|
|
* through this so a bad/missing id is a 400 rather than a silently empty
|
|
* register that reads as "this account has no movements".
|
|
*/
|
|
async requireAccount(bankAccountId: string | undefined) {
|
|
if (!bankAccountId || !bankAccountId.trim())
|
|
throw new BadRequestException("Falta la cuenta bancaria (bankAccountId)");
|
|
const account = await this.prisma.bankAccount.findUnique({
|
|
where: { id: bankAccountId },
|
|
select: { id: true, label: true, currency: true, active: true },
|
|
});
|
|
if (!account)
|
|
throw new NotFoundException(`Cuenta bancaria ${bankAccountId} no existe`);
|
|
return account;
|
|
}
|
|
|
|
async createBank(dto: CreateBankDto) {
|
|
return this.prisma.bank.create({
|
|
data: { name: dto.name.trim(), country: dto.country?.trim() || null },
|
|
});
|
|
}
|
|
|
|
async updateBank(id: string, dto: UpdateBankDto) {
|
|
await this.getBankOr404(id);
|
|
return this.prisma.bank.update({
|
|
where: { id },
|
|
data: {
|
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
|
...(dto.country !== undefined
|
|
? { country: dto.country.trim() || null }
|
|
: {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
private async getBankOr404(id: string) {
|
|
const bank = await this.prisma.bank.findUnique({
|
|
where: { id },
|
|
select: { id: true },
|
|
});
|
|
if (!bank) throw new NotFoundException(`Banco ${id} no existe`);
|
|
return bank;
|
|
}
|
|
|
|
async createAccount(dto: CreateBankAccountDto) {
|
|
await this.getBankOr404(dto.bankId);
|
|
return this.prisma.bankAccount.create({
|
|
data: {
|
|
bankId: dto.bankId,
|
|
label: dto.label.trim(),
|
|
currency: dto.currency,
|
|
businessLine: dto.businessLine ?? null,
|
|
active: dto.active ?? true,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* `currency` is intentionally absent from the update DTO: the movements
|
|
* already booked in this account are denominated in it, so changing it would
|
|
* silently re-denominate history rather than convert it.
|
|
*/
|
|
async updateAccount(id: string, dto: UpdateBankAccountDto) {
|
|
await this.requireAccount(id);
|
|
if (dto.bankId !== undefined) await this.getBankOr404(dto.bankId);
|
|
return this.prisma.bankAccount.update({
|
|
where: { id },
|
|
data: {
|
|
...(dto.bankId !== undefined ? { bankId: dto.bankId } : {}),
|
|
...(dto.label !== undefined ? { label: dto.label.trim() } : {}),
|
|
...(dto.businessLine !== undefined
|
|
? { businessLine: dto.businessLine }
|
|
: {}),
|
|
...(dto.active !== undefined ? { active: dto.active } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
// --- writes (append + void) -----------------------------------------------
|
|
|
|
async createMovement(dto: CreateBankMovementDto) {
|
|
const date = new Date(dto.transactionDate);
|
|
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
|
const account = await this.requireAccount(dto.bankAccountId);
|
|
if (!account.active)
|
|
throw new BadRequestException(
|
|
`La cuenta "${account.label}" está cerrada; no admite movimientos nuevos.`,
|
|
);
|
|
return this.prisma.bankTransaction.create({
|
|
data: {
|
|
bankAccountId: account.id,
|
|
amount: dto.amount,
|
|
transactionDate: date,
|
|
concept: dto.concept,
|
|
reference: dto.reference,
|
|
transactionType: dto.transactionType,
|
|
cleared: dto.cleared ?? false,
|
|
transferred: dto.transferred ?? false,
|
|
notes: dto.notes,
|
|
amountInWords: dto.amountInWords,
|
|
},
|
|
});
|
|
}
|
|
|
|
async voidMovement(id: string, userId: string) {
|
|
const row = await this.prisma.bankTransaction.findUnique({
|
|
where: { id },
|
|
select: { id: true, voidedAt: true },
|
|
});
|
|
if (!row) throw new NotFoundException(`Bank transaction ${id} not found`);
|
|
if (row.voidedAt) throw new BadRequestException("El movimiento ya está anulado");
|
|
return this.prisma.bankTransaction.update({
|
|
where: { id },
|
|
data: { voidedAt: new Date(), voidedById: userId },
|
|
});
|
|
}
|
|
}
|
|
|
|
function directionOf(amount: Prisma.Decimal): BankDirection {
|
|
if (amount.greaterThan(0)) return "income";
|
|
return amount.lessThan(0) ? "expense" : "void";
|
|
}
|