import { Body, Controller, Get, Param, Patch, Post, Query, Req, Res, StreamableFile, UploadedFiles, UseGuards, UseInterceptors, } from "@nestjs/common"; import { FilesInterceptor } from "@nestjs/platform-express"; import type { ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database"; import type { Request, Response } 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 type { UploadedFileLike } from "../storage/upload-file"; import { StatementsService } from "./statements.service"; import { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto"; /** * Statement OCR intake (RECEIPT_CAPTURE_SPEC §2). * * Nothing here writes to the ledger directly — confirming a batch delegates to * BillingService, so an OCR-captured charge is indistinguishable from a * hand-keyed one except for its `captureSource`. */ @Controller("statements") @UseGuards(AuthenticatedGuard, AbilityGuard) export class StatementsController { constructor( private readonly statements: StatementsService, private readonly audit: AuditService, ) {} private actingId(req: Request): string { return (req.user as { id: string } | undefined)?.id ?? ""; } /** * Whether this deployment can ingest scans at all — the UI hides automatic * capture without it. Both halves are needed: OCR to read the page, object * storage to keep it. */ @Get("status") async status() { return { ocrAvailable: await this.statements.ocrAvailable(), storageAvailable: this.statements.storageAvailable(), }; } @Get("batches") listBatches(@Query("page") page?: string, @Query("pageSize") pageSize?: string) { return this.statements.listBatches( Math.max(1, Number(page) || 1), Math.min(100, Math.max(1, Number(pageSize) || 25)), ); } @Get("batches/:id") getBatch(@Param("id") id: string) { return this.statements.getBatch(id); } @Get("batches/:id/documents") listDocuments(@Param("id") id: string, @Query("status") status?: string) { return this.statements.listDocuments( id, (status || undefined) as StatementDocumentStatus | undefined, ); } /** The rendered page, so a reviewer can compare it against what was read. */ @Get("documents/:id/page") async pageImage(@Param("id") id: string, @Res({ passthrough: true }) res: Response) { const { stream, contentType, contentLength } = await this.statements.pageImage(id); res.set({ "Content-Type": contentType ?? "image/png", ...(contentLength ? { "Content-Length": String(contentLength) } : {}), }); return new StreamableFile(stream); } // --- writes --------------------------------------------------------------- @Post("batches") @RequireAbility("statement:ingest") @UseInterceptors( // A month of one company's statements is a handful of multi-page scans; // 25 files at 50MB covers that with room to spare. FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }), ) async createBatch( @UploadedFiles() files: UploadedFileLike[] | undefined, @Query("serviceKind") serviceKind: ServiceKind, @Query("label") label: string | undefined, @Req() req: Request, ) { const batch = await this.statements.createBatch( files ?? [], serviceKind, this.actingId(req), label, ); void this.audit.log(this.actingId(req), "statement.batch.create", { batchId: batch.id, serviceKind, fileCount: batch.fileCount, }); return batch; } @Patch("documents/:id") @RequireAbility("statement:review") async review( @Param("id") id: string, @Body() dto: ReviewDocumentDto, @Req() req: Request, ) { const doc = await this.statements.review(id, dto, this.actingId(req)); void this.audit.log(this.actingId(req), "statement.document.review", { documentId: id, status: doc.status, }); return doc; } @Post("documents/:id/reject") @RequireAbility("statement:review") async reject(@Param("id") id: string, @Req() req: Request) { const doc = await this.statements.reject(id, this.actingId(req)); void this.audit.log(this.actingId(req), "statement.document.reject", { documentId: id, }); return doc; } /** Post every matched document in the batch, against one check. */ @Post("batches/:id/confirm") @RequireAbility("statement:review") async confirm( @Param("id") id: string, @Body() dto: ConfirmBatchDto, @Req() req: Request, ) { const result = await this.statements.confirmBatch(id, dto, this.actingId(req)); void this.audit.log(this.actingId(req), "statement.batch.confirm", { batchId: id, posted: result.posted, total: result.total, checkNumber: dto.checkNumber, }); return result; } }