const { encryptStringOrThrow, decryptStringOrThrow } = require("../../../src/services/crypto"); const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching"); const { buildVerificationData, claimVerifiedReceipts, transactionFingerprint, canRetryReceiptRecognition } = require("../../../src/services/paymentReceiptVerification"); const { syntheticDocumentEvidence } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs"); const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") }; function fixture() { const page = { pageNumber: 1, ...normalizeBankSlip(Object.entries({ 小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222", 收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-REF", }).map(([Name, Value]) => ({ Name, Value }))), document: syntheticDocumentEvidence() }; const batch = { id: "batch", entity_type: "reimbursement", payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, page.payerAccount) }, items: [{ id: "item", status: "pending", amount: "10.00", currency_code: "CNY", snapshot_json: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, page.payeeAccount) } } }], }; const attachment = { id: "attachment", payment_batch_id: batch.id, file_sha256: "a".repeat(64), governance_status: "approved", archived_at: null, receipt_ocr_version: 0 }; const recognition = { status: "recognized", pagesExpected: 1, pagesProcessed: 1, pages: [page] }; const data = buildVerificationData(config, batch, attachment, recognition); const verification = { ...data, id: "verification", attachment, matches: data.matches.create }; const tx = { $queryRaw: vi.fn().mockResolvedValue([{ id: attachment.id }]), paymentReceiptVerification: { findFirst: vi.fn().mockResolvedValue(verification) }, loanBankEventClaim: { count: vi.fn().mockResolvedValue(0) }, paymentReceiptClaim: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() }, paymentReceiptTransactionAlias: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() }, paymentReceiptManualProposal: { findMany: vi.fn().mockResolvedValue([]) }, }; return { batch, attachment, recognition, data, verification, tx }; } describe("receipt evidence and transaction claims", () => { test.each([null, { version: 0 }, { ...syntheticDocumentEvidence(), kind: "non_final" }, { ...syntheticDocumentEvidence(), direction: "credit" }, { ...syntheticDocumentEvidence(), explicitSuccess: false }])( "field matches alone cannot prove final payment (%j)", (document) => { const { batch, attachment, recognition } = fixture(); recognition.pages[0].document = document; const data = buildVerificationData(config, batch, attachment, recognition); expect(data.status).toBe("needs_review"); expect(data.matches).toBeUndefined(); }); test("final claim rebuild rejects an old matched record with no document basis", async () => { const { tx, batch, verification, recognition } = fixture(); delete recognition.pages[0].document; verification.normalized_encrypted = encryptStringOrThrow(config, JSON.stringify(recognition)); await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_match_changed" }); expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled(); }); test("bank transactions already used for supplements or returns cannot be claimed again", async () => { const { tx, batch } = fixture(); tx.loanBankEventClaim.count.mockResolvedValue(1); await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_transaction_reused" }); expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled(); }); test("mixed historical cash items do not require bank snapshots or bank receipt claims", async () => { const { batch, attachment, recognition, tx } = fixture(); batch.items.push({ id: "cash-item", status: "pending", amount: "10.00", currency_code: "CNY", payment_channel_override: "cash", snapshot_json: {} }); expect(buildVerificationData(config, batch, attachment, recognition).status).toBe("matched"); await claimVerifiedReceipts(tx, config, batch); expect(tx.paymentReceiptClaim.create).toHaveBeenCalledTimes(1); expect(tx.paymentReceiptClaim.create.mock.calls[0][0].data.item_id).toBe("item"); }); test.each([[899999, false], [900000, true], [900001, true]])("retry becomes available only at timeout %s", (age, allowed) => { const now = new Date("2026-09-14T00:00:00Z"); const attachment = { receipt_ocr_state: "processing", governance_status: "approved", receipt_ocr_started_at: new Date(now.getTime() - age) }; expect(canRetryReceiptRecognition(attachment, now)).toBe(allowed); expect(canRetryReceiptRecognition({ ...attachment, archived_at: now }, now)).toBe(false); }); test("stores only encrypted normalized evidence and keyed transaction identity", () => { const { data, recognition } = fixture(); expect(JSON.stringify(data)).not.toContain("000011112222"); expect(JSON.stringify(data)).not.toContain("SYN-REF"); expect(JSON.parse(decryptStringOrThrow(config, data.normalized_encrypted))).toEqual(recognition); expect(data.matches.create[0].fingerprint).toMatch(/^[a-f0-9]{64}$/); }); test("transaction identity is independent of file, amount and payee", () => { const { recognition } = fixture(); const page = recognition.pages[0]; expect(transactionFingerprint(config, page)).toEqual(transactionFingerprint(config, { ...page, amount: "2000", payeeAccount: "999999999999" })); expect(transactionFingerprint(config, page)).not.toEqual(transactionFingerprint(config, { ...page, payerAccount: "999999999999" })); }); test("does not persist extra provider response fields even encrypted", () => { const { batch, attachment, recognition } = fixture(); const data = buildVerificationData(config, batch, attachment, { ...recognition, RawSensitive: "provider-extra" }); expect(decryptStringOrThrow(config, data.normalized_encrypted)).not.toContain("provider-extra"); }); test("does not match partial pages", () => { const { batch, attachment, recognition } = fixture(); expect(buildVerificationData(config, batch, attachment, { ...recognition, pagesExpected: 2 }).status).toBe("needs_review"); }); test("requires different items and transaction identities across pages", () => { const { batch, attachment, recognition } = fixture(); const duplicated = { ...recognition, pagesExpected: 2, pagesProcessed: 2, pages: [recognition.pages[0], { ...recognition.pages[0], pageNumber: 2 }] }; expect(buildVerificationData(config, batch, attachment, duplicated).status).toBe("needs_review"); }); test("claims a verified item inside supplied transaction", async () => { const { tx, batch } = fixture(); await claimVerifiedReceipts(tx, config, batch); expect(tx.paymentReceiptClaim.create).toHaveBeenCalledTimes(1); expect(tx.paymentReceiptClaim.create.mock.calls[0][0].data.item_id).toBe("item"); expect(tx.$queryRaw).toHaveBeenCalledTimes(2); }); test("idempotent claim does not insert again", async () => { const { tx, batch, verification } = fixture(); tx.paymentReceiptClaim.findMany.mockResolvedValue([verification.matches[0]]); await claimVerifiedReceipts(tx, config, batch); expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled(); }); test("cannot reuse transaction in another item or batch", async () => { const { tx, batch, verification } = fixture(); tx.paymentReceiptClaim.findMany.mockResolvedValue([{ ...verification.matches[0], item_id: "other-item" }]); await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_transaction_reused" }); expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled(); }); test("key rotation cannot bypass historical duplicate detection", async () => { const { tx, batch } = fixture(); tx.paymentReceiptClaim.count.mockResolvedValue(1); await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_key_rotation_required" }); expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled(); }); test.each(["archived", "quarantined", "hash_changed", "latest_failed"])("rejects unavailable evidence: %s", async (reason) => { const { tx, batch, verification } = fixture(); if (reason === "archived") verification.attachment.archived_at = new Date(); if (reason === "quarantined") verification.attachment.governance_status = "quarantined"; if (reason === "hash_changed") verification.attachment.file_sha256 = "b".repeat(64); if (reason === "latest_failed") verification.status = "needs_review"; await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_verification_required" }); expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled(); }); test("re-evaluates frozen amount rather than trusting persisted matched flag", async () => { const { tx, batch } = fixture(); batch.items[0].amount = "11.00"; await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_match_changed" }); }); test("checks persisted fingerprint against encrypted evidence", async () => { const { tx, batch, verification } = fixture(); verification.matches[0].fingerprint = "f".repeat(64); await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_match_changed" }); }); test("does not reopen already executed items", async () => { const { tx, batch } = fixture(); batch.items[0].status = "executed"; await claimVerifiedReceipts(tx, config, batch); expect(tx.$queryRaw).not.toHaveBeenCalled(); }); });