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
);
}
+/** 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([]);
+ 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 (
+
+ );
+}
+
function DocumentRow({
doc,
canReview,
@@ -411,6 +476,7 @@ function DocumentRow({
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
+ const [outstanding, setOutstanding] = useState(doc.outstanding);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
@@ -424,6 +490,7 @@ function DocumentRow({
accountRef: accountRef.trim() || undefined,
amount: amount ? Number(amount) : undefined,
matchedCustomerId: customerId || undefined,
+ outstanding,
status,
});
onChange();
@@ -456,9 +523,21 @@ function DocumentRow({
Página {doc.pageNumber}{" "}
{STATUS_LABEL[doc.status] ?? doc.status}{" "}
+ {doc.outstanding &&
Sin fondos}{" "}
{doc.provider &&
{doc.provider}}
- {doc.matchedCustomer ? (
+ {doc.splits.length > 0 ? (
+ <>
+ Dividido:{" "}
+ {doc.splits.map((s, i) => (
+
+ {i > 0 && " + "}
+ {s.customer.name}{" "}
+ {formatMoney(s.amount, "MXN")}
+
+ ))}
+ >
+ ) : doc.matchedCustomer ? (
{doc.matchedCustomer.name}
@@ -533,10 +612,18 @@ function DocumentRow({
}}
/>
+
)}
+ {canReview && !settled && (
+
+ )}
+
{err && {err}
}
)}
);
}
+
+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(() =>
+ 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(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) {
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
División del recibo ({formatMoney(total, "MXN")})
+ {parts.map((p, i) => (
+
+
+ setPart(i, { customerId: cid, customerName: name })}
+ />
+
+
setPart(i, { amount: e.target.value })}
+ />
+ {parts.length > 2 && (
+
+ )}
+
+ ))}
+
+
+
+
+ Suma {formatMoney(sum, "MXN")}
+ {!balanced && ` — diferencia ${formatMoney(total - sum, "MXN")}`}
+
+
+
+
+ {err &&
{err}
}
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 98179a2..507470b 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -31,6 +31,7 @@ import type {
UpdateBankAccountInput,
ResolveOutstandingInput,
ReviewDocumentInput,
+ SplitPartInput,
StatementBatch,
StatementBatchDetail,
StatementDocument,
@@ -1380,17 +1381,26 @@ export function listStatementDocuments(
}
/** Multi-file upload — one batch is usually several multi-page scans. */
-export async function uploadStatementBatch(
+export function uploadStatementBatch(
files: File[],
serviceKind: ServiceKind,
label?: string,
): Promise {
- const body = new FormData();
- for (const f of files) body.append("files", f, f.name);
const qs = new URLSearchParams({ serviceKind });
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 {
+ return postStatementFiles(`/statements/batches/${batchId}/files`, files);
+}
+
+async function postStatementFiles(path: string, files: File[]): Promise {
+ const body = new FormData();
+ for (const f of files) body.append("files", f, f.name);
+
+ const res = await fetch(`${API_ORIGIN}${path}`, {
method: "POST",
credentials: "include",
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 {
+ return apiFetch(`/statements/documents/${id}/splits`, {
+ method: "PUT",
+ body: JSON.stringify({ parts }),
+ });
+}
+
export function rejectStatementDocument(id: string): Promise {
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
}
diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts
index 626b886..c80cdd5 100644
--- a/apps/web/src/lib/types.ts
+++ b/apps/web/src/lib/types.ts
@@ -1415,6 +1415,8 @@ export interface StatementBatchDetail extends StatementBatch {
byStatus: Partial>;
/** Sum of the amounts still awaiting posting. */
pendingTotal: string;
+ /** Check the batch was last posted against, to prefill the next confirm. */
+ lastCheckNumber: string | null;
}
export interface StatementDocument {
@@ -1438,6 +1440,22 @@ export interface StatementDocument {
property: { id: string; addressLine1: string | null };
} | 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 {
@@ -1448,6 +1466,7 @@ export interface ReviewDocumentInput {
matchedPropertyServiceId?: string;
matchedCustomerId?: string;
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
+ outstanding?: boolean;
}
/** Check-level fields shared by every line posted from a batch. */
@@ -1463,6 +1482,8 @@ export interface ConfirmBatchInput {
export interface ConfirmBatchResult {
posted: number;
total: string;
+ /** How many of the posted rows went in sin fondos. */
+ outstanding: number;
checkNumber: string;
}
diff --git a/packages/database/prisma/migrations/20261013120000_statement_split_outstanding/migration.sql b/packages/database/prisma/migrations/20261013120000_statement_split_outstanding/migration.sql
new file mode 100644
index 0000000..c7017c8
--- /dev/null
+++ b/packages/database/prisma/migrations/20261013120000_statement_split_outstanding/migration.sql
@@ -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;
+
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 09a81d3..6fa0835 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -130,7 +130,8 @@ model Customer {
transactions Transaction[]
statementDocuments StatementDocument[]
- policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
+ statementSplits StatementDocumentSplit[]
+ policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
emailNotificationLogs EmailNotificationLog[]
accountStatusHistory AccountStatusHistory[]
@@ -730,6 +731,26 @@ model StatementBatch {
}
/// 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 {
id String @id @default(uuid())
batchId String
@@ -775,6 +796,15 @@ model StatementDocument {
postedTransactionId String? @unique
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())
@@unique([batchId, pageNumber])
@@ -853,7 +883,8 @@ model Transaction {
/// Set only on OCR-posted rows — the statement page this came from.
statementDocument StatementDocument?
- policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
+ statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
+ policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
@@unique([legacySourceDb, legacySourceTable, legacyId])
@@index([customerId, transactionDate])