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>
This commit is contained in:
2026-07-23 12:34:47 -07:00
co-authored by Claude Opus 4.8
parent 506f8ce684
commit 548eeb5798
9 changed files with 289 additions and 30 deletions
+19
View File
@@ -0,0 +1,19 @@
import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-validator";
/**
* A new bank-register movement. `amount` is signed: positive = ingreso,
* negative = egreso (the module's sign convention). Single currency (MXN).
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
*/
export class CreateBankMovementDto {
@IsNumber() amount!: number;
@IsString() @MinLength(1) transactionDate!: string;
@IsOptional() @IsString() concept?: string;
@IsOptional() @IsString() reference?: string;
@IsOptional() @IsString() transactionType?: string;
@IsOptional() @IsBoolean() cleared?: boolean;
@IsOptional() @IsBoolean() transferred?: boolean;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsString() amountInWords?: string;
}
+45 -3
View File
@@ -1,11 +1,25 @@
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import {
BankCleared,
BankDirection,
BankService,
BankSort,
} from "./bank.service";
import { CreateBankMovementDto } from "./bank-movement.dto";
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
const CLEARED: BankCleared[] = ["cleared", "pending"];
@@ -28,10 +42,17 @@ function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
return Number.isNaN(d.getTime()) ? undefined : d;
}
@UseGuards(AuthenticatedGuard)
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("bank")
export class BankController {
constructor(private readonly bank: BankService) {}
constructor(
private readonly bank: BankService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("stats")
stats() {
@@ -75,4 +96,25 @@ export class BankController {
sort: one(SORTS, sort) ?? "date_desc",
});
}
// --- writes ---------------------------------------------------------------
@Post()
@RequireAbility("bank:create")
async create(@Body() dto: CreateBankMovementDto, @Req() req: Request) {
const row = await this.bank.createMovement(dto);
void this.audit.log(this.actingId(req), "bank.create", {
bankTransactionId: row.id,
amount: dto.amount,
});
return row;
}
@Post(":id/void")
@RequireAbility("bank:void")
async void(@Param("id") id: string, @Req() req: Request) {
const row = await this.bank.voidMovement(id, this.actingId(req));
void this.audit.log(this.actingId(req), "bank.void", { bankTransactionId: id });
return row;
}
}
+59 -8
View File
@@ -1,6 +1,15 @@
import { Injectable } from "@nestjs/common";
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.
@@ -160,6 +169,7 @@ export class BankService {
notes: true,
amountInWords: true,
legacySourceTable: true,
voidedAt: true,
},
}),
]);
@@ -182,6 +192,7 @@ export class BankService {
notes: r.notes,
amountInWords: r.amountInWords,
source: r.legacySourceTable,
voided: r.voidedAt != null,
})),
total,
page: params.page,
@@ -195,17 +206,17 @@ export class BankService {
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
const [income, expense, voided] = await Promise.all([
this.prisma.bankTransaction.aggregate({
where: { AND: [where, { amount: { gt: 0 } }] },
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.bankTransaction.aggregate({
where: { AND: [where, { amount: { lt: 0 } }] },
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.bankTransaction.count({
where: { AND: [where, { amount: 0 }] },
where: { AND: [where, { amount: 0 }, NOT_VOIDED] },
}),
]);
@@ -225,13 +236,18 @@ export class BankService {
/** 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.count({ where: NOT_VOIDED }),
this.prisma.bankTransaction.aggregate({
where: NOT_VOIDED,
_min: { transactionDate: true },
_max: { transactionDate: true },
}),
this.prisma.bankTransaction.count({ where: { cleared: false } }),
this.prisma.bankTransaction.count({ where: { transferred: true } }),
this.prisma.bankTransaction.count({
where: { AND: [{ cleared: false }, NOT_VOIDED] },
}),
this.prisma.bankTransaction.count({
where: { AND: [{ transferred: true }, NOT_VOIDED] },
}),
this.totalsFor({}),
]);
@@ -252,6 +268,7 @@ export class BankService {
>`
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
FROM bank_transactions
WHERE voidedAt IS NULL
GROUP BY year
ORDER BY year DESC
`;
@@ -280,6 +297,7 @@ export class BankService {
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
`;
@@ -293,7 +311,7 @@ export class BankService {
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net
FROM bank_transactions
WHERE YEAR(transactionDate) = ${year}
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL
GROUP BY period
ORDER BY period ASC
`
@@ -346,6 +364,39 @@ export class BankService {
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 {