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,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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user