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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { 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 {
|
import {
|
||||||
BankCleared,
|
BankCleared,
|
||||||
BankDirection,
|
BankDirection,
|
||||||
BankService,
|
BankService,
|
||||||
BankSort,
|
BankSort,
|
||||||
} from "./bank.service";
|
} from "./bank.service";
|
||||||
|
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||||
|
|
||||||
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||||
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
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;
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("bank")
|
@Controller("bank")
|
||||||
export class BankController {
|
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")
|
@Get("stats")
|
||||||
stats() {
|
stats() {
|
||||||
@@ -75,4 +96,25 @@ export class BankController {
|
|||||||
sort: one(SORTS, sort) ?? "date_desc",
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
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.
|
* Bank register (chequera) module — plan step 7.
|
||||||
@@ -160,6 +169,7 @@ export class BankService {
|
|||||||
notes: true,
|
notes: true,
|
||||||
amountInWords: true,
|
amountInWords: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
voidedAt: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -182,6 +192,7 @@ export class BankService {
|
|||||||
notes: r.notes,
|
notes: r.notes,
|
||||||
amountInWords: r.amountInWords,
|
amountInWords: r.amountInWords,
|
||||||
source: r.legacySourceTable,
|
source: r.legacySourceTable,
|
||||||
|
voided: r.voidedAt != null,
|
||||||
})),
|
})),
|
||||||
total,
|
total,
|
||||||
page: params.page,
|
page: params.page,
|
||||||
@@ -195,17 +206,17 @@ export class BankService {
|
|||||||
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
|
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
|
||||||
const [income, expense, voided] = await Promise.all([
|
const [income, expense, voided] = await Promise.all([
|
||||||
this.prisma.bankTransaction.aggregate({
|
this.prisma.bankTransaction.aggregate({
|
||||||
where: { AND: [where, { amount: { gt: 0 } }] },
|
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.aggregate({
|
this.prisma.bankTransaction.aggregate({
|
||||||
where: { AND: [where, { amount: { lt: 0 } }] },
|
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.count({
|
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. */
|
/** Top-line figures for the bank page header. */
|
||||||
async stats() {
|
async stats() {
|
||||||
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
||||||
this.prisma.bankTransaction.count(),
|
this.prisma.bankTransaction.count({ where: NOT_VOIDED }),
|
||||||
this.prisma.bankTransaction.aggregate({
|
this.prisma.bankTransaction.aggregate({
|
||||||
|
where: NOT_VOIDED,
|
||||||
_min: { transactionDate: true },
|
_min: { transactionDate: true },
|
||||||
_max: { transactionDate: true },
|
_max: { transactionDate: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.count({ where: { cleared: false } }),
|
this.prisma.bankTransaction.count({
|
||||||
this.prisma.bankTransaction.count({ where: { transferred: true } }),
|
where: { AND: [{ cleared: false }, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
|
this.prisma.bankTransaction.count({
|
||||||
|
where: { AND: [{ transferred: true }, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
this.totalsFor({}),
|
this.totalsFor({}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -252,6 +268,7 @@ export class BankService {
|
|||||||
>`
|
>`
|
||||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
|
WHERE voidedAt IS NULL
|
||||||
GROUP BY year
|
GROUP BY year
|
||||||
ORDER BY year DESC
|
ORDER BY year DESC
|
||||||
`;
|
`;
|
||||||
@@ -280,6 +297,7 @@ export class BankService {
|
|||||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
SUM(amount) AS net
|
SUM(amount) AS net
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
|
WHERE voidedAt IS NULL
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
ORDER BY period ASC
|
ORDER BY period ASC
|
||||||
`;
|
`;
|
||||||
@@ -293,7 +311,7 @@ export class BankService {
|
|||||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
SUM(amount) AS net
|
SUM(amount) AS net
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
WHERE YEAR(transactionDate) = ${year}
|
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
ORDER BY period ASC
|
ORDER BY period ASC
|
||||||
`
|
`
|
||||||
@@ -346,6 +364,39 @@ export class BankService {
|
|||||||
opening: opening.toFixed(2),
|
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 {
|
function directionOf(amount: Prisma.Decimal): BankDirection {
|
||||||
|
|||||||
@@ -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 { TransactionDomain } from "@jorgecuadros/database";
|
||||||
|
import { Request } from "express";
|
||||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
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 {
|
import {
|
||||||
BalanceFilter,
|
BalanceFilter,
|
||||||
BalanceSort,
|
BalanceSort,
|
||||||
@@ -9,6 +22,7 @@ import {
|
|||||||
LedgerDirection,
|
LedgerDirection,
|
||||||
MovementSort,
|
MovementSort,
|
||||||
} from "./billing.service";
|
} from "./billing.service";
|
||||||
|
import { CreateMovementDto } from "./movement.dto";
|
||||||
|
|
||||||
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||||
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
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;
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
export class BillingController {
|
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")
|
@Get("stats")
|
||||||
stats() {
|
stats() {
|
||||||
@@ -113,4 +134,27 @@ export class BillingController {
|
|||||||
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
|
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateMovementDto } from "./movement.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared billing / statements module — plan step 6.
|
* 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);
|
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()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -187,6 +195,7 @@ export class BillingService {
|
|||||||
checkNumber: true,
|
checkNumber: true,
|
||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
voidedAt: true,
|
||||||
type: { select: { nameEn: true, nameEs: true } },
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
customer: {
|
customer: {
|
||||||
select: { id: true, name: true, nameSource: true, city: true },
|
select: { id: true, name: true, nameSource: true, city: true },
|
||||||
@@ -200,19 +209,19 @@ export class BillingService {
|
|||||||
// cover everything the filter matched.
|
// cover everything the filter matched.
|
||||||
const totals = await this.prisma.transaction.groupBy({
|
const totals = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where,
|
where: { AND: [where, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
const charges = await this.prisma.transaction.groupBy({
|
const charges = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { AND: [where, { amount: { lt: 0 } }] },
|
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
const credits = await this.prisma.transaction.groupBy({
|
const credits = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { AND: [where, { amount: { gt: 0 } }] },
|
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
@@ -233,6 +242,7 @@ export class BillingService {
|
|||||||
message: r.message,
|
message: r.message,
|
||||||
source: r.legacySourceTable,
|
source: r.legacySourceTable,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
|
voided: r.voidedAt != null,
|
||||||
customerId: r.customer.id,
|
customerId: r.customer.id,
|
||||||
customerName: r.customer.name,
|
customerName: r.customer.name,
|
||||||
customerNameSource: r.customer.nameSource,
|
customerNameSource: r.customer.nameSource,
|
||||||
@@ -326,7 +336,7 @@ export class BillingService {
|
|||||||
MAX(t.transactionDate) AS lastMovement
|
MAX(t.transactionDate) AS lastMovement
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
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
|
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||||
${having}
|
${having}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
@@ -382,17 +392,23 @@ export class BillingService {
|
|||||||
/** Top-line figures for the billing page header. */
|
/** Top-line figures for the billing page header. */
|
||||||
async stats() {
|
async stats() {
|
||||||
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
||||||
this.prisma.transaction.count(),
|
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||||
this.prisma.transaction
|
this.prisma.transaction
|
||||||
.findMany({ distinct: ["customerId"], select: { customerId: true } })
|
.findMany({
|
||||||
|
where: NOT_VOIDED,
|
||||||
|
distinct: ["customerId"],
|
||||||
|
select: { customerId: true },
|
||||||
|
})
|
||||||
.then((r) => r.length),
|
.then((r) => r.length),
|
||||||
this.prisma.transaction.groupBy({
|
this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
|
where: NOT_VOIDED,
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.transaction.groupBy({
|
this.prisma.transaction.groupBy({
|
||||||
by: ["domain", "currency"],
|
by: ["domain", "currency"],
|
||||||
|
where: NOT_VOIDED,
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
@@ -400,13 +416,13 @@ export class BillingService {
|
|||||||
|
|
||||||
const charges = await this.prisma.transaction.groupBy({
|
const charges = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { amount: { lt: 0 } },
|
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
const credits = await this.prisma.transaction.groupBy({
|
const credits = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { amount: { gt: 0 } },
|
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
@@ -428,7 +444,7 @@ export class BillingService {
|
|||||||
SUM(bal > 0.005) AS inCredit
|
SUM(bal > 0.005) AS inCredit
|
||||||
FROM (
|
FROM (
|
||||||
SELECT customerId, currency, SUM(amount) AS bal
|
SELECT customerId, currency, SUM(amount) AS bal
|
||||||
FROM transactions GROUP BY customerId, currency
|
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
|
||||||
) x
|
) x
|
||||||
GROUP BY currency
|
GROUP BY currency
|
||||||
`;
|
`;
|
||||||
@@ -436,10 +452,12 @@ export class BillingService {
|
|||||||
|
|
||||||
const [firstRow, lastRow] = await Promise.all([
|
const [firstRow, lastRow] = await Promise.all([
|
||||||
this.prisma.transaction.findFirst({
|
this.prisma.transaction.findFirst({
|
||||||
|
where: NOT_VOIDED,
|
||||||
orderBy: { transactionDate: "asc" },
|
orderBy: { transactionDate: "asc" },
|
||||||
select: { transactionDate: true },
|
select: { transactionDate: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.transaction.findFirst({
|
this.prisma.transaction.findFirst({
|
||||||
|
where: NOT_VOIDED,
|
||||||
orderBy: { transactionDate: "desc" },
|
orderBy: { transactionDate: "desc" },
|
||||||
select: { transactionDate: true },
|
select: { transactionDate: true },
|
||||||
}),
|
}),
|
||||||
@@ -449,7 +467,7 @@ export class BillingService {
|
|||||||
// module is one view instead of two.
|
// module is one view instead of two.
|
||||||
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
||||||
SELECT COUNT(*) AS n FROM (
|
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
|
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
||||||
) x
|
) x
|
||||||
`;
|
`;
|
||||||
@@ -484,7 +502,7 @@ export class BillingService {
|
|||||||
async facets() {
|
async facets() {
|
||||||
const types = await this.prisma.transaction.groupBy({
|
const types = await this.prisma.transaction.groupBy({
|
||||||
by: ["typeId"],
|
by: ["typeId"],
|
||||||
where: { typeId: { not: null } },
|
where: { AND: [{ typeId: { not: null } }, NOT_VOIDED] },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
orderBy: { _count: { typeId: "desc" } },
|
orderBy: { _count: { typeId: "desc" } },
|
||||||
});
|
});
|
||||||
@@ -496,6 +514,7 @@ export class BillingService {
|
|||||||
|
|
||||||
const sources = await this.prisma.transaction.groupBy({
|
const sources = await this.prisma.transaction.groupBy({
|
||||||
by: ["legacySourceTable"],
|
by: ["legacySourceTable"],
|
||||||
|
where: NOT_VOIDED,
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
orderBy: { _count: { legacySourceTable: "desc" } },
|
orderBy: { _count: { legacySourceTable: "desc" } },
|
||||||
});
|
});
|
||||||
@@ -504,7 +523,7 @@ export class BillingService {
|
|||||||
{ year: number; count: bigint | number | string }[]
|
{ year: number; count: bigint | number | string }[]
|
||||||
>`
|
>`
|
||||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
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 {
|
return {
|
||||||
@@ -573,14 +592,18 @@ export class BillingService {
|
|||||||
checkNumber: true,
|
checkNumber: true,
|
||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
voidedAt: true,
|
||||||
type: { select: { nameEn: true, nameEs: true } },
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
const movements = rows.map((r) => {
|
const movements = rows.map((r) => {
|
||||||
|
const voided = r.voidedAt != null;
|
||||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
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);
|
running.set(r.currency, next);
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -595,6 +618,7 @@ export class BillingService {
|
|||||||
message: r.message,
|
message: r.message,
|
||||||
source: r.legacySourceTable,
|
source: r.legacySourceTable,
|
||||||
type: r.type,
|
type: r.type,
|
||||||
|
voided,
|
||||||
/** Balance in this row's currency after applying it. */
|
/** Balance in this row's currency after applying it. */
|
||||||
balanceAfter: next.toFixed(2),
|
balanceAfter: next.toFixed(2),
|
||||||
};
|
};
|
||||||
@@ -628,6 +652,7 @@ export class BillingService {
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
if (r.voidedAt != null) continue; // voided rows never enter a total
|
||||||
const c =
|
const c =
|
||||||
perCurrency.get(r.currency) ??
|
perCurrency.get(r.currency) ??
|
||||||
{
|
{
|
||||||
@@ -675,6 +700,7 @@ export class BillingService {
|
|||||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||||
>();
|
>();
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
if (r.voidedAt != null) continue;
|
||||||
if (!r.amount.lessThan(0)) continue;
|
if (!r.amount.lessThan(0)) continue;
|
||||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||||
const key = `${name}|${r.currency}`;
|
const key = `${name}|${r.currency}`;
|
||||||
@@ -722,4 +748,45 @@ export class BillingService {
|
|||||||
movements,
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -132,7 +132,8 @@ export class CustomersService {
|
|||||||
// business lines" payoff), computed in the DB rather than in JS.
|
// business lines" payoff), computed in the DB rather than in JS.
|
||||||
const summary = await this.prisma.transaction.groupBy({
|
const summary = await this.prisma.transaction.groupBy({
|
||||||
by: ["domain", "currency"],
|
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 },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ export class PropertiesService {
|
|||||||
});
|
});
|
||||||
const ledger = await this.prisma.transaction.groupBy({
|
const ledger = await this.prisma.transaction.groupBy({
|
||||||
by: ["currency"],
|
by: ["currency"],
|
||||||
where: { customerId: property.customerId, domain: "UTILITY" },
|
where: { customerId: property.customerId, domain: "UTILITY", voidedAt: null },
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -409,6 +409,11 @@ model Transaction {
|
|||||||
checkNumber String?
|
checkNumber String?
|
||||||
message String? @db.Text
|
message String? @db.Text
|
||||||
outstanding Boolean @default(false)
|
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?
|
legacySourceDb String?
|
||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
@@ -457,6 +462,9 @@ model BankTransaction {
|
|||||||
transferred Boolean @default(false)
|
transferred Boolean @default(false)
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
amountInWords String?
|
amountInWords String?
|
||||||
|
// Append + void (see Transaction.voidedAt): excluded from income/expense/net.
|
||||||
|
voidedAt DateTime?
|
||||||
|
voidedById String?
|
||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user