diff --git a/apps/api/src/statements/statement-confirm.spec.ts b/apps/api/src/statements/statement-confirm.spec.ts new file mode 100644 index 0000000..4dc46f2 --- /dev/null +++ b/apps/api/src/statements/statement-confirm.spec.ts @@ -0,0 +1,127 @@ +import { BadRequestException } from "@nestjs/common"; +import { Prisma } from "@jorgecuadros/database"; +import { StatementsService, assertSplitsSum } from "./statements.service"; + +/** + * Confirming a batch is where a receipt becomes ledger rows. What must hold: + * a split receipt charges each part to its own customer and nothing to the + * matched one, sin fondos on one receipt holds back only that receipt, and a + * split that no longer adds up never posts. + */ + +const D = (v: string) => new Prisma.Decimal(v); + +function build(docs: unknown[]) { + const lines: { customerId: string; amount: number; outstanding: boolean }[] = []; + let refs: (string | undefined)[] = []; + const billing = { + createBatch: jest.fn().mockImplementation((dto, opts) => { + lines.push(...dto.lines); + refs = opts.refs; + return Promise.resolve({ + items: dto.lines.map((_: unknown, i: number) => ({ id: `tx-${i}` })), + count: dto.lines.length, + total: "0.00", + outstandingCount: 0, + }); + }), + }; + const prisma = { + statementBatch: { + findUnique: jest.fn().mockResolvedValue({ id: "b1", serviceKind: "WATER" }), + updateMany: jest.fn(), + }, + statementDocument: { + findMany: jest.fn().mockResolvedValue(docs), + update: jest.fn((a) => a), + updateMany: jest.fn((a) => a), + count: jest.fn().mockResolvedValue(0), + }, + statementDocumentSplit: { update: jest.fn((a) => a) }, + typeTransaction: { findFirst: jest.fn().mockResolvedValue({ id: "type-water" }) }, + propertyService: { updateMany: jest.fn() }, + $transaction: jest.fn().mockResolvedValue([]), + }; + const service = new StatementsService( + prisma as never, + {} as never, + billing as never, + {} as never, + {} as never, + ); + return { service, prisma, lines, refs: () => refs }; +} + +const doc = (over: Record) => ({ + id: "d1", + pageNumber: 1, + matchedCustomerId: "c-411", + matchedPropertyServiceId: null, + extractedAmount: D("300.00"), + extractedAccountRef: "3014057", + extractedPeriod: "ENE-FEB", + outstanding: false, + splits: [], + ...over, +}); + +const confirm = { checkNumber: "8728", transactionDate: "2026-10-08" }; + +describe("confirming OCR receipts", () => { + it("posts one charge per part of a split receipt", async () => { + const { service, prisma, lines, refs } = build([ + doc({ + splits: [ + { id: "s1", sequence: 1, customerId: "c-411", amount: D("150.00") }, + { id: "s2", sequence: 2, customerId: "c-523", amount: D("150.00") }, + ], + }), + ]); + + await service.confirmBatch("b1", confirm, "u1"); + + expect(lines.map((l) => [l.customerId, l.amount])).toEqual([ + ["c-411", -150], + ["c-523", -150], + ]); + expect(refs()).toEqual(["d1#1", "d1#2"]); + // The rows hang off the parts, not the document. + expect(prisma.statementDocumentSplit.update).toHaveBeenCalledTimes(2); + expect(prisma.statementDocument.update).not.toHaveBeenCalled(); + }); + + it("holds back only the receipt marked sin fondos", async () => { + const { service, lines } = build([ + doc({ id: "d1", outstanding: true }), + doc({ id: "d2", matchedCustomerId: "c-208" }), + ]); + + await service.confirmBatch("b1", confirm, "u1"); + + expect(lines.map((l) => l.outstanding)).toEqual([true, false]); + }); + + it("refuses a split that no longer adds up to the receipt", async () => { + const { service, prisma } = build([ + doc({ + extractedAmount: D("320.00"), // edited after the split was saved + splits: [ + { id: "s1", sequence: 1, customerId: "c-411", amount: D("150.00") }, + { id: "s2", sequence: 2, customerId: "c-523", amount: D("150.00") }, + ], + }), + ]); + + await expect(service.confirmBatch("b1", confirm, "u1")).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); + +describe("split sums", () => { + it("compares in cents, so float noise is not a mismatch", () => { + expect(() => assertSplitsSum([0.1, 0.2], 0.3)).not.toThrow(); + expect(() => assertSplitsSum([100, 33.33, 33.33], 166.67)).toThrow(BadRequestException); + }); +}); diff --git a/apps/api/src/statements/statement.dto.ts b/apps/api/src/statements/statement.dto.ts index f55c72f..b3ee135 100644 --- a/apps/api/src/statements/statement.dto.ts +++ b/apps/api/src/statements/statement.dto.ts @@ -1,4 +1,6 @@ import { + ArrayMaxSize, + IsArray, IsBoolean, IsEnum, IsInt, @@ -6,7 +8,9 @@ import { IsOptional, IsString, MinLength, + ValidateNested, } from "class-validator"; +import { Type } from "class-transformer"; import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database"; export class CreateStatementBatchDto { @@ -27,6 +31,22 @@ export class ReviewDocumentDto { @IsOptional() @IsEnum(StatementDocumentStatus) status?: Extract; + /** Sin fondos for this receipt alone. */ + @IsOptional() @IsBoolean() outstanding?: boolean; +} + +export class SplitPartDto { + @IsString() @MinLength(1) customerId!: string; + @IsNumber() amount!: number; +} + +/** A receipt's split; an empty list removes it. */ +export class SetSplitsDto { + @IsArray() + @ArrayMaxSize(20) + @ValidateNested({ each: true }) + @Type(() => SplitPartDto) + parts!: SplitPartDto[]; } /** diff --git a/apps/api/src/statements/statements.controller.ts b/apps/api/src/statements/statements.controller.ts index 3838457..6f286fe 100644 --- a/apps/api/src/statements/statements.controller.ts +++ b/apps/api/src/statements/statements.controller.ts @@ -4,6 +4,7 @@ import { Get, Param, Patch, + Put, Post, Query, Req, @@ -22,7 +23,7 @@ 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"; +import { ConfirmBatchDto, ReviewDocumentDto, SetSplitsDto } from "./statement.dto"; /** * Statement OCR intake (RECEIPT_CAPTURE_SPEC §2). @@ -117,6 +118,39 @@ export class StatementsController { 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( diff --git a/apps/api/src/statements/statements.service.ts b/apps/api/src/statements/statements.service.ts index 4b361a8..fa3dca0 100644 --- a/apps/api/src/statements/statements.service.ts +++ b/apps/api/src/statements/statements.service.ts @@ -17,7 +17,7 @@ import type { UploadedFileLike } from "../storage/upload-file"; import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider"; import { parseStatement } from "./parsers/statement-parser"; import { StatementMatcherService, scopedRefField } from "./statement-matcher.service"; -import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto"; +import type { ConfirmBatchDto, ReviewDocumentDto, SplitPartDto } from "./statement.dto"; /** * Default ledger concept per service kind. The names are the legacy @@ -74,6 +74,49 @@ export class StatementsService { uploadedById: string, label?: string, ) { + await this.assertCanIngest(files); + + const batch = await this.prisma.statementBatch.create({ + data: { serviceKind, uploadedById, label, fileCount: files.length }, + }); + this.startProcessing(batch.id, files, serviceKind, 0); + return batch; + } + + /** + * Add more scans to a batch already under review — the office keeps one + * batch open for one check while the receipts trickle in. New pages are + * numbered after the existing ones; a settled (COMPLETED) batch reopens. + */ + async addFiles(batchId: string, files: UploadedFileLike[]) { + await this.assertCanIngest(files); + const batch = await this.prisma.statementBatch.findUnique({ where: { id: batchId } }); + if (!batch) throw new NotFoundException("Lote no encontrado."); + if (batch.status === "DISCARDED") { + throw new BadRequestException("Este lote fue descartado; crea uno nuevo."); + } + // Two passes numbering pages at once would collide on (batchId, pageNumber). + if (batch.status === "PROCESSING" || batch.status === "UPLOADED") { + throw new BadRequestException("El lote todavía se está procesando; espera a que termine."); + } + const last = await this.prisma.statementDocument.aggregate({ + where: { batchId }, + _max: { pageNumber: true }, + }); + const updated = await this.prisma.statementBatch.update({ + where: { id: batchId }, + data: { + status: "UPLOADED", + fileCount: { increment: files.length }, + completedAt: null, + error: null, + }, + }); + this.startProcessing(batchId, files, batch.serviceKind, last._max.pageNumber ?? 0); + return updated; + } + + private async assertCanIngest(files: UploadedFileLike[]) { if (!files?.length) throw new BadRequestException("No se recibió ningún archivo."); if (!(await this.ocr.available())) { throw new BadRequestException( @@ -88,23 +131,24 @@ export class StatementsService { "guardar los recibos escaneados.", ); } + } - const batch = await this.prisma.statementBatch.create({ - data: { serviceKind, uploadedById, label, fileCount: files.length }, - }); - + private startProcessing( + batchId: string, + files: UploadedFileLike[], + serviceKind: ServiceKind, + afterPage: number, + ) { // Buffers are held for the background pass; the request's own copies would // otherwise be garbage once the response is sent. const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname })); - void this.process(batch.id, copies, serviceKind).catch(async (err) => { - this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`); + void this.process(batchId, copies, serviceKind, afterPage).catch(async (err) => { + this.logger.error(`Batch ${batchId} failed: ${(err as Error).message}`); await this.prisma.statementBatch.update({ - where: { id: batch.id }, + where: { id: batchId }, data: { status: "FAILED", error: (err as Error).message }, }); }); - - return batch; } /** Render → OCR → parse → match, one document row per page. */ @@ -112,13 +156,14 @@ export class StatementsService { batchId: string, files: { buffer: Buffer; name?: string }[], serviceKind: ServiceKind, + afterPage = 0, ) { await this.prisma.statementBatch.update({ where: { id: batchId }, data: { status: "PROCESSING" }, }); - let pageNumber = 0; + let pageNumber = afterPage; for (const file of files) { // The source PDF is kept as well as the page images: it is the artifact // the office actually received, and the only way to re-run a corrected @@ -227,11 +272,24 @@ export class StatementsService { where: { batchId: id, status: { in: OPEN } }, _sum: { extractedAmount: true }, }); + // The check this batch was last posted against, so the next confirm of a + // batch kept open for one check doesn't make staff retype it. + const last = await this.prisma.transaction.findFirst({ + where: { + OR: [ + { statementDocument: { batchId: id } }, + { statementSplit: { document: { batchId: id } } }, + ], + }, + orderBy: { createdAt: "desc" }, + select: { checkNumber: true }, + }); return { ...batch, byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])), pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00", + lastCheckNumber: last?.checkNumber ?? null, }; } @@ -250,6 +308,10 @@ export class StatementsService { property: { select: { id: true, addressLine1: true } }, }, }, + splits: { + orderBy: { sequence: "asc" }, + include: { customer: { select: { id: true, name: true } } }, + }, }, }); } @@ -268,11 +330,17 @@ export class StatementsService { /** Staff correction of an extracted field or of the match itself. */ async review(id: string, dto: ReviewDocumentDto, reviewedById: string) { - const doc = await this.prisma.statementDocument.findUnique({ where: { id } }); + const doc = await this.prisma.statementDocument.findUnique({ + where: { id }, + include: { splits: { select: { amount: true } } }, + }); if (!doc) throw new NotFoundException("Documento no encontrado."); if (doc.status === "POSTED") { throw new BadRequestException("Este documento ya fue registrado."); } + if (dto.amount != null && doc.splits.length) { + assertSplitsSum(doc.splits.map((s) => s.amount), dto.amount); + } // Changing the service implies its owner; deriving the customer here rather // than trusting a client-supplied pair is what stops a page being posted to @@ -329,6 +397,7 @@ export class StatementsService { extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, matchedPropertyServiceId, matchedCustomerId, + outstanding: dto.outstanding ?? undefined, status: dto.status ?? "MATCHED", reviewedById, reviewedAt: new Date(), @@ -336,6 +405,62 @@ export class StatementsService { }); } + /** + * Replace a receipt's split. Empty clears it, and the receipt posts whole to + * its matched customer again. Otherwise at least two parts, each positive, + * summing to the receipt's amount to the cent — a split that doesn't add up + * would charge the customers more or less than the bill. + */ + async setSplits(id: string, parts: SplitPartDto[], reviewedById: string) { + const doc = await this.prisma.statementDocument.findUnique({ where: { id } }); + if (!doc) throw new NotFoundException("Documento no encontrado."); + if (doc.status === "POSTED" || doc.status === "REJECTED") { + throw new BadRequestException("Este documento ya no se puede dividir."); + } + if (parts.length === 1) { + throw new BadRequestException("Una división necesita al menos dos partes."); + } + if (parts.length) { + if (doc.extractedAmount == null) { + throw new BadRequestException("Captura el importe del recibo antes de dividirlo."); + } + if (parts.some((p) => !(p.amount > 0))) { + throw new BadRequestException("Cada parte debe ser mayor a cero."); + } + assertSplitsSum(parts.map((p) => p.amount), Number(doc.extractedAmount)); + const ids = [...new Set(parts.map((p) => p.customerId))]; + const found = await this.prisma.customer.count({ where: { id: { in: ids } } }); + if (found !== ids.length) throw new BadRequestException("Cliente no encontrado."); + } + + await this.prisma.$transaction([ + this.prisma.statementDocumentSplit.deleteMany({ where: { documentId: id } }), + ...parts.map((p, i) => + this.prisma.statementDocumentSplit.create({ + data: { + documentId: id, + sequence: i + 1, + customerId: p.customerId, + amount: new Prisma.Decimal(p.amount.toFixed(2)), + }, + }), + ), + // A split receipt has its customers; it is ready to post like any match. + this.prisma.statementDocument.update({ + where: { id }, + data: { + ...(parts.length ? { status: "MATCHED" } : {}), + reviewedById, + reviewedAt: new Date(), + }, + }), + ]); + return this.prisma.statementDocument.findUnique({ + where: { id }, + include: { splits: { orderBy: { sequence: "asc" } } }, + }); + } + async reject(id: string, reviewedById: string) { const doc = await this.prisma.statementDocument.findUnique({ where: { id } }); if (!doc) throw new NotFoundException("Documento no encontrado."); @@ -414,9 +539,10 @@ export class StatementsService { where: { batchId, status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] }, - matchedCustomerId: { not: null }, + OR: [{ matchedCustomerId: { not: null } }, { splits: { some: {} } }], }, orderBy: { pageNumber: "asc" }, + include: { splits: { orderBy: { sequence: "asc" } } }, }); if (!docs.length) { throw new BadRequestException("No hay documentos listos para registrar."); @@ -430,8 +556,39 @@ export class StatementsService { ); } + // Re-checked here as well as when saved: the amount may have been edited + // after the split was. + for (const d of docs) { + if (d.splits.length) { + assertSplitsSum(d.splits.map((s) => s.amount), Number(d.extractedAmount)); + } + } + const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind)); + // One ledger line per receipt, or per part of a split receipt. `posts` + // remembers which record each line came from, so the created rows can be + // written back below. + const posts = docs.flatMap((d) => + d.splits.length + ? d.splits.map((s) => ({ + doc: d, + splitId: s.id as string | null, + customerId: s.customerId, + amount: Number(s.amount), + ref: `${d.id}#${s.sequence}`, + })) + : [ + { + doc: d, + splitId: null, + customerId: d.matchedCustomerId!, + amount: Number(d.extractedAmount), + ref: d.id, + }, + ], + ); + const result = await this.billing.createBatch( { domain: "UTILITY", @@ -439,36 +596,44 @@ export class StatementsService { checkNumber: dto.checkNumber, currency: dto.currency ?? "MXN", typeId, - lines: docs.map((d) => ({ - customerId: d.matchedCustomerId!, + lines: posts.map((p) => ({ + customerId: p.customerId, // Charges are negative in this ledger: a negative amount is what the // customer owes. The parser reads the printed (positive) figure, so // the sign is applied here, at the single point where a statement // becomes a ledger row. - amount: -Math.abs(Number(d.extractedAmount)), - reference: d.extractedAccountRef ?? undefined, - period: d.extractedPeriod ?? undefined, - outstanding: dto.outstanding ?? false, + amount: -Math.abs(p.amount), + reference: p.doc.extractedAccountRef ?? undefined, + period: p.doc.extractedPeriod ?? undefined, + // Sin fondos is per check or per receipt: either one holds it back. + outstanding: (dto.outstanding ?? false) || p.doc.outstanding, })), }, - { source: "OCR", refs: docs.map((d) => d.id) }, + { source: "OCR", refs: posts.map((p) => p.ref) }, ); // `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so - // the created rows zip straight back onto the documents that produced them. - await this.prisma.$transaction( - docs.map((d, i) => - this.prisma.statementDocument.update({ - where: { id: d.id }, - data: { - status: "POSTED", - postedTransactionId: result.items[i].id, - reviewedById, - reviewedAt: new Date(), - }, - }), + // the created rows zip straight back onto the records that produced them. + // A split receipt's rows hang off its parts; the document itself keeps no + // single posted row. + const now = new Date(); + await this.prisma.$transaction([ + ...posts.map((p, i) => + p.splitId + ? this.prisma.statementDocumentSplit.update({ + where: { id: p.splitId }, + data: { postedTransactionId: result.items[i].id }, + }) + : this.prisma.statementDocument.update({ + where: { id: p.doc.id }, + data: { postedTransactionId: result.items[i].id }, + }), ), - ); + this.prisma.statementDocument.updateMany({ + where: { id: { in: docs.map((d) => d.id) } }, + data: { status: "POSTED", reviewedById, reviewedAt: now }, + }), + ]); // Teach the matcher. When a document was matched by clave catastral or by // hand because the scoped field was blank, writing the reference back means @@ -479,7 +644,12 @@ export class StatementsService { await this.closeIfDone(batchId); - return { posted: result.count, total: result.total, checkNumber: dto.checkNumber }; + return { + posted: result.count, + total: result.total, + outstanding: result.outstandingCount, + checkNumber: dto.checkNumber, + }; } /** Write a confirmed reference onto a service that had none. */ @@ -524,3 +694,14 @@ export class StatementsService { return row?.id; } } + +/** Parts of a split must add up to the receipt, compared in cents. */ +export function assertSplitsSum(parts: (number | Prisma.Decimal)[], total: number) { + const cents = (v: number | Prisma.Decimal) => Math.round(Number(v) * 100); + const sum = parts.reduce((s, p) => s + cents(p), 0); + if (sum !== cents(total)) { + throw new BadRequestException( + `La división suma ${(sum / 100).toFixed(2)} y el recibo es de ${total.toFixed(2)}.`, + ); + } +} diff --git a/apps/web/src/app/recibos/[id]/page.tsx b/apps/web/src/app/recibos/[id]/page.tsx index fefb77b..68e619b 100644 --- a/apps/web/src/app/recibos/[id]/page.tsx +++ b/apps/web/src/app/recibos/[id]/page.tsx @@ -6,18 +6,21 @@ import { AppShell } from "@/components/AppShell"; import { CustomerPicker } from "@/components/CustomerPicker"; import { DiscardBatchCard } from "@/components/DiscardBatchCard"; import { + addStatementFiles, confirmStatementBatch, discardStatementBatch, getStatementBatch, listStatementDocuments, rejectStatementDocument, reviewStatementDocument, + setStatementSplits, statementPageUrl, } from "@/lib/api"; import { useCan } from "@/lib/abilities"; import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels"; import type { ConfirmBatchInput, + SplitPartInput, StatementBatchDetail, StatementDocument, StatementDocumentStatus, @@ -64,6 +67,7 @@ export default function RecibosBatchPage({ params }: { params: { id: string } }) function BatchReview({ id }: { id: string }) { const canReview = useCan("statement:review"); + const canIngest = useCan("statement:ingest"); const [batch, setBatch] = useState(null); const [docs, setDocs] = useState([]); const [error, setError] = useState(null); @@ -108,7 +112,7 @@ function BatchReview({ id }: { id: string }) { ); const readyCount = docs.filter( - (d) => d.status === "MATCHED" && d.matchedCustomer, + (d) => d.status === "MATCHED" && (d.matchedCustomer || d.splits.length > 0), ).length; async function discard() { @@ -162,9 +166,14 @@ function BatchReview({ id }: { id: string }) { + {canIngest && !processing && batch.status !== "DISCARDED" && ( + + )} + {canReview && readyCount > 0 && ( void; setError: (m: string | null) => void; }) { - const [checkNumber, setCheckNumber] = useState(""); + // A batch kept open for one check is posted in rounds against that check. + const [checkNumber, setCheckNumber] = useState(lastCheckNumber ?? ""); const [transactionDate, setTransactionDate] = useState( new Date().toISOString().slice(0, 10), ); @@ -322,9 +334,9 @@ function ConfirmCard({ }; const r = await confirmStatementBatch(batchId, input); setResult( - `Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`, + `Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}` + + (r.outstanding ? `; ${r.outstanding} sin fondos.` : "."), ); - setCheckNumber(""); onDone(); } catch (e) { setError((e as Error)?.message ?? "No se pudo registrar el lote."); @@ -363,7 +375,7 @@ function ConfirmCard({ checked={outstanding} onChange={(e) => setOutstanding(e.target.checked)} />{" "} - Sin fondos (queda pendiente) + Todo el cheque sin fondos