const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching"); const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("../../../src/services/paymentReceiptManualReview"); const { syntheticDocumentEvidence } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs"); const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") }; function reviewFixture() { return { reviewer: { id: "reviewer", roleCodes: ["ceo"] }, hasReviewPermission: true, makerId: "maker", proposedBy: "operator", participants: [{ ownerId: "owner", submitterId: "submitter" }] }; } describe("independent manual receipt review identity", () => { test("independent business reviewer is eligible", () => { expect(() => assertIndependentReceiptReviewer(reviewFixture())).not.toThrow(); }); test.each(["maker", "operator", "owner", "submitter"])("cannot review as %s", (id) => { const input = reviewFixture(); input.reviewer.id = id; expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_conflict"); }); test.each([["admin"], ["finance"], ["*"]])("permission does not substitute for business role %j", (role) => { const input = reviewFixture(); input.reviewer.roleCodes = [role]; expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_permission_required"); }); test("business role does not substitute for review permission", () => { expect(() => assertIndependentReceiptReviewer({ ...reviewFixture(), hasReviewPermission: false })) .toThrow("receipt_review_permission_required"); }); test.each(["makerId", "proposedBy", "participants"])("missing %s fails closed", (key) => { const input = reviewFixture(); delete input[key]; expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_unresolved"); }); test("every source participant is checked, not only the first reimbursement", () => { const input = reviewFixture(); input.participants.push({ ownerId: "reviewer", submitterId: "another" }); expect(() => assertIndependentReceiptReviewer(input)).toThrow("receipt_review_identity_conflict"); }); }); function fixture() { const fields = Object.entries({ 小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222", 收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-MANUAL", }).map(([Name, Value]) => ({ Name, Value })); return { original: { status: "recognized", pagesExpected: 1, pagesProcessed: 1, documentPages: [{ pageNumber: 1, document: syntheticDocumentEvidence() }], pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), document: syntheticDocumentEvidence() }] }, pages: [{ pageNumber: 1, itemId: "item-2", fields }], candidates: ["item-1", "item-2"].map((id) => ({ id, amount: "10.00", currency: "CNY", payerAccount: "000011112222", payeeAccount: "000033334444", })), }; } describe("manual receipt proposal is evidence preparation, not authorization", () => { test("booked debit uses actual bank booking facts without inventing success wording", () => { const input = fixture(); input.original.pages[0].document = syntheticDocumentEvidence({ status: "booked" }); input.original.pages[0].transactionStatus = null; input.original.pages[0].transactionStatusRaw = null; input.pages[0].fields.find((field) => field.Name === "单据状态").Value = ""; input.pages[0].paymentProof = { basis: "booked_debit", bookedOn: "2026-10-01", originalChecked: true, note: "合成正式回单以公司付款账户记载借方支出" }; const result = buildManualReceiptProposal(config, input); expect(result.requiresIndependentReview).toBe(true); expect(result.recognition.pages[0]).toMatchObject({ transactionStatus: "success", transactionStatusRaw: null, document: { source: "manual", proof: { basis: "booked_debit", bookedOn: "2026-10-01" } } }); expect(result.changedFields[0].fields).toContain("paymentProof"); }); test.each([{ kind: "non_final" }, { direction: "credit" }, { issues: ["document_transaction_pending"] }, { issues: ["document_multiple_receipts"] }])("manual declarations cannot overwrite documentary restrictions %j", (change) => { const input = fixture(); input.original.pages[0].document = { ...syntheticDocumentEvidence(), ...change }; input.pages[0].paymentProof = { basis: "explicit_success", originalChecked: true, note: "合成强行确认不能覆盖已知限制" }; expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_not_final"); }); test("unknown original needs an explicit, reviewed final-document declaration", () => { const input = fixture(); input.original.pages[0].document = { ...syntheticDocumentEvidence(), kind: "unknown", explicitSuccess: false }; expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_proof_required"); input.pages[0].paymentProof = { basis: "explicit_success", originalChecked: true, note: "合成原件为正式付款回单,已检查全部页及说明" }; expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true); }); test("legacy records require local reinspection before a manual proposal", () => { const input = fixture(); delete input.original.documentPages; delete input.original.pages[0].document; expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_document_check_required"); }); test("explicit same-amount association needs no field reentry review", () => { const input = fixture(); const before = JSON.stringify(input); const result = buildManualReceiptProposal(config, input); expect(result.requiresIndependentReview).toBe(false); expect(result.associations).toEqual([{ pageNumber: 1, itemId: "item-2" }]); expect(result.changedFields).toEqual([{ pageNumber: 1, fields: [] }]); expect(result.bankVerified).toBe(false); expect(JSON.stringify(input)).toBe(before); }); test.each(["amount", "currency", "payerAccount", "payeeAccount", "reference", "transactionStatus"])( "corrected %s cannot masquerade as an automatic match", (field) => { const input = fixture(); input.original.pages[0][field] = null; const result = buildManualReceiptProposal(config, input); expect(result.requiresIndependentReview).toBe(true); expect(result.changedFields[0].fields).toContain(field); expect(input.original.pages[0][field]).toBeNull(); }, ); test("OCR failure requires independent review even with fully matching manual fields", () => { const input = fixture(); input.original = { status: "ocr_failed", pagesExpected: 1, pagesProcessed: 0, pages: [], documentPages: [{ pageNumber: 1, document: syntheticDocumentEvidence() }] }; expect(buildManualReceiptProposal(config, input).requiresIndependentReview).toBe(true); }); test.each(["failed", "pending"])("cannot rewrite original %s to success", (status) => { const input = fixture(); input.original.pages[0].transactionStatus = status; expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_not_successful"); }); test.each(["交易失败", "处理中"])("conflicting status aliases retain %s veto", (status) => { const input = fixture(); input.original.pages[0] = { pageNumber: 1, ...normalizeBankSlip([ ...input.pages[0].fields, { Name: "交易状态", Value: status }, ]) }; expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_not_successful"); }); test.each([["小写金额", "11.00"], ["币别", "USD"], ["付款人账号", "111122223333"], ["收款人账号", "222233334444"], ["单据状态", "已受理"], ["凭证号", ""]])( "%s mismatch cannot be overridden by item selection or reason", (name, value) => { const input = fixture(); input.pages[0].fields.find((field) => field.Name === name).Value = value; input.reason = "测试理由不能免除核验"; expect(() => buildManualReceiptProposal(config, input)).toThrow(name === "单据状态" ? "receipt_document_proof_required" : "receipt_manual_match_required"); }, ); test("association cannot select another batch's item", () => { const input = fixture(); input.pages[0].itemId = "outside"; expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_item_unavailable"); }); test("missing PDF page cannot be waived", () => { const input = fixture(); input.original.pagesExpected = 2; expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_pages_unresolved"); }); test("unknown original page count cannot be guessed from manual input", () => { const input = fixture(); input.original.pagesExpected = null; expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_pages_unresolved"); }); test("duplicate transaction on two pages cannot pay two items", () => { const input = fixture(); input.original.pagesExpected = 2; input.original.documentPages.push({ pageNumber: 2, document: syntheticDocumentEvidence() }); input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1" }); expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused"); }); test("two transactions cannot be assigned to one reimbursement", () => { const input = fixture(); input.original.pagesExpected = 2; input.original.documentPages.push({ pageNumber: 2, document: syntheticDocumentEvidence() }); input.pages.push({ ...input.pages[0], pageNumber: 2, fields: input.pages[0].fields.map((field) => field.Name === "凭证号" ? { ...field, Value: "SYN-SECOND" } : field) }); expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_multiple_transactions"); }); test.each(["凭证号", "付款人账号"])("cannot hide original duplicate by correcting %s", (name) => { const input = fixture(); input.original.pagesExpected = 2; input.original.pagesProcessed = 2; input.original.pages.push({ ...input.original.pages[0], pageNumber: 2 }); input.pages.push({ ...input.pages[0], pageNumber: 2, itemId: "item-1", fields: input.pages[0].fields.map((field) => field.Name === name ? { ...field, Value: name === "凭证号" ? "SYN-ALTERED" : "999900001111" } : field) }); expect(() => buildManualReceiptProposal(config, input)).toThrow("receipt_transaction_reused"); }); test("retains original fingerprint for persistent cross-batch reuse checks", () => { const input = fixture(); const original = buildManualReceiptProposal(config, input).originalFingerprints; input.pages[0].fields.find((field) => field.Name === "凭证号").Value = "SYN-CORRECTED"; const changed = buildManualReceiptProposal(config, input); expect(changed.originalFingerprints).toEqual(original); expect(changed.requiresIndependentReview).toBe(true); expect(original).toHaveLength(1); expect(original[0]).toMatch(/^[a-f0-9]{64}$/); }); });