feat(recibos): split a receipt, sin fondos per receipt, keep a batch open
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:
1 parent
f655fcbb82
commit
fd5df240bc
9 files changed
+753
-46
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
@@ -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;
|
||||||
|
|
||||||
@@ -130,6 +130,7 @@ model Customer {
|
|||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
|
|
||||||
statementDocuments StatementDocument[]
|
statementDocuments StatementDocument[]
|
||||||
|
statementSplits StatementDocumentSplit[]
|
||||||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||||||
|
|
||||||
emailNotificationLogs EmailNotificationLog[]
|
emailNotificationLogs EmailNotificationLog[]
|
||||||
@@ -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,6 +883,7 @@ 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?
|
||||||
|
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
|
||||||
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
|
||||||
|
|
||||||
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
||||||
|
|||||||
Reference in new issue
Block a user