Files
jorgecuadros-platform/apps/api/src/bank/bank.service.ts
T
rmancinasandClaude Opus 4.8 548eeb5798 feat(ledger,bank): append + void write API, voided excluded from totals (plan phase 5 API)
Transactions and the bank register become append-only with a void
(reversal) action — never edited or hard-deleted. This is the API half of
phase 5; the capture/void web UI is the remaining piece.

Schema:
- Transaction and BankTransaction gain voidedAt + voidedById. A non-null
  voidedAt reverses the row. Pushed to dev.

Correctness (the high-stakes part):
- Every aggregate excludes voided rows: billing movements totals, the raw
  balances SQL, stats (groupBy + the sides/crossLine raw subqueries +
  first/last), facets (types/sources/years); the statement's running
  balance freezes on a voided row and its per-currency/per-domain/per-type
  summaries skip them; customers.detail and property owner-ledger groupBy;
  and every bank total (totalsFor, stats counts/bounds, facets + summary
  raw SQL). List views still return voided rows with a `voided` flag so
  the UI can strike them through.
- Bank's legacy zero-amount "void" cheques are unchanged and distinct from
  app voids (voidedAt).

API:
- POST /billing + POST /billing/:id/void (ledger:create / ledger:void);
  POST /bank + POST /bank/:id/void (bank:create / bank:void). Create needs
  STAFF+, void needs MANAGER+. Double-void -> 400, unknown id -> 404,
  bad date -> 400. Mutations audited. DTOs added.

Verified against dev end-to-end: a -500 MXN charge moved a customer
balance 31082.08 -> 30582.08, and voiding it returned it to 31082.08 to
the cent; a +1234.56 bank ingreso moved net 899375.77 -> 900610.33 and
voiding returned it to 899375.77. VIEWER create/void both 403,
double-void 400. API compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:34:47 -07:00

406 lines
14 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";
/**
* 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<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
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
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";
}