feat(bank): chequera register module (plan step 7)
Adds the office's own bank-register browser over the migrated SCOTHIA
data (22,354 bank_transactions), the last self-contained feature module.
API (apps/api/src/bank):
- GET /bank register browser: search over concepto/reference/notes/
amountInWords; direction (income|expense|void), cleared and
date-range filters; 5 sorts; income/expense/net totals for
the whole filtered set, not just the page
- GET /bank/stats headline income/expense/net + counts, date span, pending
- GET /bank/facets year list for the period filter
- GET /bank/summary year and month rollups with a running net-movement figure
Web (/banco): "Movimientos" register + "Resumen por periodo" with year->month
drill-down; added to the AppShell nav as "Chequera".
Deliberately kept OUT of /estado-cuenta: this is the office's own money, not
customer balances, and the two are never summed or shown together.
No category/ramo dimension, and the deferred concept->ramo classifier is
dropped as won't-build: concepto is a payee name (0 of 22,354 match a
category) and TABLA RAMODOS is an expense chart of accounts + owner names,
not the insurance/servicios/fideicomiso split it was assumed to be, so a
classifier would invent data. Single currency (MXN); the "acumulado" is net
movement since the register opened (no opening balance in the source), not a
bank balance. Verified end-to-end in the browser; totals reconcile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { CustomersModule } from "./customers/customers.module";
|
||||
import { PoliciesModule } from "./policies/policies.module";
|
||||
import { PropertiesModule } from "./properties/properties.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
import { BankModule } from "./bank/bank.module";
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@Module({
|
||||
@@ -19,6 +20,7 @@ import { AppController } from "./app.controller";
|
||||
PoliciesModule,
|
||||
PropertiesModule,
|
||||
BillingModule,
|
||||
BankModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import {
|
||||
BankCleared,
|
||||
BankDirection,
|
||||
BankService,
|
||||
BankSort,
|
||||
} from "./bank.service";
|
||||
|
||||
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||
const SORTS: BankSort[] = [
|
||||
"date_desc",
|
||||
"date_asc",
|
||||
"amount_desc",
|
||||
"amount_asc",
|
||||
"reference",
|
||||
];
|
||||
|
||||
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
||||
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||
}
|
||||
|
||||
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||
if (!v) return undefined;
|
||||
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@Controller("bank")
|
||||
export class BankController {
|
||||
constructor(private readonly bank: BankService) {}
|
||||
|
||||
@Get("stats")
|
||||
stats() {
|
||||
return this.bank.stats();
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.bank.facets();
|
||||
}
|
||||
|
||||
/** Year and month rollups with a running net-movement figure. */
|
||||
@Get("summary")
|
||||
summary(@Query("year") year?: string) {
|
||||
const y = Number(year);
|
||||
return this.bank.summary(
|
||||
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** The register browser. */
|
||||
@Get()
|
||||
list(
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@Query("direction") direction?: string,
|
||||
@Query("cleared") cleared?: string,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.bank.list({
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
direction: one(DIRECTIONS, direction),
|
||||
cleared: one(CLEARED, cleared),
|
||||
from: parseDate(from),
|
||||
to: parseDate(to, true),
|
||||
sort: one(SORTS, sort) ?? "date_desc",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BankController } from "./bank.controller";
|
||||
import { BankService } from "./bank.service";
|
||||
|
||||
@Module({
|
||||
controllers: [BankController],
|
||||
providers: [BankService],
|
||||
})
|
||||
export class BankModule {}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// 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,
|
||||
})),
|
||||
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 } }] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.bankTransaction.aggregate({
|
||||
where: { AND: [where, { amount: { lt: 0 } }] },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [where, { amount: 0 }] },
|
||||
}),
|
||||
]);
|
||||
|
||||
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(),
|
||||
this.prisma.bankTransaction.aggregate({
|
||||
_min: { transactionDate: true },
|
||||
_max: { transactionDate: true },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({ where: { cleared: false } }),
|
||||
this.prisma.bankTransaction.count({ where: { transferred: true } }),
|
||||
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
|
||||
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
|
||||
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}
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function directionOf(amount: Prisma.Decimal): BankDirection {
|
||||
if (amount.greaterThan(0)) return "income";
|
||||
return amount.lessThan(0) ? "expense" : "void";
|
||||
}
|
||||
Reference in New Issue
Block a user