Three asks from the 2026-10-08 meeting, all on the OCR receipt queue. - Split: one receipt paid by several customers (411 + 523 sharing water 3014057) posts one charge per part. statement_document_splits holds free amounts that must sum to the receipt in cents, checked on save and again at confirm; "partes iguales" fills them in. Each part's row carries captureRef <docId>#<n>, so the double-post guard still holds. - Sin fondos per receipt: StatementDocument.outstanding, toggled in review. At confirm a line is outstanding if its receipt or the whole check is. - Open batch: POST /statements/batches/:id/files appends scans, numbered after the existing pages; a COMPLETED batch reopens. The confirm card prefills the check the batch was last posted against. Verified on dev against a real batch: 1,892.00 split 946/946 across two customers, a sibling receipt posted outstanding, second confirm refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
6.4 KiB
TypeScript
209 lines
6.4 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
Patch,
|
|
Put,
|
|
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, SetSplitsDto } 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;
|
|
}
|
|
|
|
/** More scans for a batch kept open for one check. */
|
|
@Post("batches/:id/files")
|
|
@RequireAbility("statement:ingest")
|
|
@UseInterceptors(FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }))
|
|
async addFiles(
|
|
@Param("id") id: string,
|
|
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
|
@Req() req: Request,
|
|
) {
|
|
const batch = await this.statements.addFiles(id, files ?? []);
|
|
void this.audit.log(this.actingId(req), "statement.batch.add-files", {
|
|
batchId: id,
|
|
added: files?.length ?? 0,
|
|
});
|
|
return batch;
|
|
}
|
|
|
|
/** Split one receipt across several customers; empty parts undo it. */
|
|
@Put("documents/:id/splits")
|
|
@RequireAbility("statement:review")
|
|
async setSplits(
|
|
@Param("id") id: string,
|
|
@Body() dto: SetSplitsDto,
|
|
@Req() req: Request,
|
|
) {
|
|
const doc = await this.statements.setSplits(id, dto.parts, this.actingId(req));
|
|
void this.audit.log(this.actingId(req), "statement.document.split", {
|
|
documentId: id,
|
|
parts: dto.parts.length,
|
|
});
|
|
return doc;
|
|
}
|
|
|
|
@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;
|
|
}
|
|
|
|
/** Abandon a batch pending review — rejects every unposted page. */
|
|
@Post("batches/:id/discard")
|
|
@RequireAbility("statement:review")
|
|
async discard(@Param("id") id: string, @Req() req: Request) {
|
|
const result = await this.statements.discardBatch(id, this.actingId(req));
|
|
void this.audit.log(this.actingId(req), "statement.batch.discard", {
|
|
batchId: id,
|
|
rejected: result.rejected,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|