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 { import {
ArrayMaxSize,
IsArray,
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsInt, IsInt,
@@ -6,7 +8,9 @@ import {
IsOptional, IsOptional,
IsString, IsString,
MinLength, MinLength,
ValidateNested,
} from "class-validator"; } from "class-validator";
import { Type } from "class-transformer";
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database"; import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
export class CreateStatementBatchDto { export class CreateStatementBatchDto {
@@ -27,6 +31,22 @@ export class ReviewDocumentDto {
@IsOptional() @IsOptional()
@IsEnum(StatementDocumentStatus) @IsEnum(StatementDocumentStatus)
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">; 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, Get,
Param, Param,
Patch, Patch,
Put,
Post, Post,
Query, Query,
Req, Req,
@@ -22,7 +23,7 @@ import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service"; import { AuditService } from "../common/audit.service";
import type { UploadedFileLike } from "../storage/upload-file"; import type { UploadedFileLike } from "../storage/upload-file";
import { StatementsService } from "./statements.service"; 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). * Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
@@ -117,6 +118,39 @@ export class StatementsController {
return batch; 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") @Patch("documents/:id")
@RequireAbility("statement:review") @RequireAbility("statement:review")
async 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 { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
import { parseStatement } from "./parsers/statement-parser"; import { parseStatement } from "./parsers/statement-parser";
import { StatementMatcherService, scopedRefField } from "./statement-matcher.service"; 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 * Default ledger concept per service kind. The names are the legacy
@@ -74,6 +74,49 @@ export class StatementsService {
uploadedById: string, uploadedById: string,
label?: 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 (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
if (!(await this.ocr.available())) { if (!(await this.ocr.available())) {
throw new BadRequestException( throw new BadRequestException(
@@ -88,23 +131,24 @@ export class StatementsService {
"guardar los recibos escaneados.", "guardar los recibos escaneados.",
); );
} }
}
const batch = await this.prisma.statementBatch.create({ private startProcessing(
data: { serviceKind, uploadedById, label, fileCount: files.length }, batchId: string,
}); files: UploadedFileLike[],
serviceKind: ServiceKind,
afterPage: number,
) {
// Buffers are held for the background pass; the request's own copies would // Buffers are held for the background pass; the request's own copies would
// otherwise be garbage once the response is sent. // otherwise be garbage once the response is sent.
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname })); const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
void this.process(batch.id, copies, serviceKind).catch(async (err) => { void this.process(batchId, copies, serviceKind, afterPage).catch(async (err) => {
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`); this.logger.error(`Batch ${batchId} failed: ${(err as Error).message}`);
await this.prisma.statementBatch.update({ await this.prisma.statementBatch.update({
where: { id: batch.id }, where: { id: batchId },
data: { status: "FAILED", error: (err as Error).message }, data: { status: "FAILED", error: (err as Error).message },
}); });
}); });
return batch;
} }
/** Render → OCR → parse → match, one document row per page. */ /** Render → OCR → parse → match, one document row per page. */
@@ -112,13 +156,14 @@ export class StatementsService {
batchId: string, batchId: string,
files: { buffer: Buffer; name?: string }[], files: { buffer: Buffer; name?: string }[],
serviceKind: ServiceKind, serviceKind: ServiceKind,
afterPage = 0,
) { ) {
await this.prisma.statementBatch.update({ await this.prisma.statementBatch.update({
where: { id: batchId }, where: { id: batchId },
data: { status: "PROCESSING" }, data: { status: "PROCESSING" },
}); });
let pageNumber = 0; let pageNumber = afterPage;
for (const file of files) { for (const file of files) {
// The source PDF is kept as well as the page images: it is the artifact // 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 // 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 } }, where: { batchId: id, status: { in: OPEN } },
_sum: { extractedAmount: true }, _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 { return {
...batch, ...batch,
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])), byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00", 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 } }, 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. */ /** Staff correction of an extracted field or of the match itself. */
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) { 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) throw new NotFoundException("Documento no encontrado.");
if (doc.status === "POSTED") { if (doc.status === "POSTED") {
throw new BadRequestException("Este documento ya fue registrado."); 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 // 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 // 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, extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
matchedPropertyServiceId, matchedPropertyServiceId,
matchedCustomerId, matchedCustomerId,
outstanding: dto.outstanding ?? undefined,
status: dto.status ?? "MATCHED", status: dto.status ?? "MATCHED",
reviewedById, reviewedById,
reviewedAt: new Date(), 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) { async reject(id: string, reviewedById: string) {
const doc = await this.prisma.statementDocument.findUnique({ where: { id } }); const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
if (!doc) throw new NotFoundException("Documento no encontrado."); if (!doc) throw new NotFoundException("Documento no encontrado.");
@@ -414,9 +539,10 @@ export class StatementsService {
where: { where: {
batchId, batchId,
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] }, status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
matchedCustomerId: { not: null }, OR: [{ matchedCustomerId: { not: null } }, { splits: { some: {} } }],
}, },
orderBy: { pageNumber: "asc" }, orderBy: { pageNumber: "asc" },
include: { splits: { orderBy: { sequence: "asc" } } },
}); });
if (!docs.length) { if (!docs.length) {
throw new BadRequestException("No hay documentos listos para registrar."); 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)); 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( const result = await this.billing.createBatch(
{ {
domain: "UTILITY", domain: "UTILITY",
@@ -439,36 +596,44 @@ export class StatementsService {
checkNumber: dto.checkNumber, checkNumber: dto.checkNumber,
currency: dto.currency ?? "MXN", currency: dto.currency ?? "MXN",
typeId, typeId,
lines: docs.map((d) => ({ lines: posts.map((p) => ({
customerId: d.matchedCustomerId!, customerId: p.customerId,
// Charges are negative in this ledger: a negative amount is what the // Charges are negative in this ledger: a negative amount is what the
// customer owes. The parser reads the printed (positive) figure, so // customer owes. The parser reads the printed (positive) figure, so
// the sign is applied here, at the single point where a statement // the sign is applied here, at the single point where a statement
// becomes a ledger row. // becomes a ledger row.
amount: -Math.abs(Number(d.extractedAmount)), amount: -Math.abs(p.amount),
reference: d.extractedAccountRef ?? undefined, reference: p.doc.extractedAccountRef ?? undefined,
period: d.extractedPeriod ?? undefined, period: p.doc.extractedPeriod ?? undefined,
outstanding: dto.outstanding ?? false, // 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 // `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
// the created rows zip straight back onto the documents that produced them. // the created rows zip straight back onto the records that produced them.
await this.prisma.$transaction( // A split receipt's rows hang off its parts; the document itself keeps no
docs.map((d, i) => // single posted row.
this.prisma.statementDocument.update({ const now = new Date();
where: { id: d.id }, await this.prisma.$transaction([
data: { ...posts.map((p, i) =>
status: "POSTED", p.splitId
postedTransactionId: result.items[i].id, ? this.prisma.statementDocumentSplit.update({
reviewedById, where: { id: p.splitId },
reviewedAt: new Date(), 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 // 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 // hand because the scoped field was blank, writing the reference back means
@@ -479,7 +644,12 @@ export class StatementsService {
await this.closeIfDone(batchId); 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. */ /** Write a confirmed reference onto a service that had none. */
@@ -524,3 +694,14 @@ export class StatementsService {
return row?.id; 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)}.`,
);
}
}
+250 -8
View File
@@ -6,18 +6,21 @@ import { AppShell } from "@/components/AppShell";
import { CustomerPicker } from "@/components/CustomerPicker"; import { CustomerPicker } from "@/components/CustomerPicker";
import { DiscardBatchCard } from "@/components/DiscardBatchCard"; import { DiscardBatchCard } from "@/components/DiscardBatchCard";
import { import {
addStatementFiles,
confirmStatementBatch, confirmStatementBatch,
discardStatementBatch, discardStatementBatch,
getStatementBatch, getStatementBatch,
listStatementDocuments, listStatementDocuments,
rejectStatementDocument, rejectStatementDocument,
reviewStatementDocument, reviewStatementDocument,
setStatementSplits,
statementPageUrl, statementPageUrl,
} from "@/lib/api"; } from "@/lib/api";
import { useCan } from "@/lib/abilities"; import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels"; import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
import type { import type {
ConfirmBatchInput, ConfirmBatchInput,
SplitPartInput,
StatementBatchDetail, StatementBatchDetail,
StatementDocument, StatementDocument,
StatementDocumentStatus, StatementDocumentStatus,
@@ -64,6 +67,7 @@ export default function RecibosBatchPage({ params }: { params: { id: string } })
function BatchReview({ id }: { id: string }) { function BatchReview({ id }: { id: string }) {
const canReview = useCan("statement:review"); const canReview = useCan("statement:review");
const canIngest = useCan("statement:ingest");
const [batch, setBatch] = useState<StatementBatchDetail | null>(null); const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
const [docs, setDocs] = useState<StatementDocument[]>([]); const [docs, setDocs] = useState<StatementDocument[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -108,7 +112,7 @@ function BatchReview({ id }: { id: string }) {
); );
const readyCount = docs.filter( const readyCount = docs.filter(
(d) => d.status === "MATCHED" && d.matchedCustomer, (d) => d.status === "MATCHED" && (d.matchedCustomer || d.splits.length > 0),
).length; ).length;
async function discard() { async function discard() {
@@ -162,9 +166,14 @@ function BatchReview({ id }: { id: string }) {
<SummaryCard batch={batch} readyCount={readyCount} /> <SummaryCard batch={batch} readyCount={readyCount} />
{canIngest && !processing && batch.status !== "DISCARDED" && (
<AddFilesCard batchId={id} onDone={load} setError={setError} />
)}
{canReview && readyCount > 0 && ( {canReview && readyCount > 0 && (
<ConfirmCard <ConfirmCard
batchId={id} batchId={id}
lastCheckNumber={batch.lastCheckNumber}
readyCount={readyCount} readyCount={readyCount}
onDone={load} onDone={load}
setError={setError} setError={setError}
@@ -291,16 +300,19 @@ function SummaryCard({
*/ */
function ConfirmCard({ function ConfirmCard({
batchId, batchId,
lastCheckNumber,
readyCount, readyCount,
onDone, onDone,
setError, setError,
}: { }: {
batchId: string; batchId: string;
lastCheckNumber: string | null;
readyCount: number; readyCount: number;
onDone: () => void; onDone: () => void;
setError: (m: string | null) => 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( const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10), new Date().toISOString().slice(0, 10),
); );
@@ -322,9 +334,9 @@ function ConfirmCard({
}; };
const r = await confirmStatementBatch(batchId, input); const r = await confirmStatementBatch(batchId, input);
setResult( 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(); onDone();
} catch (e) { } catch (e) {
setError((e as Error)?.message ?? "No se pudo registrar el lote."); setError((e as Error)?.message ?? "No se pudo registrar el lote.");
@@ -363,7 +375,7 @@ function ConfirmCard({
checked={outstanding} checked={outstanding}
onChange={(e) => setOutstanding(e.target.checked)} onChange={(e) => setOutstanding(e.target.checked)}
/>{" "} />{" "}
Sin fondos (queda pendiente) Todo el cheque sin fondos
</label> </label>
<label className="check"> <label className="check">
<input <input
@@ -389,12 +401,65 @@ function ConfirmCard({
)} )}
<p className="page-sub" style={{ marginTop: 12 }}> <p className="page-sub" style={{ marginTop: 12 }}>
Se registran como cargos del cliente, por la misma vía que la captura Se registran como cargos del cliente, por la misma vía que la captura
manual. Un lote registrado dos veces no duplica cobros. manual. Un lote registrado dos veces no duplica cobros. Para dejar
pendiente un solo recibo, márcalo «Sin fondos» en el recibo.
</p> </p>
</section> </section>
); );
} }
/** More scans for this batch — it stays open while receipts keep arriving. */
function AddFilesCard({
batchId,
onDone,
setError,
}: {
batchId: string;
onDone: () => void;
setError: (m: string | null) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [busy, setBusy] = useState(false);
async function upload() {
setBusy(true);
setError(null);
try {
await addStatementFiles(batchId, files);
setFiles([]);
onDone();
} catch (e) {
setError((e as Error)?.message ?? "No se pudieron agregar los archivos.");
} finally {
setBusy(false);
}
}
return (
<section className="card" style={{ padding: 16 }}>
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<label>
<span className="page-sub">Agregar recibos a este lote</span>
<input
type="file"
accept="application/pdf"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
</label>
<button
type="button"
className="btn btn-outline"
disabled={!files.length || busy}
onClick={upload}
>
{busy ? "Subiendo…" : `Agregar ${files.length || ""} archivo(s)`}
</button>
</div>
</section>
);
}
function DocumentRow({ function DocumentRow({
doc, doc,
canReview, canReview,
@@ -411,6 +476,7 @@ function DocumentRow({
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? ""); const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? ""); const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? ""); const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
const [outstanding, setOutstanding] = useState(doc.outstanding);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@@ -424,6 +490,7 @@ function DocumentRow({
accountRef: accountRef.trim() || undefined, accountRef: accountRef.trim() || undefined,
amount: amount ? Number(amount) : undefined, amount: amount ? Number(amount) : undefined,
matchedCustomerId: customerId || undefined, matchedCustomerId: customerId || undefined,
outstanding,
status, status,
}); });
onChange(); onChange();
@@ -456,9 +523,21 @@ function DocumentRow({
<div> <div>
<strong>Página {doc.pageNumber}</strong>{" "} <strong>Página {doc.pageNumber}</strong>{" "}
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "} <span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
{doc.outstanding && <span className="tag">Sin fondos</span>}{" "}
{doc.provider && <span className="page-sub">{doc.provider}</span>} {doc.provider && <span className="page-sub">{doc.provider}</span>}
<div className="page-sub" style={{ marginTop: 4 }}> <div className="page-sub" style={{ marginTop: 4 }}>
{doc.matchedCustomer ? ( {doc.splits.length > 0 ? (
<>
Dividido:{" "}
{doc.splits.map((s, i) => (
<span key={s.id}>
{i > 0 && " + "}
<Link href={`/clientes/${s.customer.id}`}>{s.customer.name}</Link>{" "}
{formatMoney(s.amount, "MXN")}
</span>
))}
</>
) : doc.matchedCustomer ? (
<Link href={`/clientes/${doc.matchedCustomer.id}`}> <Link href={`/clientes/${doc.matchedCustomer.id}`}>
{doc.matchedCustomer.name} {doc.matchedCustomer.name}
</Link> </Link>
@@ -533,10 +612,18 @@ function DocumentRow({
}} }}
/> />
</div> </div>
<label className="check">
<input
type="checkbox"
checked={outstanding}
onChange={(e) => setOutstanding(e.target.checked)}
/>{" "}
Sin fondos
</label>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
disabled={busy || !customerId} disabled={busy || (!customerId && doc.splits.length === 0)}
onClick={() => save("MATCHED")} onClick={() => save("MATCHED")}
> >
Guardar Guardar
@@ -552,9 +639,164 @@ function DocumentRow({
</div> </div>
)} )}
{canReview && !settled && (
<SplitEditor doc={doc} onChange={onChange} />
)}
{err && <div className="state-box state-error">{err}</div>} {err && <div className="state-box state-error">{err}</div>}
</div> </div>
)} )}
</div> </div>
); );
} }
type Part = { customerId: string; customerName: string; amount: string };
/** Equal parts in cents; the odd cents go to the first parts. */
function equalParts(total: number, n: number): string[] {
const cents = Math.round(total * 100);
const base = Math.floor(cents / n);
return Array.from({ length: n }, (_, i) =>
((base + (i < cents - base * n ? 1 : 0)) / 100).toFixed(2),
);
}
/**
* One receipt paid by several customers — a water bill two neighbours share.
* Amounts are free (the office splits unevenly as often as not) but must add
* up to the receipt; "partes iguales" fills them in.
*/
function SplitEditor({ doc, onChange }: { doc: StatementDocument; onChange: () => void }) {
const total = Number(doc.extractedAmount ?? 0);
const [parts, setParts] = useState<Part[]>(() =>
doc.splits.map((s) => ({
customerId: s.customer.id,
customerName: s.customer.name,
amount: Number(s.amount).toFixed(2),
})),
);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const sum = parts.reduce((s, p) => s + Math.round(Number(p.amount || 0) * 100), 0) / 100;
const balanced = Math.round(sum * 100) === Math.round(total * 100);
const complete = parts.length >= 2 && parts.every((p) => p.customerId && Number(p.amount) > 0);
function setPart(i: number, patch: Partial<Part>) {
setParts((prev) => prev.map((p, j) => (j === i ? { ...p, ...patch } : p)));
}
function start() {
const first = {
customerId: doc.matchedCustomer?.id ?? "",
customerName: doc.matchedCustomer?.name ?? "",
};
const [a, b] = equalParts(total, 2);
setParts([
{ ...first, amount: a },
{ customerId: "", customerName: "", amount: b },
]);
}
function evenly() {
const amounts = equalParts(total, parts.length);
setParts((prev) => prev.map((p, i) => ({ ...p, amount: amounts[i] })));
}
async function save(next: SplitPartInput[]) {
setBusy(true);
setErr(null);
try {
await setStatementSplits(doc.id, next);
onChange();
} catch (e) {
setErr((e as Error)?.message ?? "No se pudo guardar la división.");
} finally {
setBusy(false);
}
}
if (!parts.length) {
return (
<div>
<button
type="button"
className="btn btn-ghost"
disabled={!doc.extractedAmount}
title={doc.extractedAmount ? undefined : "Captura el importe primero"}
onClick={start}
>
Dividir entre varios clientes
</button>
</div>
);
}
return (
<div className="stack" style={{ gap: 8 }}>
<strong>División del recibo ({formatMoney(total, "MXN")})</strong>
{parts.map((p, i) => (
<div key={i} className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<div style={{ minWidth: 260 }}>
<CustomerPicker
value={p.customerId}
valueName={p.customerName}
onPick={(cid, name) => setPart(i, { customerId: cid, customerName: name })}
/>
</div>
<input
className="input"
inputMode="decimal"
style={{ width: 120 }}
value={p.amount}
onChange={(e) => setPart(i, { amount: e.target.value })}
/>
{parts.length > 2 && (
<button
type="button"
className="btn btn-ghost"
onClick={() => setParts((prev) => prev.filter((_, j) => j !== i))}
>
Quitar
</button>
)}
</div>
))}
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => setParts((prev) => [...prev, { customerId: "", customerName: "", amount: "" }])}
>
Agregar cliente
</button>
<button type="button" className="btn btn-ghost" onClick={evenly}>
Partes iguales
</button>
<span className={balanced ? "page-sub" : "state-error"}>
Suma {formatMoney(sum, "MXN")}
{!balanced && ` — diferencia ${formatMoney(total - sum, "MXN")}`}
</span>
<button
type="button"
className="btn btn-primary"
disabled={busy || !balanced || !complete}
onClick={() =>
save(parts.map((p) => ({ customerId: p.customerId, amount: Number(p.amount) })))
}
>
Guardar división
</button>
<button
type="button"
className="btn btn-outline"
disabled={busy}
onClick={() => (doc.splits.length ? save([]) : setParts([]))}
>
Quitar división
</button>
</div>
{err && <div className="state-box state-error">{err}</div>}
</div>
);
}
+25 -4
View File
@@ -31,6 +31,7 @@ import type {
UpdateBankAccountInput, UpdateBankAccountInput,
ResolveOutstandingInput, ResolveOutstandingInput,
ReviewDocumentInput, ReviewDocumentInput,
SplitPartInput,
StatementBatch, StatementBatch,
StatementBatchDetail, StatementBatchDetail,
StatementDocument, StatementDocument,
@@ -1380,17 +1381,26 @@ export function listStatementDocuments(
} }
/** Multi-file upload — one batch is usually several multi-page scans. */ /** Multi-file upload — one batch is usually several multi-page scans. */
export async function uploadStatementBatch( export function uploadStatementBatch(
files: File[], files: File[],
serviceKind: ServiceKind, serviceKind: ServiceKind,
label?: string, label?: string,
): Promise<StatementBatch> { ): Promise<StatementBatch> {
const body = new FormData();
for (const f of files) body.append("files", f, f.name);
const qs = new URLSearchParams({ serviceKind }); const qs = new URLSearchParams({ serviceKind });
if (label) qs.set("label", label); if (label) qs.set("label", label);
return postStatementFiles(`/statements/batches?${qs}`, files);
}
const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, { /** More scans for a batch kept open for one check. */
export function addStatementFiles(batchId: string, files: File[]): Promise<StatementBatch> {
return postStatementFiles(`/statements/batches/${batchId}/files`, files);
}
async function postStatementFiles(path: string, files: File[]): Promise<StatementBatch> {
const body = new FormData();
for (const f of files) body.append("files", f, f.name);
const res = await fetch(`${API_ORIGIN}${path}`, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
body, body,
@@ -1418,6 +1428,17 @@ export function reviewStatementDocument(
}); });
} }
/** Split a receipt across customers; an empty list undoes the split. */
export function setStatementSplits(
id: string,
parts: SplitPartInput[],
): Promise<StatementDocument> {
return apiFetch(`/statements/documents/${id}/splits`, {
method: "PUT",
body: JSON.stringify({ parts }),
});
}
export function rejectStatementDocument(id: string): Promise<StatementDocument> { export function rejectStatementDocument(id: string): Promise<StatementDocument> {
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" }); return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
} }
+21
View File
@@ -1415,6 +1415,8 @@ export interface StatementBatchDetail extends StatementBatch {
byStatus: Partial<Record<StatementDocumentStatus, number>>; byStatus: Partial<Record<StatementDocumentStatus, number>>;
/** Sum of the amounts still awaiting posting. */ /** Sum of the amounts still awaiting posting. */
pendingTotal: string; pendingTotal: string;
/** Check the batch was last posted against, to prefill the next confirm. */
lastCheckNumber: string | null;
} }
export interface StatementDocument { export interface StatementDocument {
@@ -1438,6 +1440,22 @@ export interface StatementDocument {
property: { id: string; addressLine1: string | null }; property: { id: string; addressLine1: string | null };
} | null; } | null;
postedTransactionId: string | null; postedTransactionId: string | null;
/** Sin fondos for this receipt alone. */
outstanding: boolean;
/** Empty unless the receipt is shared by several customers. */
splits: StatementSplit[];
}
export interface StatementSplit {
id: string;
sequence: number;
amount: string;
customer: { id: string; name: string };
}
export interface SplitPartInput {
customerId: string;
amount: number;
} }
export interface ReviewDocumentInput { export interface ReviewDocumentInput {
@@ -1448,6 +1466,7 @@ export interface ReviewDocumentInput {
matchedPropertyServiceId?: string; matchedPropertyServiceId?: string;
matchedCustomerId?: string; matchedCustomerId?: string;
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED"; status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
outstanding?: boolean;
} }
/** Check-level fields shared by every line posted from a batch. */ /** Check-level fields shared by every line posted from a batch. */
@@ -1463,6 +1482,8 @@ export interface ConfirmBatchInput {
export interface ConfirmBatchResult { export interface ConfirmBatchResult {
posted: number; posted: number;
total: string; total: string;
/** How many of the posted rows went in sin fondos. */
outstanding: number;
checkNumber: string; checkNumber: string;
} }
@@ -0,0 +1,30 @@
-- Two asks from the 2026-10-08 meeting, both on the OCR receipt queue:
-- * sin fondos per receipt, not only per check (`outstanding`);
-- * one receipt paid by several customers (411 + 523 sharing a water bill),
-- posted as one charge per part.
-- AlterTable
ALTER TABLE `statement_documents` ADD COLUMN `outstanding` BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE `statement_document_splits` (
`id` VARCHAR(191) NOT NULL,
`documentId` VARCHAR(191) NOT NULL,
`sequence` INTEGER NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`amount` DECIMAL(12, 2) NOT NULL,
`postedTransactionId` VARCHAR(191) NULL,
UNIQUE INDEX `statement_document_splits_postedTransactionId_key`(`postedTransactionId`),
UNIQUE INDEX `statement_document_splits_documentId_sequence_key`(`documentId`, `sequence`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_documentId_fkey` FOREIGN KEY (`documentId`) REFERENCES `statement_documents`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_postedTransactionId_fkey` FOREIGN KEY (`postedTransactionId`) REFERENCES `transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+33 -2
View File
@@ -130,7 +130,8 @@ model Customer {
transactions Transaction[] transactions Transaction[]
statementDocuments StatementDocument[] statementDocuments StatementDocument[]
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer") statementSplits StatementDocumentSplit[]
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
emailNotificationLogs EmailNotificationLog[] emailNotificationLogs EmailNotificationLog[]
accountStatusHistory AccountStatusHistory[] accountStatusHistory AccountStatusHistory[]
@@ -730,6 +731,26 @@ model StatementBatch {
} }
/// One statement — one customer, one period — after splitting the batch. /// One statement — one customer, one period — after splitting the batch.
/// One customer's share of a split receipt — a water bill two neighbours
/// share, an HOA fee spread across a building. Free amounts, no stored
/// percentages: the office splits unevenly as often as not.
model StatementDocumentSplit {
id String @id @default(uuid())
documentId String
document StatementDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
/// 1-based; also the `#n` in the posted row's captureRef.
sequence Int
customerId String
customer Customer @relation(fields: [customerId], references: [id])
amount Decimal @db.Decimal(12, 2)
postedTransactionId String? @unique
postedTransaction Transaction? @relation("StatementDocumentSplitTransaction", fields: [postedTransactionId], references: [id])
@@unique([documentId, sequence])
@@map("statement_document_splits")
}
model StatementDocument { model StatementDocument {
id String @id @default(uuid()) id String @id @default(uuid())
batchId String batchId String
@@ -775,6 +796,15 @@ model StatementDocument {
postedTransactionId String? @unique postedTransactionId String? @unique
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id]) postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
/// Sin fondos for this receipt alone: posted as outstanding even when the
/// rest of the check is funded.
outstanding Boolean @default(false)
/// When set, the receipt is paid for by several customers and posts one
/// charge per part instead of one to `matchedCustomer`. The parts always sum
/// to `extractedAmount`.
splits StatementDocumentSplit[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([batchId, pageNumber]) @@unique([batchId, pageNumber])
@@ -853,7 +883,8 @@ model Transaction {
/// Set only on OCR-posted rows — the statement page this came from. /// Set only on OCR-posted rows — the statement page this came from.
statementDocument StatementDocument? statementDocument StatementDocument?
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction") statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
@@unique([legacySourceDb, legacySourceTable, legacyId]) @@unique([legacySourceDb, legacySourceTable, legacyId])
@@index([customerId, transactionDate]) @@index([customerId, transactionDate])