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>
161 lines
4.6 KiB
TypeScript
161 lines
4.6 KiB
TypeScript
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,
|
|
BillingService,
|
|
LedgerCurrency,
|
|
LedgerDirection,
|
|
MovementSort,
|
|
} from "./billing.service";
|
|
import { CreateMovementDto } from "./movement.dto";
|
|
|
|
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
|
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
|
const DIRECTIONS: LedgerDirection[] = ["charge", "credit"];
|
|
const BALANCES: BalanceFilter[] = ["all", "owing", "credit", "settled"];
|
|
const MOVEMENT_SORTS: MovementSort[] = [
|
|
"date_desc",
|
|
"date_asc",
|
|
"amount_desc",
|
|
"amount_asc",
|
|
"customer",
|
|
];
|
|
const BALANCE_SORTS: BalanceSort[] = [
|
|
"owing_desc",
|
|
"credit_desc",
|
|
"recent",
|
|
"customer",
|
|
];
|
|
|
|
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("billing")
|
|
export class BillingController {
|
|
constructor(
|
|
private readonly billing: BillingService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
private actingId(req: Request): string {
|
|
return (req.user as { id: string }).id;
|
|
}
|
|
|
|
@Get("stats")
|
|
stats() {
|
|
return this.billing.stats();
|
|
}
|
|
|
|
@Get("facets")
|
|
facets() {
|
|
return this.billing.facets();
|
|
}
|
|
|
|
/** Per-customer balances — the receivables worklist. */
|
|
@Get("balances")
|
|
balances(
|
|
@Query("query") query?: string,
|
|
@Query("page") page?: string,
|
|
@Query("pageSize") pageSize?: string,
|
|
@Query("currency") currency?: string,
|
|
@Query("balance") balance?: string,
|
|
@Query("domain") domain?: string,
|
|
@Query("sort") sort?: string,
|
|
) {
|
|
return this.billing.balances({
|
|
query,
|
|
page: Math.max(1, Number(page) || 1),
|
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
|
currency: one(CURRENCIES, currency) ?? "MXN",
|
|
balance: one(BALANCES, balance) ?? "all",
|
|
domain: one(DOMAINS, domain),
|
|
sort: one(BALANCE_SORTS, sort) ?? "owing_desc",
|
|
});
|
|
}
|
|
|
|
/** One customer's full statement across both business lines. */
|
|
@Get("customers/:id")
|
|
statement(@Param("id") id: string) {
|
|
return this.billing.statement(id);
|
|
}
|
|
|
|
/** Cross-customer movement browser. */
|
|
@Get()
|
|
movements(
|
|
@Query("query") query?: string,
|
|
@Query("page") page?: string,
|
|
@Query("pageSize") pageSize?: string,
|
|
@Query("domain") domain?: string,
|
|
@Query("currency") currency?: string,
|
|
@Query("direction") direction?: string,
|
|
@Query("typeId") typeId?: string,
|
|
@Query("source") source?: string,
|
|
@Query("customerId") customerId?: string,
|
|
@Query("from") from?: string,
|
|
@Query("to") to?: string,
|
|
@Query("sort") sort?: string,
|
|
) {
|
|
return this.billing.movements({
|
|
query,
|
|
page: Math.max(1, Number(page) || 1),
|
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
|
domain: one(DOMAINS, domain),
|
|
currency: one(CURRENCIES, currency),
|
|
direction: one(DIRECTIONS, direction),
|
|
typeId: typeId || undefined,
|
|
source: source || undefined,
|
|
customerId: customerId || undefined,
|
|
from: parseDate(from),
|
|
to: parseDate(to, true),
|
|
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;
|
|
}
|
|
}
|