const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching"); const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("../../../src/services/paymentReceiptManualReview"); 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, pages: [{ pageNumber: 1, ...normalizeBankSlip(fields) }] }, 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("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: [] }; 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("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.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.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}$/); }); });