const incoming = require("../../../src/services/loanIncomingReceipt"); const { syntheticReceiptText, syntheticBankReceiptPdf, syntheticCcbIncomingPdf } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs"); const config = { fieldEncryptionKey: Buffer.alloc(32, 9).toString("base64") }; const credit = (options = {}) => syntheticReceiptText({ direction: "credit", status: "booked", amount: "50,000.00", reference: "SYN-IN-0001", bookedOn: "2025-03-14", extraLines: ["付款人名称 合成出借人", "收款人名称 合成公司"], ...options }); const parse = (...texts) => incoming.finalizeResult(incoming.parseIncomingTexts(texts)); describe("incoming receipt field extraction", () => { test("reads amount, booking date, reference and parties from labelled lines", () => { const result = parse(credit()); expect(result.blocks).toEqual([]); expect(result.direction).toBe("credit"); expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001", referenceKind: "voucher", currency: "CNY", payerName: "合成出借人", payeeName: "合成公司" }); expect(result.sources).toMatchObject({ amount: "pdf_text", receivedDate: "pdf_text", reference: "pdf_text" }); expect(result.fieldIssues).toEqual({}); }); test("a stable serial number is preferred over a voucher number, kept as text", () => { const result = parse(credit({ extraLines: ["交易流水号 0012345678"] })); expect(result.fields.reference).toBe("0012345678"); expect(result.fields.referenceKind).toBe("serial"); }); test("conflicting dates are left empty rather than choosing one", () => { const result = parse(credit({ extraLines: ["交易日期 2025-03-15"] })); expect(result.fields.receivedDate).toBeUndefined(); expect(result.fieldIssues.receivedDate).toBe("ambiguous"); }); test("print stamps and capital amounts are never read as business values", () => { const result = parse(credit({ extraLines: ["打印日期 2026-10-09", "大写金额 伍万元整"] })); expect(result.fields.receivedDate).toBe("2025-03-14"); expect(result.fields.amount).toBe("5000000"); }); test("pages repeating one transaction are merged; different transactions are rejected", () => { expect(parse(credit(), credit()).blocks).toEqual([]); const result = parse(credit(), credit({ amount: "20.00", reference: "SYN-IN-0002" })); expect(result.blocks).toContain("multiple_transactions"); expect(result.fields.amount).toBeUndefined(); }); test.each([ [{ final: false }, "non_final"], [{ status: "failed" }, "transaction_failed"], [{ status: "pending" }, "transaction_pending"], [{ currency: "USD" }, "non_cny"], ])("restricted document %j is blocked", (options, block) => { expect(parse(credit(options)).blocks).toContain(block); }); test("two receipts on one page are blocked", () => { expect(parse(credit() + "\n" + credit({ amount: "1.00" })).blocks).toContain("multiple_transactions"); }); test("missing required fields are reported, not invented", () => { const result = parse("中国建设银行单位客户专用回单\n贷\n摘要 借款"); expect(result.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" }); expect(result.fields).toEqual({}); }); }); describe("review counterexamples (2026-10-10)", () => { test("same amount with different voucher numbers is several transactions, not a missing reference", () => { const result = parse(credit({ reference: "SYNTHETIC-VOUCHER-A" }), credit({ reference: "SYNTHETIC-VOUCHER-B" })); expect(result.blocks).toContain("multiple_transactions"); }); test("a provider reporting different serial numbers with one amount is several transactions", () => { const result = incoming.parseIncomingTexts([""]); incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-A" }], [{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-B" }]]); expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions"); }); test("one transaction repeated across pages remains allowed", () => { expect(parse(credit(), credit()).blocks).toEqual([]); }); test("a provider currency never clears a locally known one", () => { const local = incoming.parseIncomingTexts([credit()]); incoming.mergeProviderFields(local, [[{ Name: "币别", Value: "美元" }, { Name: "币种", Value: "USD" }]]); expect(incoming.finalizeResult(local).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"])); expect(local.fields.currency).toBe("CNY"); }); test("a locally known foreign currency blocks before any provider call", async () => { const createClient = vi.fn(); const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked", currency: "USD", bookedOn: "not-a-date" }), { config, createClient }); expect(createClient).not.toHaveBeenCalled(); expect(result.blocks).toContain("non_cny"); }); test("conflicting currencies within the document are a known contradiction", () => { expect(parse(credit({ extraLines: ["币种 USD"] })).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"])); }); test.each([["1,23.45"], ["1234.567"], ["50000.00USD"], ["1.2.3"], ["¥"]])("malformed amount %s is invalid, never truncated", (amount) => { const result = parse(credit({ amount })); expect(result.fields.amount).toBeUndefined(); expect(result.fieldIssues.amount).toBe("invalid"); }); test.each([["¥1,234.50元", "123450"], ["人民币1234.5", "123450"], ["0.01", "1"], ["999,999,999,999.99", "99999999999999"]])( "well-formed amount %s is read whole", (amount, minor) => { expect(parse(credit({ amount })).fields.amount).toBe(minor); }); test("the provider deadline stops later pages, not just the wait", async () => { let calls = 0; const BankSlipOCR = vi.fn(() => { calls += 1; return new Promise((resolve) => setTimeout(() => resolve({ BankSlipInfos: [] }), 30)); }); const pass = await incoming.providerPass(config, Buffer.from("%PDF-synthetic"), 3, () => ({ BankSlipOCR }), { totalMs: 10 }); expect(pass.status).toBe("failed"); const atReturn = calls; await new Promise((resolve) => setTimeout(resolve, 150)); expect(calls).toBe(atReturn); expect(calls).toBe(1); }); }); describe("CCB table layout (synthetic replica of the real borrow-in receipt)", () => { const inspect = async (options) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient: vi.fn() }); test("reads every field from out-of-order table items without any provider call", async () => { const createClient = vi.fn(); const result = await incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(), { config, createClient }); expect(createClient).not.toHaveBeenCalled(); expect(result).toMatchObject({ ocr: "not_needed", blocks: [], direction: "credit", documentKind: "bank_receipt", fieldIssues: {} }); expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "123456789AB12CDEFG3", referenceKind: "serial", currency: "CNY", payerName: "合成甲", payerAccount: "6222000011112222", payeeName: "合成公司", payeeAccount: "33050161000000000001" }); }); test("a vertical copy note level with the amount row neither pollutes the amount nor becomes a column header", async () => { const result = await inspect({ copyNote: "(收款人回单)(第一联)" }); expect(result.fields.amount).toBe("5000000"); expect(result.fields.payeeName).toBe("合成公司"); }); test("baseline jitter within one row still reads the row left to right", async () => { expect((await inspect({ dateDy: 1 })).fields.receivedDate).toBe("2025-03-14"); expect((await inspect({ dateDy: -2 })).fields.receivedDate).toBe("2025-03-14"); }); test("two different unlabelled header dates are ambiguous", async () => { const result = await inspect({ extraItems: [["2025年03月15日", 150, 785]] }); expect(result.fields.receivedDate).toBeUndefined(); expect(result.fieldIssues.receivedDate).toBe("ambiguous"); }); test("an impossible header date is invalid, not shifted", async () => { const result = await inspect({ date: ["2025", "02", "30"] }); expect(result.fieldIssues.receivedDate).toBe("invalid"); }); test("parties are only read when the payer column is left of the payee column", async () => { const swapped = await inspect({ headers: ["收款人", "付款人"] }); expect(swapped.fields.payerName).toBeUndefined(); expect(swapped.fields.payeeName).toBeUndefined(); expect(swapped.fields.amount).toBe("5000000"); }); test("a malformed lower-case amount is invalid even in the table layout", async () => { const result = await inspect({ amount: "50,00.00" }); expect(result.fields.amount).toBeUndefined(); expect(result.fieldIssues.amount).toBe("invalid"); }); test("a non-final notice still blocks the table layout", async () => { const result = await inspect({ extraItems: [["本回执不作为收、付款方交易的最终依据。", 25, 575]] }); expect(result.blocks).toContain("non_final"); }); }); describe("second review counterexamples (2026-10-10)", () => { const inspect = async (options, createClient = vi.fn()) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient }); test.each([[["交易失败", 140, 650], ["交易状态", 37, 650]], [["处理中", 140, 650], ["交易状态", 37, 650]], [["交易的最终依据。", 140, 575], ["本回执不作为收、付款方", 25, 575]]])( "restrictions stored value-first are still read in reading order: %j", async (...extraItems) => { const result = await inspect({ extraItems }); expect(result.blocks.some((b) => ["transaction_failed", "transaction_pending", "non_final"].includes(b))).toBe(true); }); test("a labelled print date on the header row is never the booking date", async () => { const result = await inspect({ date: null, extraItems: [["打印日期:2026年10月08日", 140, 785]] }); expect(result.fields.receivedDate).toBeUndefined(); expect(result.fieldIssues.receivedDate).toBe("missing"); }); test("a labelled print date beside the unlabelled booking date leaves the booking date", async () => { expect((await inspect({ extraItems: [["打印日期:2026年10月08日", 450, 785]] })).fields.receivedDate).toBe("2025-03-14"); }); test.each([["2025-03-149"], ["2025-03-14/2025-04-14"]])("labelled date %s must be one whole date", (date) => { expect(parse(credit({ bookedOn: date })).fieldIssues.receivedDate).toBe("invalid"); }); async function readablePlusScanned() { const { PDFDocument } = require("pdf-lib"); const doc = await PDFDocument.create(); const source = await PDFDocument.load(await syntheticCcbIncomingPdf()); doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage(); return Buffer.from(await doc.save()); } test("a scanned page of another transaction after a readable page is read and blocked", async () => { const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "9.00" }, { Name: "流水号", Value: "OTHER-2" }] })); const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) }); expect(BankSlipOCR).toHaveBeenCalledTimes(1); expect(BankSlipOCR.mock.calls[0][0].PdfPageNumber).toBe(2); expect(result.blocks).toContain("multiple_transactions"); }); test("an unverifiable extra page blocks when no provider is available", async () => { const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => null }); expect(result.blocks).toContain("pages_unverified"); }); test("a scanned repeat of the same transaction is accepted", async () => { const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" }, { Name: "流水号", Value: "123456789AB12CDEFG3" }] })); const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) }); expect(result.blocks).toEqual([]); }); test.each([ ["empty provider page", []], ["unparseable serial", [{ Name: "流水号", Value: "?" }]], ["unparseable amount", [{ Name: "金额", Value: "无法识别" }]], ["same amount without any identifier", [{ Name: "金额", Value: "50,000.00" }]], ])("a scanned page is not verified by %s", async (_label, fields) => { const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: fields })); const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) }); expect(result.blocks).toContain("pages_unverified"); }); test("a readable page with only the same amount and no identifier is not verified", async () => { const { PDFDocument } = require("pdf-lib"); const doc = await PDFDocument.create(); for (const options of [{}, { serial: null }]) { const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options)); doc.addPage((await doc.copyPages(source, [0]))[0]); } const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => null }); expect(result.blocks).toContain("pages_unverified"); }); test("a different secondary voucher number is another transaction even with the same serial", async () => { const { PDFDocument } = require("pdf-lib"); const doc = await PDFDocument.create(); const source = await PDFDocument.load(await syntheticCcbIncomingPdf({ extraItems: [["凭证号 LOCAL-VOUCHER", 293, 702]] })); doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage(); const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" }, { Name: "流水号", Value: "123456789AB12CDEFG3" }, { Name: "凭证号", Value: "OTHER-VOUCHER" }] })); const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) }); expect(result.fields.referenceKind).toBe("serial"); expect(result.blocks).toContain("multiple_transactions"); }); async function twoCcbPages(first, second) { const { PDFDocument } = require("pdf-lib"); const doc = await PDFDocument.create(); for (const options of [first, second]) { const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options)); doc.addPage((await doc.copyPages(source, [0]))[0]); } return Buffer.from(await doc.save()); } const voucherOnly = (voucher) => ({ serial: null, extraItems: [[`凭证号 ${voucher}`, 293, 702]] }); test("a serial on one page and an unrelated voucher on the other are not linked", async () => { const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A" }, voucherOnly("VOUCHER-B")), { config, createClient: () => null }); expect(result.blocks).toContain("pages_unverified"); }); test("the same text under different identifier kinds is not a link", async () => { const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SAME-0001" }, voucherOnly("SAME-0001")), { config, createClient: () => null }); expect(result.blocks).toContain("pages_unverified"); }); test("a page carrying both numbers links a later page that repeats one of them", async () => { const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A", extraItems: [["凭证号 VOUCHER-B", 293, 702]] }, voucherOnly("VOUCHER-B")), { config, createClient: () => null }); expect(result.blocks).toEqual([]); }); test.each([["SERIAL-A/001"], ["SERIAL-A?"], ["A".repeat(70)]])("identifier %s is invalid as a whole, never cut to a prefix", (reference) => { const result = parse(credit({ reference })); expect(result.fields.reference).toBeUndefined(); expect(result.fieldIssues.reference).toBe("invalid"); }); test("same prefix with different suffixes cannot collapse into one identifier", async () => { const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIALA/001" }, { serial: "SERIALA/002" }), { config, createClient: () => null }); expect(result.blocks).toContain("pages_unverified"); expect(result.fields.reference).toBeUndefined(); }); test("a provider identifier with an invalid suffix does not verify a page", async () => { const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "流水号", Value: "123456789AB12CDEFG3?" }] })); const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) }); expect(result.blocks).toContain("pages_unverified"); }); test("a provider transaction different from the text layer is several transactions", () => { const result = incoming.parseIncomingTexts([credit({ reference: "LOCAL-A", amount: "10.00" })]); incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "20.00" }, { Name: "凭证号", Value: "OCR-B" }]]); expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions"); }); }); describe("bounded provider fallback", () => { const providerFields = [{ Name: "金额", Value: "1,234.50" }, { Name: "记账日期", Value: "2025年1月2日" }, { Name: "流水号", Value: "OCR-77" }, { Name: "币别", Value: "人民币" }]; test("a readable PDF with all required fields makes no provider call", async () => { const createClient = vi.fn(); const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked", reference: "SYN-PDF-1", bookedOn: "2025-02-03", amount: "88.00" }), { config, createClient }); expect(createClient).not.toHaveBeenCalled(); expect(result.ocr).toBe("not_needed"); expect(result.fields).toMatchObject({ amount: "8800", receivedDate: "2025-02-03", reference: "SYN-PDF-1" }); }); test("an unreadable PDF uses each page at most once and only fills gaps", async () => { const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: providerFields })); const { PDFDocument } = require("pdf-lib"); const doc = await PDFDocument.create(); doc.addPage(); doc.addPage(); const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) }); expect(BankSlipOCR).toHaveBeenCalledTimes(2); expect(result.ocr).toBe("used"); expect(result.fields).toMatchObject({ amount: "123450", receivedDate: "2025-01-02", reference: "OCR-77" }); expect(result.sources.amount).toBe("ocr"); }); test("provider unavailable or failing leaves manual completion without retries", async () => { const { PDFDocument } = require("pdf-lib"); const doc = await PDFDocument.create(); doc.addPage(); const pdf = Buffer.from(await doc.save()); expect((await incoming.inspectIncomingPdf(pdf, { config, createClient: () => null })).ocr).toBe("unavailable"); const BankSlipOCR = vi.fn(async () => { throw new Error("quota"); }); const failed = await incoming.inspectIncomingPdf(pdf, { config, createClient: () => ({ BankSlipOCR }) }); expect(BankSlipOCR).toHaveBeenCalledTimes(1); expect(failed.ocr).toBe("failed"); expect(failed.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" }); }); test("provider text can add restrictions but never overwrite local values", () => { const result = incoming.parseIncomingTexts([credit()]); incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "1.00" }, { Name: "备注", Value: "本回执不作为收付款方交易的最终依据" }]]); incoming.finalizeResult(result); expect(result.blocks).toContain("non_final"); expect(result.fields.amount).toBeUndefined(); expect(result.fieldIssues.amount).toBe("ambiguous"); }); test.each(["not a pdf", ""])("rejects non-PDF input %j", async (text) => { await expect(incoming.inspectIncomingPdf(Buffer.from(text), { config })).rejects.toMatchObject({ details: { reasonCode: "loan.incoming_receipt_invalid" } }); }); }); describe("sealed prefill context", () => { const base = { v: 1, purpose: incoming.PURPOSE, parserVersion: incoming.PARSER_VERSION, actorId: "actor-1", fileSha256: "a".repeat(64), issuedAt: 1000, expiresAt: 1000 + incoming.CONTEXT_TTL_MS, fields: {}, blocks: [] }; test("opens only for the same actor, file and lifetime", () => { const token = incoming.sealContext(config, base); expect(incoming.openContext(config, token, { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 }).actorId).toBe("actor-1"); for (const binding of [{ actorId: "actor-2", fileSha256: "a".repeat(64), now: 2000 }, { actorId: "actor-1", fileSha256: "b".repeat(64), now: 2000 }, { actorId: "actor-1", fileSha256: "a".repeat(64), now: base.expiresAt + 1 }]) { expect(() => incoming.openContext(config, token, binding)).toThrow(); } }); test("tampered, foreign-purpose or plain tokens are rejected", () => { const token = incoming.sealContext(config, base); const binding = { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 }; expect(() => incoming.openContext(config, `${token.slice(0, -4)}AAAA`, binding)).toThrow(); expect(() => incoming.openContext(config, incoming.sealContext(config, { ...base, purpose: "other" }), binding)).toThrow(); expect(() => incoming.openContext(config, Buffer.from(JSON.stringify(base)).toString("base64"), binding)).toThrow(); }); }); describe("save guards", () => { const lender = { legal_entity: { name: "合成公司" } }; test("company payment out or money to another entity is not a borrow-in", () => { expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成公司" } }, lender)).toThrow(); expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "另一公司" } }, lender)).toThrow(); expect(() => incoming.assertSavable({ blocks: ["non_final"], direction: "credit", fields: {} }, lender)).toThrow(); }); test("a credit to the company, or a lender's own debit slip, can be saved", () => { expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "合成公司" } }, lender)).not.toThrow(); expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成出借人" } }, lender)).not.toThrow(); expect(() => incoming.assertSavable({ blocks: [], direction: null, fields: {} }, lender)).not.toThrow(); }); test("records corrected field names only", () => { const context = { fields: { amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001" } }; expect(incoming.corrections(context, { originalPrincipal: "50000.00", receivedDate: "2025-03-15", originReference: "SYN-IN-0001" })) .toEqual(["receivedDate"]); }); });