Scanning a stack of bills and keying them in are the same daily job, ending in the same ledger path, so OCR intake becomes a mode of the capture screen instead of a second menu entry: - components/Captura.tsx holds the mode switch; the manual check form moves verbatim to components/ManualCheckCapture.tsx and the OCR intake to components/StatementIntake.tsx. - /estado-cuenta/lote opens on manual, /recibos on automatic — both render Captura, so batch-review links and old bookmarks still land right. - Nav drops "Recibos (OCR)"; "Captura" covers both, with a NavLink.aliases field so /recibos still highlights it. Also fixes the "El almacenamiento de documentos no está configurado" failure staff hit on upload. Uploading with no object storage configured used to succeed, then die on the first put minutes later, leaving a FAILED batch whose only explanation was that string. createBatch now refuses up front, GET /statements/status reports storageAvailable alongside ocrAvailable, and the intake tab explains the situation instead of offering an upload that cannot work. S3_* documented in .env.example (deploy stacks already set it). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
4.9 KiB
TypeScript
163 lines
4.9 KiB
TypeScript
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;
|
|
}
|
|
}
|