文件
hyb-oa/server/tests/unit/services/paymentReceiptVerification.test.js
T
wangsli 5a7a13524e 完善付款回单凭证核验与本地重检(09-17-company-loan-principal-repayment)
区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。

正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。

本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。

本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。

限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
2026-10-09 06:18:09 +08:00

165 行
9.8 KiB
JavaScript

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();
});
});