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>
121 lines
3.2 KiB
TypeScript
121 lines
3.2 KiB
TypeScript
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"];
|
|
const SORTS: BankSort[] = [
|
|
"date_desc",
|
|
"date_asc",
|
|
"amount_desc",
|
|
"amount_asc",
|
|
"reference",
|
|
];
|
|
|
|
function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
|
return allowed.includes(value as T) ? (value as T) : undefined;
|
|
}
|
|
|
|
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
|
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
|
if (!v) return undefined;
|
|
const d = new Date(endOfDay ? `${v}T23:59:59.999Z` : `${v}T00:00:00.000Z`);
|
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
}
|
|
|
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
|
@Controller("bank")
|
|
export class BankController {
|
|
constructor(
|
|
private readonly bank: BankService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
private actingId(req: Request): string {
|
|
return (req.user as { id: string }).id;
|
|
}
|
|
|
|
@Get("stats")
|
|
stats() {
|
|
return this.bank.stats();
|
|
}
|
|
|
|
@Get("facets")
|
|
facets() {
|
|
return this.bank.facets();
|
|
}
|
|
|
|
/** Year and month rollups with a running net-movement figure. */
|
|
@Get("summary")
|
|
summary(@Query("year") year?: string) {
|
|
const y = Number(year);
|
|
return this.bank.summary(
|
|
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
|
);
|
|
}
|
|
|
|
/** The register browser. */
|
|
@Get()
|
|
list(
|
|
@Query("query") query?: string,
|
|
@Query("page") page?: string,
|
|
@Query("pageSize") pageSize?: string,
|
|
@Query("direction") direction?: string,
|
|
@Query("cleared") cleared?: string,
|
|
@Query("from") from?: string,
|
|
@Query("to") to?: string,
|
|
@Query("sort") sort?: string,
|
|
) {
|
|
return this.bank.list({
|
|
query,
|
|
page: Math.max(1, Number(page) || 1),
|
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
|
direction: one(DIRECTIONS, direction),
|
|
cleared: one(CLEARED, cleared),
|
|
from: parseDate(from),
|
|
to: parseDate(to, true),
|
|
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;
|
|
}
|
|
}
|