import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; import { CreateBankMovementDto } from "./bank-movement.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. * * SINGLE CURRENCY. Unlike the customer ledger there is no currency column here: * `bank_transactions` has none, and every `amountInWords` on the egreso side is * spelled out in PESOS. All figures in this module are MXN. * * 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 { 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[] = []; 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 } : {}), }, }); } return and.length ? { 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. */ async stats() { const [count, bounds, pending, transferred, totals] = await Promise.all([ this.prisma.bankTransaction.count({ where: NOT_VOIDED }), this.prisma.bankTransaction.aggregate({ where: NOT_VOIDED, _min: { transactionDate: true }, _max: { transactionDate: true }, }), this.prisma.bankTransaction.count({ where: { AND: [{ cleared: false }, NOT_VOIDED] }, }), this.prisma.bankTransaction.count({ where: { AND: [{ transferred: true }, NOT_VOIDED] }, }), this.totalsFor({}), ]); return { movements: count, firstMovement: bounds._min.transactionDate, lastMovement: bounds._max.transactionDate, pending, transferred, ...totals, }; } /** Year list for the period filter, newest first. */ async facets() { 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 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. */ async summary(year?: number) { const years = await this.prisma.$queryRaw` 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 GROUP BY period ORDER BY period ASC `; const months = year ? await this.prisma.$queryRaw` 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 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), }; } // --- writes (append + void) ----------------------------------------------- async createMovement(dto: CreateBankMovementDto) { const date = new Date(dto.transactionDate); if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida"); return this.prisma.bankTransaction.create({ data: { 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"; }