From 548eeb5798f1f954d45511eb6b9cac1b1a422eca Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 23 Jul 2026 12:34:47 -0700 Subject: [PATCH] feat(ledger,bank): append + void write API, voided excluded from totals (plan phase 5 API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/src/bank/bank-movement.dto.ts | 19 ++++ apps/api/src/bank/bank.controller.ts | 48 +++++++++- apps/api/src/bank/bank.service.ts | 67 +++++++++++-- apps/api/src/billing/billing.controller.ts | 50 +++++++++- apps/api/src/billing/billing.service.ts | 95 ++++++++++++++++--- apps/api/src/billing/movement.dto.ts | 27 ++++++ apps/api/src/customers/customers.service.ts | 3 +- apps/api/src/properties/properties.service.ts | 2 +- packages/database/prisma/schema.prisma | 8 ++ 9 files changed, 289 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/bank/bank-movement.dto.ts create mode 100644 apps/api/src/billing/movement.dto.ts diff --git a/apps/api/src/bank/bank-movement.dto.ts b/apps/api/src/bank/bank-movement.dto.ts new file mode 100644 index 0000000..b5d01ba --- /dev/null +++ b/apps/api/src/bank/bank-movement.dto.ts @@ -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; +} diff --git a/apps/api/src/bank/bank.controller.ts b/apps/api/src/bank/bank.controller.ts index eb030a7..65f3b7f 100644 --- a/apps/api/src/bank/bank.controller.ts +++ b/apps/api/src/bank/bank.controller.ts @@ -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; + } } diff --git a/apps/api/src/bank/bank.service.ts b/apps/api/src/bank/bank.service.ts index 0dafccc..8b3df10 100644 --- a/apps/api/src/bank/bank.service.ts +++ b/apps/api/src/bank/bank.service.ts @@ -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 { diff --git a/apps/api/src/billing/billing.controller.ts b/apps/api/src/billing/billing.controller.ts index 52d8529..93219ee 100644 --- a/apps/api/src/billing/billing.controller.ts +++ b/apps/api/src/billing/billing.controller.ts @@ -1,6 +1,19 @@ -import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; import { TransactionDomain } from "@jorgecuadros/database"; +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 { BalanceFilter, BalanceSort, @@ -9,6 +22,7 @@ import { LedgerDirection, MovementSort, } from "./billing.service"; +import { CreateMovementDto } from "./movement.dto"; const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"]; const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"]; @@ -39,10 +53,17 @@ function parseDate(v: string | undefined, endOfDay = false): Date | undefined { return Number.isNaN(d.getTime()) ? undefined : d; } -@UseGuards(AuthenticatedGuard) +@UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("billing") export class BillingController { - constructor(private readonly billing: BillingService) {} + constructor( + private readonly billing: BillingService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } @Get("stats") stats() { @@ -113,4 +134,27 @@ export class BillingController { sort: one(MOVEMENT_SORTS, sort) ?? "date_desc", }); } + + // --- writes --------------------------------------------------------------- + + @Post() + @RequireAbility("ledger:create") + async create(@Body() dto: CreateMovementDto, @Req() req: Request) { + const tx = await this.billing.createMovement(dto); + void this.audit.log(this.actingId(req), "ledger.create", { + transactionId: tx.id, + customerId: dto.customerId, + amount: dto.amount, + currency: tx.currency, + }); + return tx; + } + + @Post(":id/void") + @RequireAbility("ledger:void") + async void(@Param("id") id: string, @Req() req: Request) { + const tx = await this.billing.voidMovement(id, this.actingId(req)); + void this.audit.log(this.actingId(req), "ledger.void", { transactionId: id }); + return tx; + } } diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index ad3422c..b4381a5 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -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(); 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 }, + }); + } } diff --git a/apps/api/src/billing/movement.dto.ts b/apps/api/src/billing/movement.dto.ts new file mode 100644 index 0000000..aadb104 --- /dev/null +++ b/apps/api/src/billing/movement.dto.ts @@ -0,0 +1,27 @@ +import { + IsEnum, + IsNumber, + IsOptional, + IsString, + MinLength, +} from "class-validator"; +import { Currency, TransactionDomain } from "@jorgecuadros/database"; + +/** + * A new ledger movement. `amount` is signed: negative = cargo (charge), + * positive = abono (credit) — the module's sign convention. Booked movements + * are never edited; a mistake is corrected by voiding and re-capturing. + */ +export class CreateMovementDto { + @IsString() @MinLength(1) customerId!: string; + @IsEnum(TransactionDomain) domain!: TransactionDomain; + @IsNumber() amount!: number; + @IsString() @MinLength(1) transactionDate!: string; + + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() typeId?: string; + @IsOptional() @IsString() period?: string; + @IsOptional() @IsString() reference?: string; + @IsOptional() @IsString() checkNumber?: string; + @IsOptional() @IsString() message?: string; +} diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts index 63fa63c..b523737 100644 --- a/apps/api/src/customers/customers.service.ts +++ b/apps/api/src/customers/customers.service.ts @@ -132,7 +132,8 @@ export class CustomersService { // business lines" payoff), computed in the DB rather than in JS. const summary = await this.prisma.transaction.groupBy({ by: ["domain", "currency"], - where: { customerId: id }, + // Exclude voided rows so the per-domain balance matches the statement. + where: { customerId: id, voidedAt: null }, _sum: { amount: true }, _count: { _all: true }, }); diff --git a/apps/api/src/properties/properties.service.ts b/apps/api/src/properties/properties.service.ts index 31c6326..1430ebb 100644 --- a/apps/api/src/properties/properties.service.ts +++ b/apps/api/src/properties/properties.service.ts @@ -425,7 +425,7 @@ export class PropertiesService { }); const ledger = await this.prisma.transaction.groupBy({ by: ["currency"], - where: { customerId: property.customerId, domain: "UTILITY" }, + where: { customerId: property.customerId, domain: "UTILITY", voidedAt: null }, _sum: { amount: true }, _count: { _all: true }, }); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 1f21c3b..7dc6f6e 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -409,6 +409,11 @@ model Transaction { checkNumber String? message String? @db.Text outstanding Boolean @default(false) + // Append + void: booked rows are never edited or hard-deleted. A non-null + // voidedAt reverses the movement — it MUST be excluded from every balance + // and total (SUM/count) so a voided amount stops affecting the books. + voidedAt DateTime? + voidedById String? legacySourceDb String? legacySourceTable String? legacyId String? @@ -457,6 +462,9 @@ model BankTransaction { transferred Boolean @default(false) notes String? @db.Text amountInWords String? + // Append + void (see Transaction.voidedAt): excluded from income/expense/net. + voidedAt DateTime? + voidedById String? legacySourceTable String? legacyId String?