feat(recibos): split a receipt, sin fondos per receipt, keep a batch open
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m57s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m22s

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>
This commit is contained in:
rmancinasandClaude Opus 5.5 committed 2026-10-12 19:20:20 -07:00
1 parent f655fcbb82
commit fd5df240bc
9 files changed
+756 -49

No files matched your search

@@ -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<string, unknown>) => ({
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);
});
});
+20
View File
@@ -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<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
/** 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[];
}
/**
@@ -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(
+215 -34
View File
@@ -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<number>((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)}.`,
);
}
}