fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -0,0 +1,139 @@
|
||||
const { encryptStringOrThrow, decryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||
const { buildVerificationData, claimVerifiedReceipts, transactionFingerprint, canRetryReceiptRecognition } = require("../../../src/services/paymentReceiptVerification");
|
||||
|
||||
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 }))) };
|
||||
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) },
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户