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
+81 -14
View File
@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateMovementDto } from "./movement.dto";
/**
* Shared billing / statements module — plan step 6.
@@ -104,6 +105,13 @@ function dec(v: Prisma.Decimal | null | undefined): string {
return (v ?? new Prisma.Decimal(0)).toFixed(2);
}
/**
* Every aggregate (SUM/count/groupBy and the raw balance SQL) must exclude
* voided rows, or a reversed movement keeps affecting the books. List views
* still show voided rows struck-through — only totals drop them.
*/
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
@@ -187,6 +195,7 @@ export class BillingService {
checkNumber: true,
message: true,
legacySourceTable: true,
voidedAt: true,
type: { select: { nameEn: true, nameEs: true } },
customer: {
select: { id: true, name: true, nameSource: true, city: true },
@@ -200,19 +209,19 @@ export class BillingService {
// cover everything the filter matched.
const totals = await this.prisma.transaction.groupBy({
by: ["currency"],
where,
where: { AND: [where, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
const charges = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { AND: [where, { amount: { lt: 0 } }] },
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
const credits = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { AND: [where, { amount: { gt: 0 } }] },
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
@@ -233,6 +242,7 @@ export class BillingService {
message: r.message,
source: r.legacySourceTable,
type: r.type,
voided: r.voidedAt != null,
customerId: r.customer.id,
customerName: r.customer.name,
customerNameSource: r.customer.nameSource,
@@ -326,7 +336,7 @@ export class BillingService {
MAX(t.transactionDate) AS lastMovement
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE 1 = 1 ${nameFilter} ${txFilter}
WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter}
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
${having}
${orderBy}
@@ -382,17 +392,23 @@ export class BillingService {
/** Top-line figures for the billing page header. */
async stats() {
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
this.prisma.transaction.count(),
this.prisma.transaction.count({ where: NOT_VOIDED }),
this.prisma.transaction
.findMany({ distinct: ["customerId"], select: { customerId: true } })
.findMany({
where: NOT_VOIDED,
distinct: ["customerId"],
select: { customerId: true },
})
.then((r) => r.length),
this.prisma.transaction.groupBy({
by: ["currency"],
where: NOT_VOIDED,
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.transaction.groupBy({
by: ["domain", "currency"],
where: NOT_VOIDED,
_sum: { amount: true },
_count: { _all: true },
}),
@@ -400,13 +416,13 @@ export class BillingService {
const charges = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { amount: { lt: 0 } },
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
const credits = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { amount: { gt: 0 } },
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
@@ -428,7 +444,7 @@ export class BillingService {
SUM(bal > 0.005) AS inCredit
FROM (
SELECT customerId, currency, SUM(amount) AS bal
FROM transactions GROUP BY customerId, currency
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
) x
GROUP BY currency
`;
@@ -436,10 +452,12 @@ export class BillingService {
const [firstRow, lastRow] = await Promise.all([
this.prisma.transaction.findFirst({
where: NOT_VOIDED,
orderBy: { transactionDate: "asc" },
select: { transactionDate: true },
}),
this.prisma.transaction.findFirst({
where: NOT_VOIDED,
orderBy: { transactionDate: "desc" },
select: { transactionDate: true },
}),
@@ -449,7 +467,7 @@ export class BillingService {
// module is one view instead of two.
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
SELECT COUNT(*) AS n FROM (
SELECT customerId FROM transactions
SELECT customerId FROM transactions WHERE voidedAt IS NULL
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
) x
`;
@@ -484,7 +502,7 @@ export class BillingService {
async facets() {
const types = await this.prisma.transaction.groupBy({
by: ["typeId"],
where: { typeId: { not: null } },
where: { AND: [{ typeId: { not: null } }, NOT_VOIDED] },
_count: { _all: true },
orderBy: { _count: { typeId: "desc" } },
});
@@ -496,6 +514,7 @@ export class BillingService {
const sources = await this.prisma.transaction.groupBy({
by: ["legacySourceTable"],
where: NOT_VOIDED,
_count: { _all: true },
orderBy: { _count: { legacySourceTable: "desc" } },
});
@@ -504,7 +523,7 @@ export class BillingService {
{ year: number; count: bigint | number | string }[]
>`
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
FROM transactions GROUP BY year ORDER BY year DESC
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
`;
return {
@@ -573,14 +592,18 @@ export class BillingService {
checkNumber: true,
message: true,
legacySourceTable: true,
voidedAt: true,
type: { select: { nameEn: true, nameEs: true } },
},
});
const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => {
const voided = r.voidedAt != null;
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
const next = prev.plus(r.amount);
// A voided row does not move the running balance — it shows struck-through
// with the balance unchanged from the previous live movement.
const next = voided ? prev : prev.plus(r.amount);
running.set(r.currency, next);
return {
id: r.id,
@@ -595,6 +618,7 @@ export class BillingService {
message: r.message,
source: r.legacySourceTable,
type: r.type,
voided,
/** Balance in this row's currency after applying it. */
balanceAfter: next.toFixed(2),
};
@@ -628,6 +652,7 @@ export class BillingService {
>();
for (const r of rows) {
if (r.voidedAt != null) continue; // voided rows never enter a total
const c =
perCurrency.get(r.currency) ??
{
@@ -675,6 +700,7 @@ export class BillingService {
{ name: string; currency: string; total: Prisma.Decimal; count: number }
>();
for (const r of rows) {
if (r.voidedAt != null) continue;
if (!r.amount.lessThan(0)) continue;
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
const key = `${name}|${r.currency}`;
@@ -722,4 +748,45 @@ export class BillingService {
movements,
};
}
// --- writes (append + void; never edit or delete a booked row) ------------
async createMovement(dto: CreateMovementDto) {
const customer = await this.prisma.customer.findUnique({
where: { id: dto.customerId },
select: { id: true },
});
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
const date = new Date(dto.transactionDate);
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
return this.prisma.transaction.create({
data: {
customerId: dto.customerId,
domain: dto.domain,
amount: dto.amount,
transactionDate: date,
currency: dto.currency,
typeId: dto.typeId,
period: dto.period,
reference: dto.reference,
checkNumber: dto.checkNumber,
message: dto.message,
},
});
}
/** Reverse a movement by marking it voided; it stops counting toward totals. */
async voidMovement(id: string, userId: string) {
const tx = await this.prisma.transaction.findUnique({
where: { id },
select: { id: true, voidedAt: true },
});
if (!tx) throw new NotFoundException(`Transaction ${id} not found`);
if (tx.voidedAt) throw new BadRequestException("El movimiento ya está anulado");
return this.prisma.transaction.update({
where: { id },
data: { voidedAt: new Date(), voidedById: userId },
});
}
}