import { Body, Controller, Get, Param, Patch, 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"; import { CreateBankAccountDto, CreateBankDto, UpdateBankAccountDto, UpdateBankDto, } from "./bank-account.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(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; } // --- accounts ------------------------------------------------------------- // Declared before the parameterised routes below so `/bank/accounts` can // never be swallowed by a `:id`-shaped path. /** * The account picker. Readable by any authenticated user, VIEWER included — * nothing else on this page can render until an account is chosen. */ @Get("accounts") accounts() { return this.bank.listAccounts(); } @Get("banks") banks() { return this.bank.listBanks(); } @Post("banks") @RequireAbility("bank:manage-accounts") async createBank(@Body() dto: CreateBankDto, @Req() req: Request) { const row = await this.bank.createBank(dto); void this.audit.log(this.actingId(req), "bank.bank.create", { bankId: row.id, name: row.name, }); return row; } @Patch("banks/:id") @RequireAbility("bank:manage-accounts") async updateBank( @Param("id") id: string, @Body() dto: UpdateBankDto, @Req() req: Request, ) { const row = await this.bank.updateBank(id, dto); void this.audit.log(this.actingId(req), "bank.bank.update", { bankId: id }); return row; } @Post("accounts") @RequireAbility("bank:manage-accounts") async createAccount( @Body() dto: CreateBankAccountDto, @Req() req: Request, ) { const row = await this.bank.createAccount(dto); void this.audit.log(this.actingId(req), "bank.account.create", { bankAccountId: row.id, label: row.label, currency: row.currency, }); return row; } @Patch("accounts/:id") @RequireAbility("bank:manage-accounts") async updateAccount( @Param("id") id: string, @Body() dto: UpdateBankAccountDto, @Req() req: Request, ) { const row = await this.bank.updateAccount(id, dto); void this.audit.log(this.actingId(req), "bank.account.update", { bankAccountId: id, }); return row; } // --- register reads (all scoped to one account) --------------------------- @Get("stats") async stats(@Query("bankAccountId") bankAccountId?: string) { const account = await this.bank.requireAccount(bankAccountId); return this.bank.stats(account.id); } @Get("facets") async facets(@Query("bankAccountId") bankAccountId?: string) { const account = await this.bank.requireAccount(bankAccountId); return this.bank.facets(account.id); } /** Year and month rollups with a running net-movement figure. */ @Get("summary") async summary( @Query("bankAccountId") bankAccountId?: string, @Query("year") year?: string, ) { const account = await this.bank.requireAccount(bankAccountId); const y = Number(year); return this.bank.summary( account.id, Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined, ); } /** The register browser. */ @Get() async list( @Query("bankAccountId") bankAccountId?: string, @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, ) { const account = await this.bank.requireAccount(bankAccountId); return this.bank.list({ bankAccountId: account.id, 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, bankAccountId: row.bankAccountId, 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; } }