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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user