fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -70,6 +70,15 @@
|
||||
"payment_batch.invalid_identifier": "付款记录标识已失效,请刷新后重新选择。",
|
||||
"payment_batch.invalid_input": "付款操作内容不完整或格式不正确,请核对页面必填项后重试。",
|
||||
"payment_batch.bank_receipt_required": "银行付款登记前必须上传有效银行回单,请先补充回单。",
|
||||
"payment_batch.receipt_retry_unavailable": "回单状态已变化或仍在识别,请刷新后查看;处理中超过15分钟仍未完成时可重试。",
|
||||
"payment_batch.receipt_independent_review_required": "回单人工核验需要独立审批人复核;制单人、核验人及报销归属人或提交人不能自审。",
|
||||
"payment_batch.receipt_transaction_reused": "回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。",
|
||||
"payment_batch.receipt_manual_state_changed": "回单或核验版本已变化,请刷新后重新查看当前状态。",
|
||||
"payment_batch.receipt_manual_verification_required": "回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。",
|
||||
"payment_batch.receipt_key_unavailable": "回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。",
|
||||
"payment_batch.receipt_key_rotation_required": "回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。",
|
||||
"payment_batch.receipt_snapshot_unsupported": "付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。",
|
||||
"payment_batch.receipt_identity_unresolved": "无法完整确认报销参与人身份,请管理员核实人员关系后再复核;不能自行批准。",
|
||||
"payment_batch.department_unavailable": "当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。",
|
||||
"payment_batch.source_not_found": "部分来源单据已不存在或不可用,请刷新后重新选择。",
|
||||
"payment_batch.daily_sequence_exhausted": "今日付款批次编号已用完,请联系管理员处理,勿重复提交。",
|
||||
|
||||
@@ -291,6 +291,10 @@ function buildPrisma(options = {}) {
|
||||
};
|
||||
|
||||
const tx = {
|
||||
paymentReceiptClaim: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
|
||||
paymentReceiptTransactionAlias: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
|
||||
paymentReceiptManualProposal: { findMany: vi.fn(async () => []) },
|
||||
paymentReceiptVerification: { findFirst: vi.fn(async () => null) },
|
||||
paymentAccount: {
|
||||
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
|
||||
findMany: vi.fn(async ({ where }) => where.legal_entity_id.in.includes(state.paymentAccount.legal_entity_id) && state.paymentAccount.enabled && !state.paymentAccount.archived_at ? [{ ...state.paymentAccount }] : []),
|
||||
@@ -383,6 +387,7 @@ function buildPrisma(options = {}) {
|
||||
}),
|
||||
},
|
||||
paymentBatchReceiptAttachment: {
|
||||
findUnique: vi.fn(async ({ where }) => state.receiptAttachments.find((item) => item.id === where.id) ?? null),
|
||||
count: vi.fn(async ({ where }) =>
|
||||
state.receiptAttachments.filter((item) =>
|
||||
item.payment_batch_id === where.payment_batch_id &&
|
||||
@@ -417,7 +422,10 @@ function buildPrisma(options = {}) {
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const item = state.receiptAttachments.find((candidate) => candidate.id === where.id);
|
||||
if (!item) throw new Error("receipt not found");
|
||||
const nextVersion = data.receipt_ocr_version?.increment == null ? null :
|
||||
item.receipt_ocr_version + data.receipt_ocr_version.increment;
|
||||
Object.assign(item, data);
|
||||
if (nextVersion !== null) item.receipt_ocr_version = nextVersion;
|
||||
return { ...item };
|
||||
}),
|
||||
deleteMany: vi.fn(async ({ where }) => {
|
||||
@@ -500,6 +508,9 @@ function buildPrisma(options = {}) {
|
||||
$executeRaw: vi.fn(async () => 0),
|
||||
$queryRaw: vi.fn(async (query, ...values) => {
|
||||
const text = Array.isArray(query) ? query.join(" ") : String(query?.sql ?? query ?? "");
|
||||
if (text.includes("FROM payment_batch_receipt_attachments") && text.includes("ORDER BY id")) {
|
||||
return state.receiptAttachments.map((attachment) => ({ id: attachment.id }));
|
||||
}
|
||||
if (text.includes('ORDER BY "batch_no" DESC')) {
|
||||
const pattern = values[1];
|
||||
return typeof pattern === "string" && new RegExp(pattern).test(state.batch.batch_no)
|
||||
@@ -510,6 +521,33 @@ function buildPrisma(options = {}) {
|
||||
}),
|
||||
};
|
||||
|
||||
if (options.verifiedReceipt) {
|
||||
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
|
||||
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||
const { buildVerificationData } = require("../../../src/services/paymentReceiptVerification");
|
||||
const payerAccount = "000011112222";
|
||||
const payeeAccount = "000033334444";
|
||||
state.batch.payment_account_snapshot_json = {
|
||||
bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, payerAccount),
|
||||
};
|
||||
for (const item of state.batch.items) item.snapshot_json = { ...item.snapshot_json, payee: {
|
||||
bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, payeeAccount),
|
||||
} };
|
||||
const attachment = state.receiptAttachments[0];
|
||||
Object.assign(attachment, { receipt_ocr_version: 0, receipt_ocr_state: "matched" });
|
||||
const pages = state.batch.items.filter((item) => ["pending", "failed"].includes(item.status)).map((item, index) => ({
|
||||
pageNumber: index + 1, ...normalizeBankSlip(Object.entries({
|
||||
小写金额: String(item.amount), 币别: item.currency_code, 付款人账号: payerAccount,
|
||||
收款人账号: payeeAccount, 单据状态: "交易成功", 凭证号: `SYN-${item.id}`,
|
||||
}).map(([Name, Value]) => ({ Name, Value }))),
|
||||
}));
|
||||
const data = buildVerificationData(config, state.batch, attachment, {
|
||||
status: "recognized", pagesExpected: pages.length, pagesProcessed: pages.length, pages,
|
||||
});
|
||||
tx.paymentReceiptVerification.findFirst.mockResolvedValue({
|
||||
id: PAYMENT_ACCOUNT_ID, ...data, attachment, matches: data.matches?.create ?? [],
|
||||
});
|
||||
}
|
||||
const prisma = {
|
||||
...tx,
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
@@ -518,6 +556,246 @@ function buildPrisma(options = {}) {
|
||||
return { prisma, tx, state };
|
||||
}
|
||||
|
||||
describe("manual receipt verification routes", () => {
|
||||
const headers = { authorization: "Bearer synthetic-test" };
|
||||
const url = `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/manual-verification`;
|
||||
function setupEvidence(tx, state) {
|
||||
const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY };
|
||||
const { normalizeBankSlip } = require("../../../src/services/paymentReceiptMatching");
|
||||
const fields = Object.entries({
|
||||
小写金额: "1683.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-HTTP-MANUAL",
|
||||
}).map(([Name, Value]) => ({ Name, Value }));
|
||||
state.batch.payment_account_snapshot_json = {
|
||||
bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, "000011112222"),
|
||||
};
|
||||
state.batch.items[0].snapshot_json = { payee: {
|
||||
bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, "000033334444"),
|
||||
} };
|
||||
const attachment = state.receiptAttachments[0];
|
||||
Object.assign(attachment, { receipt_ocr_version: 0, receipt_ocr_state: "needs_review", governance_status: "approved" });
|
||||
const original = { id: LEGAL_ENTITY_ID, attachment_id: attachment.id,
|
||||
source_version: 0, source_sha256: attachment.file_sha256,
|
||||
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
|
||||
status: "needs_review", pagesExpected: 1, pagesProcessed: 1,
|
||||
pages: [{ pageNumber: 1, ...normalizeBankSlip(fields), amount: null, issues: ["amount_unresolved"] }],
|
||||
})) };
|
||||
let stored;
|
||||
tx.paymentReceiptVerification = { findFirst: vi.fn(async () => original) };
|
||||
tx.paymentReceiptClaim = { count: vi.fn(async () => 0) };
|
||||
tx.paymentReceiptTransactionAlias = { count: vi.fn(async () => 0) };
|
||||
tx.paymentReceiptManualProposal = {
|
||||
create: vi.fn(async ({ data }) => { stored = { id: PAYMENT_ACCOUNT_ID, ...data }; return stored; }),
|
||||
findUnique: vi.fn(async () => stored ? { ...stored, original_verification: original } : null),
|
||||
update: vi.fn(async ({ data }) => { Object.assign(stored, data); return stored; }),
|
||||
};
|
||||
return { expectedVersion: 0, reason: "合成更正", pages: [{ pageNumber: 1, itemId: ITEM_ID, fields }] };
|
||||
}
|
||||
|
||||
it("saves encrypted proposal and rejects stale click without recording payment", async () => {
|
||||
const { app, tx, state, tempDir } = await buildApp();
|
||||
const payload = setupEvidence(tx, state);
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url, headers, payload });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toEqual({ id: PAYMENT_ACCOUNT_ID, version: 1, status: "pending_review" });
|
||||
expect(response.body).not.toContain("SYN-HTTP-MANUAL");
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
|
||||
action: "PAYMENT_RECEIPT_MANUAL_PROPOSED",
|
||||
}) }));
|
||||
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(409);
|
||||
expect(tx.paymentReceiptManualProposal.create).toHaveBeenCalledTimes(1);
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["amount", "state", "duplicate", "version", "fields"])("rejects invalid %s with no write", async (failure) => {
|
||||
const { app, tx, state, tempDir } = await buildApp();
|
||||
const payload = setupEvidence(tx, state);
|
||||
if (failure === "amount") payload.pages[0].fields[0].Value = "1.00";
|
||||
if (failure === "state") state.batch.status = "executed";
|
||||
if (failure === "duplicate") tx.paymentReceiptClaim.count.mockResolvedValueOnce(0).mockResolvedValue(1);
|
||||
if (failure === "version") payload.expectedVersion = 1;
|
||||
if (failure === "fields") payload.pages[0].fields[0].unexpected = true;
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url, headers, payload });
|
||||
expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(response.statusCode).toBeLessThan(500);
|
||||
expect(tx.paymentReceiptManualProposal.create).not.toHaveBeenCalled();
|
||||
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("reads masked facts and merges only changed fields without requiring account reentry", async () => {
|
||||
const { app, tx, state, tempDir } = await buildApp();
|
||||
const payload = setupEvidence(tx, state);
|
||||
try {
|
||||
const detail = await app.inject({ method: "GET", url, headers });
|
||||
expect(detail.statusCode).toBe(200);
|
||||
expect(detail.json().data.item.pagesExpected).toBe(1);
|
||||
expect(detail.body).not.toContain("000011112222");
|
||||
expect(detail.body).not.toContain("000033334444");
|
||||
expect(tx.$executeRaw).toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
payload.pages[0].fields = [{ Name: "小写金额", Value: "1683.00" }];
|
||||
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(200);
|
||||
const proposed = await app.inject({ method: "GET", url, headers });
|
||||
expect(proposed.statusCode).toBe(200);
|
||||
expect(proposed.json().data.item.proposal.pages[0].amount).toBe("1683.00");
|
||||
expect(proposed.json().data.item.canReview).toBe(false);
|
||||
expect(proposed.body).not.toContain("000011112222");
|
||||
expect(proposed.body).not.toContain("payload_encrypted");
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["foreign", "voided", "stale"])("does not expose details for %s evidence", async (stateKind) => {
|
||||
const { app, tx, state, tempDir } = await buildApp();
|
||||
setupEvidence(tx, state);
|
||||
if (stateKind === "foreign") state.receiptAttachments[0].payment_batch_id = LEGAL_ENTITY_ID;
|
||||
if (stateKind === "voided") state.receiptAttachments[0].archived_at = new Date();
|
||||
if (stateKind === "stale") state.receiptAttachments[0].receipt_ocr_version = 5;
|
||||
try {
|
||||
const response = await app.inject({ method: "GET", url, headers });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body).not.toContain("SYN-HTTP-MANUAL");
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["anonymous", "ceo", "disabled", "department"])("protects proposal boundary: %s", async (boundary) => {
|
||||
const options = boundary === "ceo" ? { actor: {
|
||||
id: ACTOR_ID, roleCodes: ["ceo"], permissionCodes: ["payment.batch.review"],
|
||||
} } : boundary === "disabled" ? { paymentBatchEnabled: false } :
|
||||
boundary === "department" ? { paymentBatchAllowedDepartmentIds: ["different"] } : {};
|
||||
const { app, tx, state, tempDir } = await buildApp(options);
|
||||
const payload = setupEvidence(tx, state);
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url, payload,
|
||||
headers: boundary === "anonymous" ? {} : headers });
|
||||
const read = await app.inject({ method: "GET", url, headers: boundary === "anonymous" ? {} : headers });
|
||||
if (boundary !== "ceo") expect(read.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(read.body).not.toContain("000011112222");
|
||||
expect(response.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(response.statusCode).toBeLessThan(500);
|
||||
expect(tx.paymentReceiptManualProposal.create).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["finance", "admin"])("cannot approve a manual proposal as %s alone", async (role) => {
|
||||
const { app, tx, tempDir } = await buildApp({ actor: {
|
||||
id: ACTOR_ID, roleCodes: [role], permissionCodes: ["*"],
|
||||
} });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url: `${url}/${PAYMENT_ACCOUNT_ID}/review`, headers,
|
||||
payload: { expectedVersion: 1, decision: "approve", reason: "合成复核" } });
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["independent", "proposer", "owner", "submitter", "maker"])("review enforces %s identity", async (identity) => {
|
||||
const actor = { id: ACTOR_ID, roleCodes: ["finance", "ceo"],
|
||||
permissionCodes: ["payment.batch.manage", "payment.batch.review"] };
|
||||
const { app, tx, state, tempDir } = await buildApp({ actor });
|
||||
const payload = setupEvidence(tx, state);
|
||||
try {
|
||||
expect((await app.inject({ method: "POST", url, headers, payload })).statusCode).toBe(200);
|
||||
actor.id = identity === "proposer" ? ACTOR_ID : identity === "owner" ? OWNER_ID :
|
||||
identity === "submitter" ? SUBMITTER_ID : identity === "maker" ? MAKER_ID : REVIEWER_ID;
|
||||
const detail = await app.inject({ method: "GET", url, headers });
|
||||
expect(detail.json().data.item.canReview).toBe(identity === "independent");
|
||||
const reviewUrl = `${url}/${PAYMENT_ACCOUNT_ID}/review`;
|
||||
const response = await app.inject({ method: "POST", url: reviewUrl, headers,
|
||||
payload: { expectedVersion: 1, decision: "approve", reason: "合成复核" } });
|
||||
expect(response.statusCode).toBe(identity === "independent" ? 200 : 403);
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
if (identity === "independent") {
|
||||
expect(response.json().data.item.status).toBe("approved");
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
|
||||
action: "PAYMENT_RECEIPT_MANUAL_REVIEWED",
|
||||
}) }));
|
||||
} else {
|
||||
expect(tx.paymentReceiptManualProposal.update).not.toHaveBeenCalled();
|
||||
expect(response.json().error.details.reasonCode).toBe("payment_batch.receipt_independent_review_required");
|
||||
}
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("receipt OCR retry routes", () => {
|
||||
const headers = { authorization: "Bearer synthetic-test" };
|
||||
it("stores cash receipt evidence without queuing and rejects explicit OCR retry", async () => {
|
||||
const { app, tx, tempDir } = await buildApp({
|
||||
batch: { default_payment_channel: "cash" }, receiptAttachments: [],
|
||||
});
|
||||
try {
|
||||
const multipart = buildMultipartRequest({ files: [{
|
||||
filename: "synthetic-cash.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF,
|
||||
}] });
|
||||
const upload = await app.inject({ method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
|
||||
headers: { ...headers, ...multipart.headers }, payload: multipart.payload });
|
||||
expect(upload.statusCode).toBe(200);
|
||||
const attachment = upload.json().data.items[0];
|
||||
expect(attachment.ocrState).toBe("legacy");
|
||||
expect(attachment.ocrRetryAvailable).toBe(false);
|
||||
const listed = await app.inject({ method: "GET",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers });
|
||||
expect(listed.statusCode).toBe(200);
|
||||
expect(listed.json().data.items[0].ocrRetryAvailable).toBe(false);
|
||||
const auditCount = tx.auditLog.create.mock.calls.length;
|
||||
const retry = await app.inject({ method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachment.id}/ocr-retry`,
|
||||
headers, payload: { expectedVersion: 0 } });
|
||||
expect(retry.statusCode).toBe(409);
|
||||
expect(retry.json().error.details.reasonCode).toBe("payment_batch.receipt_retry_unavailable");
|
||||
expect(tx.auditLog.create).toHaveBeenCalledTimes(auditCount);
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
it.each(["legacy", "ocr_failed", "needs_review", "manual_rejected"])("queues %s with audit and stale click protection", async (ocrState) => {
|
||||
const { app, tx, state, tempDir } = await buildApp();
|
||||
Object.assign(state.receiptAttachments[0], { receipt_ocr_version: 0, receipt_ocr_state: ocrState, governance_status: "approved" });
|
||||
const url = `/api/payment-batches/${BATCH_ID}/receipt-attachments/${state.receiptAttachments[0].id}/ocr-retry`;
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", url, headers, payload: { expectedVersion: 0 } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({ ocrState: "queued", ocrVersion: 1 });
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
|
||||
action: "PAYMENT_BATCH_RECEIPT_OCR_REQUEUED",
|
||||
}) }));
|
||||
expect((await app.inject({ method: "POST", url, headers, payload: { expectedVersion: 0 } })).statusCode).toBe(409);
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["queued", "matched", "processing"])("does not requeue %s immediately", async (ocrState) => {
|
||||
const { app, tx, state, tempDir } = await buildApp();
|
||||
Object.assign(state.receiptAttachments[0], { receipt_ocr_version: 0, receipt_ocr_state: ocrState,
|
||||
receipt_ocr_started_at: new Date(), governance_status: "approved" });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", headers,
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${state.receiptAttachments[0].id}/ocr-retry`,
|
||||
payload: { expectedVersion: 0 } });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reasonCode).toBe("payment_batch.receipt_retry_unavailable");
|
||||
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("forbids retry without finance management permission", async () => {
|
||||
const { app, tx, tempDir } = await buildApp({ actor: {
|
||||
id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["ceo"],
|
||||
} });
|
||||
try {
|
||||
const response = await app.inject({ method: "POST", headers,
|
||||
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/ocr-retry`,
|
||||
payload: { expectedVersion: 0 } });
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const { prisma, tx, state } = buildPrisma(options);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-cashv-payment-receipts-"));
|
||||
@@ -650,8 +928,43 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe("payment batch reimbursement routes", () => {
|
||||
it.each(["legacy", "superseded", "amount", "voided", "reused"])("blocks bank execution for %s evidence", async (condition) => {
|
||||
const { app, state, tx, tempDir } = await buildApp({ verifiedReceipt: condition !== "legacy" });
|
||||
if (condition === "superseded") state.receiptAttachments[0].receipt_ocr_version += 1;
|
||||
if (condition === "amount") state.batch.items[0].amount = "1684.00";
|
||||
if (condition === "voided") state.receiptAttachments[0].archived_at = new Date();
|
||||
if (condition === "reused") tx.paymentReceiptTransactionAlias.findMany.mockResolvedValue([
|
||||
{ claim_fingerprint: "different-transaction", key_id: "different-key" },
|
||||
]);
|
||||
const headers = { authorization: "Bearer synthetic-test" };
|
||||
try {
|
||||
const readiness = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-readiness`, headers });
|
||||
expect(readiness.statusCode).toBe(200);
|
||||
expect(readiness.json().data.item.ready).toBe(false);
|
||||
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||
expect(tx.paymentReceiptTransactionAlias.create).not.toHaveBeenCalled();
|
||||
const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers, payload: {} });
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("readiness is read-only and valid evidence is claimed by execute", async () => {
|
||||
const { app, tx, tempDir } = await buildApp({ verifiedReceipt: true });
|
||||
const headers = { authorization: "Bearer synthetic-test" };
|
||||
try {
|
||||
const response = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-readiness`, headers });
|
||||
expect(response.json().data.item.ready).toBe(true);
|
||||
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||
expect((await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers, payload: {} })).statusCode).toBe(200);
|
||||
expect(tx.paymentReceiptClaim.create).toHaveBeenCalledTimes(1);
|
||||
expect(tx.paymentReceiptTransactionAlias.create).toHaveBeenCalledTimes(1);
|
||||
} finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); }
|
||||
});
|
||||
it("preserves internal failure summaries while refreshing safe reason metadata on an existing risk", async () => {
|
||||
const { app, tx, state } = await buildApp({ reimbursement: { status: "reviewing" } });
|
||||
const { app, tx, state } = await buildApp({ verifiedReceipt: true, reimbursement: { status: "reviewing" } });
|
||||
const risk = {
|
||||
id: "existing-risk", entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID,
|
||||
risk_code: "payment_batch_execute_failed", level: "high", title: "支付批次执行失败",
|
||||
@@ -788,7 +1101,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
{ id: "notif-2", recipient_employee_id: SUBMITTER_ID },
|
||||
]);
|
||||
|
||||
const { app, tx, state } = await buildApp();
|
||||
const { app, tx, state } = await buildApp({ verifiedReceipt: true });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -1495,6 +1808,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
|
||||
it("uses the company-local registration date when no payment date was entered", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
verifiedReceipt: true,
|
||||
batch: { default_payment_date: null },
|
||||
});
|
||||
|
||||
@@ -1789,6 +2103,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
|
||||
it("returns a conflict without payment audit when the source was already paid", async () => {
|
||||
const { app, state, tx } = await buildApp({
|
||||
verifiedReceipt: true,
|
||||
reimbursement: {
|
||||
status: "paid",
|
||||
payment_voucher_no: "PBX-001",
|
||||
@@ -1817,7 +2132,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
});
|
||||
|
||||
it("rolls back the whole execution path on an unexpected database error", async () => {
|
||||
const { app, state, tx } = await buildApp();
|
||||
const { app, state, tx } = await buildApp({ verifiedReceipt: true });
|
||||
tx.invoicePool.updateMany.mockRejectedValueOnce(new Error("synthetic_database_failure"));
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -2027,6 +2342,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(uploadResponse.json().data.items[0]).toMatchObject({
|
||||
originalName: "bank-receipt.pdf",
|
||||
mimeType: "application/pdf",
|
||||
ocrState: "queued",
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "confidential",
|
||||
attachmentType: "receipt",
|
||||
@@ -2455,6 +2771,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
it("retries only failed items and recomputes the batch summary from every final item state", async () => {
|
||||
const baseItem = buildPaymentBatch().items[0];
|
||||
const { app, tx, state } = await buildApp({
|
||||
verifiedReceipt: true,
|
||||
batch: {
|
||||
status: "failed",
|
||||
execution_summary_json: { successCount: 1, failedCount: 1, attemptedCount: 2 },
|
||||
@@ -2560,6 +2877,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
|
||||
it("marks batch item failed when reimbursement is not payable and does not queue follow-ups", async () => {
|
||||
const { app, state } = await buildApp({
|
||||
verifiedReceipt: true,
|
||||
reimbursement: {
|
||||
status: "reviewing",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
const {
|
||||
normalizeBankSlip,
|
||||
matchBankSlip,
|
||||
parseReceiptMinorUnits,
|
||||
} = require("../../../src/services/paymentReceiptMatching");
|
||||
|
||||
function fields(overrides = {}) {
|
||||
return Object.entries({
|
||||
小写金额: "1,234.56", 币别: "人民币", 付款人账号: "000011112222",
|
||||
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-0001",
|
||||
...overrides,
|
||||
}).map(([Name, Value]) => ({ Name, Value }));
|
||||
}
|
||||
|
||||
function candidates() {
|
||||
return [{ id: "synthetic-item", amount: "1234.56", currency: "CNY",
|
||||
payerAccount: "000011112222", payeeAccount: "000033334444" }];
|
||||
}
|
||||
|
||||
describe("bank receipt normalization", () => {
|
||||
test.each([
|
||||
["1,234.56", "123456"], ["123.45", "12345"], ["0.01", "1"],
|
||||
["1234", "123400"], ["¥ 1234.56 元", "123456"], ["1,23.45", null],
|
||||
["123.456", null], ["-1.00", null], ["1e3", null], ["", null],
|
||||
["已付123.45", null], ["1 234.56", null],
|
||||
])("parses exact money without guessing %s", (input, expected) => {
|
||||
expect(parseReceiptMinorUnits(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test("normalizes actual provider field aliases using synthetic values", () => {
|
||||
const result = normalizeBankSlip(fields());
|
||||
expect(result).toMatchObject({ amount: "123456", currency: "CNY",
|
||||
payerAccount: "000011112222", payeeAccount: "000033334444",
|
||||
transactionStatus: "success", reference: "SYN-0001", issues: [] });
|
||||
});
|
||||
|
||||
test("does not use fees or uppercase text as transaction amount", () => {
|
||||
const result = normalizeBankSlip(fields({ 小写金额: "", 手续费: "1234.56", 大写金额: "壹仟贰佰叁拾肆元伍角陆分" }));
|
||||
expect(result.amount).toBeNull();
|
||||
});
|
||||
|
||||
test("conflicting aliases are not silently resolved", () => {
|
||||
const result = normalizeBankSlip(fields({ 交易金额: "999.00" }));
|
||||
expect(result.amount).toBeNull();
|
||||
expect(result.issues).toContain("amount_ambiguous");
|
||||
});
|
||||
|
||||
test("repeated semantic fields may represent multiple receipts on one page", () => {
|
||||
const result = normalizeBankSlip([...fields(), ...fields()]);
|
||||
expect(result.issues).toContain("amount_ambiguous");
|
||||
});
|
||||
|
||||
test.each(["已受理", "交易成功处理中", "未成功", "", "已打印"])("does not infer payment success from %s", (status) => {
|
||||
expect(normalizeBankSlip(fields({ 单据状态: status })).transactionStatus).not.toBe("success");
|
||||
});
|
||||
|
||||
test("masked accounts stay unresolved", () => {
|
||||
expect(normalizeBankSlip(fields({ 收款人账号: "****4444" })).payeeAccount).toBeNull();
|
||||
});
|
||||
|
||||
test("invalid response does not become an empty successful receipt", () => {
|
||||
expect(normalizeBankSlip(null).issues).toContain("response_invalid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bank receipt candidate matching", () => {
|
||||
test("matches one exact transaction but does not declare bank verification", () => {
|
||||
expect(matchBankSlip(normalizeBankSlip(fields()), candidates()))
|
||||
.toEqual({ status: "matched", itemId: "synthetic-item", reasons: [], bankVerified: false });
|
||||
});
|
||||
|
||||
test.each([
|
||||
[{ 小写金额: "1234.55" }, "amount_mismatch"],
|
||||
[{ 收款人账号: "999933334444" }, "payee_account_mismatch"],
|
||||
[{ 付款人账号: "999911112222" }, "payer_account_mismatch"],
|
||||
[{ 币别: "USD" }, "currency_mismatch"],
|
||||
])("blocks a known mismatch", (overrides, reason) => {
|
||||
const result = matchBankSlip(normalizeBankSlip(fields(overrides)), candidates());
|
||||
expect(result.status).toBe("mismatch");
|
||||
expect(result.reasons).toContain(reason);
|
||||
});
|
||||
|
||||
test.each(["交易失败", "已撤销", "已冲正", "已退汇"])("blocks %s", (status) => {
|
||||
expect(matchBankSlip(normalizeBankSlip(fields({ 单据状态: status })), candidates()).status).toBe("mismatch");
|
||||
});
|
||||
|
||||
test.each(["已受理", "未知", ""])("requires review for status %s", (status) => {
|
||||
expect(matchBankSlip(normalizeBankSlip(fields({ 单据状态: status })), candidates()).status).toBe("needs_review");
|
||||
});
|
||||
|
||||
test("requires a transaction reference for downstream duplicate protection", () => {
|
||||
expect(matchBankSlip(normalizeBankSlip(fields({ 凭证号: "" })), candidates()).reasons).toContain("reference_unresolved");
|
||||
});
|
||||
|
||||
test("same-account same-amount items require disambiguation", () => {
|
||||
expect(matchBankSlip(normalizeBankSlip(fields()), [...candidates(), { ...candidates()[0], id: "second-item" }]).status)
|
||||
.toBe("needs_review");
|
||||
});
|
||||
|
||||
test("does not match receipt against combined batch total", () => {
|
||||
const items = [ { ...candidates()[0], amount: "600.00" }, { ...candidates()[0], id: "second-item", amount: "634.56" } ];
|
||||
expect(matchBankSlip(normalizeBankSlip(fields()), items).status).toBe("mismatch");
|
||||
});
|
||||
|
||||
test("does not match missing expected accounts", () => {
|
||||
expect(matchBankSlip(normalizeBankSlip(fields()), [{ ...candidates()[0], payerAccount: null }]).status)
|
||||
.toBe("needs_review");
|
||||
});
|
||||
|
||||
test("does not return receipt values in public matching results", () => {
|
||||
const result = matchBankSlip(normalizeBankSlip(fields()), candidates());
|
||||
expect(JSON.stringify(result)).not.toContain("000011112222");
|
||||
expect(JSON.stringify(result)).not.toContain("SYN-0001");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const sharp = require("sharp");
|
||||
const { recognizePaymentReceipt } = require("../../../src/services/paymentReceiptOcr");
|
||||
|
||||
async function pdfBuffer(pages = 1) {
|
||||
const pdf = await PDFDocument.create();
|
||||
for (let page = 0; page < pages; page += 1) pdf.addPage();
|
||||
return Buffer.from(await pdf.save());
|
||||
}
|
||||
|
||||
function providerResult() {
|
||||
return { BankSlipInfos: Object.entries({
|
||||
小写金额: "10.00", 币别: "人民币", 付款人账号: "000011112222",
|
||||
收款人账号: "000033334444", 单据状态: "交易成功", 凭证号: "SYN-REFERENCE",
|
||||
}).map(([Name, Value]) => ({ Name, Value })), RequestId: "not-persisted", RawSensitive: "not-persisted" };
|
||||
}
|
||||
|
||||
describe("dedicated payment receipt OCR", () => {
|
||||
test("processes every PDF page without public URLs or invoice writes", async () => {
|
||||
const BankSlipOCR = vi.fn().mockResolvedValue(providerResult());
|
||||
const result = await recognizePaymentReceipt({
|
||||
buffer: await pdfBuffer(2), mimeType: "application/pdf", createClient: () => ({ BankSlipOCR }),
|
||||
});
|
||||
expect(result).toMatchObject({ status: "recognized", pagesExpected: 2, pagesProcessed: 2 });
|
||||
expect(BankSlipOCR).toHaveBeenCalledTimes(2);
|
||||
expect(BankSlipOCR.mock.calls.map(([request]) => request.PdfPageNumber)).toEqual([1, 2]);
|
||||
expect(BankSlipOCR.mock.calls.every(([request]) => request.ImageUrl === undefined)).toBe(true);
|
||||
expect(result.pages[0].amount).toBe("1000");
|
||||
expect(JSON.stringify(result)).not.toContain("not-persisted");
|
||||
});
|
||||
|
||||
test("does not claim completeness after a later page fails or retry automatically", async () => {
|
||||
const BankSlipOCR = vi.fn().mockResolvedValueOnce(providerResult()).mockRejectedValueOnce(new Error("sensitive error"));
|
||||
const result = await recognizePaymentReceipt({
|
||||
buffer: await pdfBuffer(2), mimeType: "application/pdf", createClient: () => ({ BankSlipOCR }),
|
||||
});
|
||||
expect(result).toMatchObject({ status: "ocr_failed", pagesExpected: 2, pagesProcessed: 1, reason: "receipt_ocr_request_failed" });
|
||||
expect(BankSlipOCR).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.stringify(result)).not.toContain("sensitive error");
|
||||
});
|
||||
|
||||
test("preserves unknown fields as unresolved rather than success", async () => {
|
||||
const result = await recognizePaymentReceipt({
|
||||
buffer: await pdfBuffer(), mimeType: "application/pdf",
|
||||
createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: [] }) }),
|
||||
});
|
||||
expect(result.status).toBe("needs_review");
|
||||
expect(result.pages[0].issues).toContain("response_invalid");
|
||||
});
|
||||
|
||||
test("rejects invalid PDF before creating cloud client", async () => {
|
||||
const createClient = vi.fn();
|
||||
const result = await recognizePaymentReceipt({ buffer: Buffer.from("not pdf"), mimeType: "application/pdf", createClient });
|
||||
expect(result.reason).toBe("receipt_decode_failed");
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("limits pages before any external call", async () => {
|
||||
const createClient = vi.fn();
|
||||
const result = await recognizePaymentReceipt({ buffer: await pdfBuffer(21), mimeType: "application/pdf", createClient });
|
||||
expect(result.reason).toBe("receipt_page_limit");
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("converts existing WEBP uploads to supported PNG in memory", async () => {
|
||||
const buffer = await sharp({ create: { width: 10, height: 10, channels: 3, background: "white" } }).webp().toBuffer();
|
||||
const BankSlipOCR = vi.fn().mockResolvedValue(providerResult());
|
||||
const result = await recognizePaymentReceipt({ buffer, mimeType: "image/webp", createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.status).toBe("recognized");
|
||||
expect(BankSlipOCR.mock.calls[0][0].IsPdf).toBe(false);
|
||||
const sent = Buffer.from(BankSlipOCR.mock.calls[0][0].ImageBase64, "base64");
|
||||
expect((await sharp(sent).metadata()).format).toBe("png");
|
||||
});
|
||||
|
||||
test("missing credentials do not silently disable OCR", async () => {
|
||||
const result = await recognizePaymentReceipt({ buffer: await pdfBuffer(), mimeType: "application/pdf", config: {} });
|
||||
expect(result).toMatchObject({ status: "ocr_failed", reason: "receipt_ocr_unavailable" });
|
||||
});
|
||||
|
||||
test.each(["off", "mock"])("provider %s never falls through to real Tencent", async (ocrProvider) => {
|
||||
const result = await recognizePaymentReceipt({
|
||||
buffer: await pdfBuffer(), mimeType: "application/pdf",
|
||||
config: { ocrProvider, tencentSecretId: "synthetic", tencentSecretKey: "synthetic" },
|
||||
});
|
||||
expect(result).toMatchObject({ status: "ocr_failed", reason: "receipt_ocr_unavailable" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const { claimReceiptJob, runReceiptOcrOnce, startReceiptOcrJob, startReceiptPgBossJob } = require("../../../src/jobs/paymentReceiptOcrJob");
|
||||
|
||||
test("cron serializes ticks locally and removes timer on close", async () => {
|
||||
const cron = require("node-cron");
|
||||
let callback;
|
||||
let onClose;
|
||||
let release;
|
||||
const task = { stop: vi.fn(), destroy: vi.fn() };
|
||||
const schedule = vi.spyOn(cron, "schedule").mockImplementation((expression, handler) => { callback = handler; return task; });
|
||||
const transaction = vi.fn(() => new Promise((resolve) => { release = resolve; }));
|
||||
try {
|
||||
startReceiptOcrJob({ config: { ocrPollCron: "* * * * *" }, prisma: { $transaction: transaction },
|
||||
log: { warn: vi.fn() }, addHook: (name, handler) => { onClose = handler; } });
|
||||
const running = callback();
|
||||
await callback();
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
release(null);
|
||||
await running;
|
||||
await onClose();
|
||||
expect(task.stop).toHaveBeenCalledTimes(1);
|
||||
expect(task.destroy).toHaveBeenCalledTimes(1);
|
||||
} finally { schedule.mockRestore(); }
|
||||
});
|
||||
|
||||
test("pg-boss uses a separate receipt queue with no automatic request retry", async () => {
|
||||
const boss = { createQueue: vi.fn(), work: vi.fn(), schedule: vi.fn() };
|
||||
await startReceiptPgBossJob({ config: { ocrPollCron: "* * * * *", appTimezone: "Asia/Shanghai" } }, boss);
|
||||
expect(boss.createQueue).toHaveBeenCalledWith("payment.receipt.ocr", { retryLimit: 0, expireInSeconds: 900 });
|
||||
expect(boss.work).toHaveBeenCalledWith("payment.receipt.ocr", { batchSize: 1 }, expect.any(Function));
|
||||
expect(boss.schedule.mock.calls[0][3]).toMatchObject({ retryLimit: 0, singletonKey: "payment.receipt.ocr" });
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const config = { fieldEncryptionKey: Buffer.alloc(32, 7).toString("base64") };
|
||||
const buffer = Buffer.from("synthetic-pdf");
|
||||
const attachment = { id: "attachment", payment_batch_id: "batch", receipt_ocr_version: 1,
|
||||
file_size: buffer.length, file_sha256: createHash("sha256").update(buffer).digest("hex"),
|
||||
mime_type: "application/pdf", storage_path: "private-synthetic", governance_status: "approved" };
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: "batch" }]),
|
||||
paymentBatchReceiptAttachment: { update: vi.fn().mockResolvedValue(attachment), updateMany: vi.fn(),
|
||||
findUnique: vi.fn().mockResolvedValue(attachment) },
|
||||
paymentBatch: { findUnique: vi.fn().mockResolvedValue({ id: "batch", status: "reviewed", entity_type: "reimbursement",
|
||||
payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountNumberEncrypted: encryptStringOrThrow(config, "000011112222") },
|
||||
items: [{ id: "item", status: "pending", amount: "10.00", currency_code: "CNY",
|
||||
snapshot_json: { payee: { bankInstitutionCode: "CCB", bankAccountEncrypted: encryptStringOrThrow(config, "000033334444") } } }] }) },
|
||||
paymentReceiptVerification: { create: vi.fn().mockResolvedValue({ id: "verification", status: "needs_review" }) },
|
||||
};
|
||||
const prisma = { $transaction: vi.fn((callback) => callback(tx)) };
|
||||
const readBuffer = vi.fn().mockResolvedValue(buffer);
|
||||
const recognize = vi.fn().mockResolvedValue({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [] });
|
||||
return { prisma, config, readBuffer, recognize, tx, attachment };
|
||||
}
|
||||
|
||||
test("idle workers do not read files or call OCR", async () => {
|
||||
const args = fixture();
|
||||
args.tx.$queryRaw.mockResolvedValue([]);
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "idle" });
|
||||
expect(args.readBuffer).not.toHaveBeenCalled();
|
||||
expect(args.recognize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ entity_type: "travel_order", default_payment_channel: "bank", items: [{ status: "pending" }] },
|
||||
{ entity_type: "reimbursement", default_payment_channel: "cash", items: [{ status: "pending" }] },
|
||||
{ entity_type: "reimbursement", items: [{ status: "executed" }] },
|
||||
])("out-of-scope queued receipts never read files or call OCR: %j", async (batch) => {
|
||||
const args = fixture();
|
||||
args.tx.paymentBatch.findUnique.mockResolvedValue({ id: "batch", ...batch });
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "idle" });
|
||||
expect(args.readBuffer).not.toHaveBeenCalled();
|
||||
expect(args.recognize).not.toHaveBeenCalled();
|
||||
expect(args.tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
|
||||
expect(args.tx.paymentBatchReceiptAttachment.updateMany).toHaveBeenCalledWith({
|
||||
where: { payment_batch_id: "batch", receipt_ocr_state: "queued" }, data: { receipt_ocr_state: "legacy" },
|
||||
});
|
||||
});
|
||||
|
||||
test("claim marks processing and increments version in the transaction", async () => {
|
||||
const args = fixture();
|
||||
await claimReceiptJob(args.prisma);
|
||||
expect(args.tx.paymentBatchReceiptAttachment.update.mock.calls[0][0].data)
|
||||
.toMatchObject({ receipt_ocr_state: "processing", receipt_ocr_version: { increment: 1 } });
|
||||
});
|
||||
|
||||
test("successful processing stores a result without raw file values in response", async () => {
|
||||
const args = fixture();
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "needs_review" });
|
||||
expect(args.recognize).toHaveBeenCalledTimes(1);
|
||||
expect(args.tx.paymentReceiptVerification.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("file mismatch prevents external transmission", async () => {
|
||||
const args = fixture();
|
||||
args.attachment.file_sha256 = "a".repeat(64);
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "ocr_failed" });
|
||||
expect(args.recognize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("provider errors are not leaked or automatically retried", async () => {
|
||||
const args = fixture();
|
||||
args.recognize.mockRejectedValue(new Error("sensitive-provider-value"));
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "ocr_failed" });
|
||||
expect(args.recognize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("superseded result cannot update job state", async () => {
|
||||
const args = fixture();
|
||||
args.tx.paymentBatchReceiptAttachment.findUnique.mockResolvedValue({ ...args.attachment, receipt_ocr_version: 2 });
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "discarded" });
|
||||
expect(args.tx.paymentReceiptVerification.create).not.toHaveBeenCalled();
|
||||
expect(args.tx.paymentBatchReceiptAttachment.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["receipt_key_unavailable", "key_failed"],
|
||||
["receipt_bank_unsupported", "snapshot_failed"],
|
||||
["unknown", "system_failed"],
|
||||
])("persistence error %s records safe terminal state %s", async (code, state) => {
|
||||
const args = fixture();
|
||||
args.tx.paymentReceiptVerification.create.mockRejectedValue(Object.assign(new Error("sensitive"), { code }));
|
||||
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 1 });
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: state });
|
||||
expect(args.tx.paymentBatchReceiptAttachment.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: args.attachment.id, payment_batch_id: args.attachment.payment_batch_id,
|
||||
receipt_ocr_version: 1, file_sha256: args.attachment.file_sha256,
|
||||
receipt_ocr_state: "processing", archived_at: null, governance_status: "approved" },
|
||||
data: { receipt_ocr_state: state },
|
||||
});
|
||||
expect(args.recognize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("missing key is persisted before any file read or cloud call", async () => {
|
||||
const args = fixture();
|
||||
args.config = {};
|
||||
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 1 });
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "key_failed" });
|
||||
expect(args.readBuffer).not.toHaveBeenCalled();
|
||||
expect(args.recognize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("late failure cannot overwrite a changed attachment", async () => {
|
||||
const args = fixture();
|
||||
args.tx.paymentReceiptVerification.create.mockRejectedValue(new Error("synthetic"));
|
||||
args.tx.paymentBatchReceiptAttachment.updateMany.mockResolvedValue({ count: 0 });
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "discarded" });
|
||||
});
|
||||
|
||||
test("failure recording outage is reported without claiming persistence", async () => {
|
||||
const args = fixture();
|
||||
args.tx.paymentReceiptVerification.create.mockRejectedValue(new Error("sensitive"));
|
||||
args.tx.paymentBatchReceiptAttachment.updateMany.mockRejectedValue(new Error("sensitive-db"));
|
||||
expect(await runReceiptOcrOnce(args)).toEqual({ status: "failure_recording_failed" });
|
||||
expect(args.recognize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户