fix: 增加回单核对与付款登记门禁(09-08-reimbursement-payment-closeout)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-15 07:21:55 +08:00
父节点 229867f5fd
当前提交 8d08d46d16
共修改 33 个文件,包含 3691 行新增和 11 行删除
@@ -0,0 +1,57 @@
ALTER TABLE "payment_batch_receipt_attachments" ADD COLUMN "receipt_ocr_version" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "payment_batch_receipt_attachments"
ADD COLUMN "receipt_ocr_state" VARCHAR(32) NOT NULL DEFAULT 'legacy',
ADD COLUMN "receipt_ocr_started_at" TIMESTAMP(3);
CREATE TABLE "payment_receipt_verifications" (
"id" UUID NOT NULL,
"attachment_id" UUID NOT NULL,
"source_sha256" VARCHAR(64) NOT NULL,
"source_version" INTEGER NOT NULL,
"status" VARCHAR(32) NOT NULL,
"pages_expected" INTEGER NOT NULL,
"pages_processed" INTEGER NOT NULL,
"normalized_encrypted" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_receipt_verifications_pkey" PRIMARY KEY ("id"),
CONSTRAINT "payment_receipt_verifications_pages_check" CHECK (
pages_expected >= 0 AND pages_processed BETWEEN 0 AND pages_expected
),
CONSTRAINT "payment_receipt_verifications_attachment_id_fkey" FOREIGN KEY ("attachment_id")
REFERENCES "payment_batch_receipt_attachments"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE INDEX "payment_receipt_verifications_attachment_id_created_at_idx"
ON "payment_receipt_verifications"("attachment_id", "created_at");
CREATE TABLE "payment_receipt_matches" (
"id" UUID NOT NULL,
"verification_id" UUID NOT NULL,
"item_id" UUID NOT NULL,
"page_number" INTEGER NOT NULL CHECK (page_number BETWEEN 1 AND 20),
"fingerprint" VARCHAR(64) NOT NULL,
"key_id" VARCHAR(64) NOT NULL,
CONSTRAINT "payment_receipt_matches_pkey" PRIMARY KEY ("id"),
CONSTRAINT "payment_receipt_matches_verification_id_fkey" FOREIGN KEY ("verification_id")
REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "payment_receipt_matches_item_id_fkey" FOREIGN KEY ("item_id")
REFERENCES "payment_batch_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "payment_receipt_matches_verification_id_page_number_key"
ON "payment_receipt_matches"("verification_id", "page_number");
CREATE UNIQUE INDEX "payment_receipt_matches_verification_id_item_id_key"
ON "payment_receipt_matches"("verification_id", "item_id");
CREATE INDEX "payment_receipt_matches_item_id_idx" ON "payment_receipt_matches"("item_id");
CREATE TABLE "payment_receipt_claims" (
"fingerprint" VARCHAR(64) NOT NULL,
"key_id" VARCHAR(64) NOT NULL,
"item_id" UUID NOT NULL,
"verification_id" UUID NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_receipt_claims_pkey" PRIMARY KEY ("fingerprint"),
CONSTRAINT "payment_receipt_claims_item_id_fkey" FOREIGN KEY ("item_id")
REFERENCES "payment_batch_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "payment_receipt_claims_verification_id_fkey" FOREIGN KEY ("verification_id")
REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "payment_receipt_claims_item_id_key" ON "payment_receipt_claims"("item_id");
@@ -0,0 +1,21 @@
CREATE TABLE "payment_receipt_manual_proposals" (
"id" UUID NOT NULL PRIMARY KEY,
"attachment_id" UUID NOT NULL REFERENCES "payment_batch_receipt_attachments"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
"original_verification_id" UUID NOT NULL REFERENCES "payment_receipt_verifications"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
"source_sha256" VARCHAR(64) NOT NULL,
"source_version" INTEGER NOT NULL CHECK ("source_version" > 0),
"status" VARCHAR(32) NOT NULL CHECK ("status" IN ('pending_review', 'association_ready', 'approved', 'rejected')),
"proposed_by" UUID NOT NULL,
"reviewed_by" UUID,
"payload_encrypted" TEXT NOT NULL,
"review_note_encrypted" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewed_at" TIMESTAMP(3),
CONSTRAINT "payment_receipt_manual_review_decision_check" CHECK (
("status" IN ('pending_review', 'association_ready') AND "reviewed_by" IS NULL AND "reviewed_at" IS NULL)
OR ("status" IN ('approved', 'rejected') AND "reviewed_by" IS NOT NULL AND "reviewed_at" IS NOT NULL
AND "reviewed_by" <> "proposed_by")
)
);
CREATE UNIQUE INDEX "payment_receipt_manual_proposals_attachment_id_source_version_key"
ON "payment_receipt_manual_proposals"("attachment_id", "source_version");
@@ -0,0 +1,13 @@
ALTER TABLE "payment_receipt_claims" ADD COLUMN "manual_proposal_id" UUID;
ALTER TABLE "payment_receipt_claims" ADD CONSTRAINT "payment_receipt_claims_manual_proposal_id_fkey"
FOREIGN KEY ("manual_proposal_id") REFERENCES "payment_receipt_manual_proposals"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE TABLE "payment_receipt_transaction_aliases" (
"fingerprint" VARCHAR(64) PRIMARY KEY,
"key_id" VARCHAR(64) NOT NULL,
"claim_fingerprint" VARCHAR(64) NOT NULL REFERENCES "payment_receipt_claims"("fingerprint") ON DELETE RESTRICT ON UPDATE CASCADE,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "payment_receipt_transaction_aliases_claim_fingerprint_idx"
ON "payment_receipt_transaction_aliases"("claim_fingerprint");
INSERT INTO "payment_receipt_transaction_aliases" ("fingerprint", "key_id", "claim_fingerprint")
SELECT "fingerprint", "key_id", "fingerprint" FROM "payment_receipt_claims";
+89
查看文件
@@ -1966,6 +1966,8 @@ model PaymentBatchItem {
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
@@index([payment_batch_id, status], map: "idx_payment_batch_items_batch_status")
receipt_matches PaymentReceiptMatch[]
receipt_claim PaymentReceiptClaim?
@@index([entity_id], map: "idx_payment_batch_items_entity_id")
@@map("payment_batch_items")
}
@@ -1993,6 +1995,11 @@ model PaymentBatchReceiptAttachment {
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Cascade)
uploader Employee @relation("PaymentBatchReceiptAttachmentUploader", fields: [uploaded_by], references: [id], onDelete: Restrict)
verifications PaymentReceiptVerification[]
manual_proposals PaymentReceiptManualProposal[]
receipt_ocr_version Int @default(0)
receipt_ocr_state String @default("legacy") @db.VarChar(32)
receipt_ocr_started_at DateTime?
@@index([payment_batch_id, created_at], map: "idx_payment_batch_receipts_batch_created")
@@index([uploaded_by], map: "idx_payment_batch_receipts_uploader")
@@ -2002,6 +2009,88 @@ model PaymentBatchReceiptAttachment {
@@map("payment_batch_receipt_attachments")
}
model PaymentReceiptVerification {
id String @id @default(uuid()) @db.Uuid
attachment_id String @db.Uuid
source_sha256 String @db.VarChar(64)
source_version Int
status String @db.VarChar(32)
pages_expected Int
pages_processed Int
normalized_encrypted String
created_at DateTime @default(now())
attachment PaymentBatchReceiptAttachment @relation(fields: [attachment_id], references: [id], onDelete: Restrict)
matches PaymentReceiptMatch[]
claims PaymentReceiptClaim[]
manual_proposals PaymentReceiptManualProposal[]
@@index([attachment_id, created_at])
@@map("payment_receipt_verifications")
}
model PaymentReceiptManualProposal {
id String @id @default(uuid()) @db.Uuid
attachment_id String @db.Uuid
original_verification_id String @db.Uuid
source_sha256 String @db.VarChar(64)
source_version Int
status String @db.VarChar(32)
proposed_by String @db.Uuid
reviewed_by String? @db.Uuid
payload_encrypted String
review_note_encrypted String?
created_at DateTime @default(now())
reviewed_at DateTime?
attachment PaymentBatchReceiptAttachment @relation(fields: [attachment_id], references: [id], onDelete: Restrict)
original_verification PaymentReceiptVerification @relation(fields: [original_verification_id], references: [id], onDelete: Restrict)
claims PaymentReceiptClaim[]
@@unique([attachment_id, source_version])
@@map("payment_receipt_manual_proposals")
}
model PaymentReceiptMatch {
id String @id @default(uuid()) @db.Uuid
verification_id String @db.Uuid
item_id String @db.Uuid
page_number Int
fingerprint String @db.VarChar(64)
key_id String @db.VarChar(64)
verification PaymentReceiptVerification @relation(fields: [verification_id], references: [id], onDelete: Restrict)
item PaymentBatchItem @relation(fields: [item_id], references: [id], onDelete: Restrict)
@@unique([verification_id, page_number])
@@unique([verification_id, item_id])
@@index([item_id])
@@map("payment_receipt_matches")
}
model PaymentReceiptClaim {
fingerprint String @id @db.VarChar(64)
key_id String @db.VarChar(64)
item_id String @unique @db.Uuid
verification_id String @db.Uuid
manual_proposal_id String? @db.Uuid
created_at DateTime @default(now())
item PaymentBatchItem @relation(fields: [item_id], references: [id], onDelete: Restrict)
verification PaymentReceiptVerification @relation(fields: [verification_id], references: [id], onDelete: Restrict)
manual_proposal PaymentReceiptManualProposal? @relation(fields: [manual_proposal_id], references: [id], onDelete: Restrict)
aliases PaymentReceiptTransactionAlias[]
@@map("payment_receipt_claims")
}
model PaymentReceiptTransactionAlias {
fingerprint String @id @db.VarChar(64)
key_id String @db.VarChar(64)
claim_fingerprint String @db.VarChar(64)
created_at DateTime @default(now())
claim PaymentReceiptClaim @relation(fields: [claim_fingerprint], references: [fingerprint], onDelete: Restrict)
@@index([claim_fingerprint])
@@map("payment_receipt_transaction_aliases")
}
model PortalAnnouncement {
id String @id @default(uuid()) @db.Uuid
title String
+1
查看文件
@@ -1,5 +1,6 @@
const ADVISORY_LOCK_NAMESPACES = Object.freeze({
paymentBatchDailySequence: 1001,
paymentReceiptClaims: 1002,
});
module.exports = {
+9
查看文件
@@ -70,6 +70,15 @@ const ERROR_REASONS = [
{"code":"payment_batch.invalid_identifier","domain":"payment_batch","audience":"user","defaultMessage":"付款记录标识已失效,请刷新后重新选择。","recovery":"follow_guidance"},
{"code":"payment_batch.invalid_input","domain":"payment_batch","audience":"user","defaultMessage":"付款操作内容不完整或格式不正确,请核对页面必填项后重试。","recovery":"follow_guidance"},
{"code":"payment_batch.bank_receipt_required","domain":"payment_batch","audience":"user","defaultMessage":"银行付款登记前必须上传有效银行回单,请先补充回单。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_retry_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"回单状态已变化或仍在识别,请刷新后查看;处理中超过15分钟仍未完成时可重试。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_independent_review_required","domain":"payment_batch","audience":"user","defaultMessage":"回单人工核验需要独立审批人复核;制单人、核验人及报销归属人或提交人不能自审。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_transaction_reused","domain":"payment_batch","audience":"user","defaultMessage":"回单交易已被使用或存在重复,请核对原回单;不能通过修改参考号重复登记。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_manual_state_changed","domain":"payment_batch","audience":"user","defaultMessage":"回单或核验版本已变化,请刷新后重新查看当前状态。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_manual_verification_required","domain":"payment_batch","audience":"user","defaultMessage":"回单信息尚不能确认与付款一致,请核对全部页、交易状态、金额、币种及收付款账号;不要直接登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_key_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"回单核验密钥不可用,请联系管理员修复;不要重复识别或登记付款。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_key_rotation_required","domain":"payment_batch","audience":"user","defaultMessage":"回单交易记录使用的密钥版本不一致,请管理员完成受控密钥迁移;人工核验不能绕过。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_snapshot_unsupported","domain":"payment_batch","audience":"user","defaultMessage":"付款业务或银行快照不支持回单核验,请联系管理员核实;不要重复识别。","recovery":"follow_guidance"},
{"code":"payment_batch.receipt_identity_unresolved","domain":"payment_batch","audience":"user","defaultMessage":"无法完整确认报销参与人身份,请管理员核实人员关系后再复核;不能自行批准。","recovery":"follow_guidance"},
{"code":"payment_batch.department_unavailable","domain":"payment_batch","audience":"user","defaultMessage":"当前单据所属部门暂不能使用付款批次,请联系管理员确认处理方式。","recovery":"follow_guidance"},
{"code":"payment_batch.source_not_found","domain":"payment_batch","audience":"user","defaultMessage":"部分来源单据已不存在或不可用,请刷新后重新选择。","recovery":"follow_guidance"},
{"code":"payment_batch.daily_sequence_exhausted","domain":"payment_batch","audience":"user","defaultMessage":"今日付款批次编号已用完,请联系管理员处理,勿重复提交。","recovery":"follow_guidance"},
+2
查看文件
@@ -1,6 +1,7 @@
const { startDelegationRevokeJob } = require("./delegationRevokeJob");
const { startInvoiceArchiveJob } = require("./invoiceArchiveJob");
const { startOcrJob } = require("./ocrJob");
const { startReceiptOcrJob } = require("./paymentReceiptOcrJob");
const { startIntegrationOutboxWorker } = require("./integrationOutboxWorker");
const { startErpMasterDataSyncJob } = require("./erpMasterDataSyncJob");
const { startPgBossJobs } = require("./pgBossJobs");
@@ -18,6 +19,7 @@ async function startJobs(fastify) {
startDelegationRevokeJob(fastify);
startOcrJob(fastify);
startReceiptOcrJob(fastify);
startInvoiceArchiveJob(fastify);
startIntegrationOutboxWorker(fastify);
startErpMasterDataSyncJob(fastify);
+135
查看文件
@@ -0,0 +1,135 @@
const { createHash } = require("node:crypto");
const { readFileBuffer } = require("../services/storage");
const { recognizePaymentReceipt } = require("../services/paymentReceiptOcr");
const { storeReceiptRecognition } = require("../services/paymentReceiptVerification");
const { requiresBankReceipt } = require("../services/paymentProcessingProgress");
const { resolveFieldEncryptionKey } = require("../services/crypto");
async function recordReceiptSystemFailure(prisma, attachment, state) {
try {
return await prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${attachment.payment_batch_id}::uuid FOR UPDATE`;
const batch = await tx.paymentBatch.findUnique({ where: { id: attachment.payment_batch_id } });
if (!batch || !["reviewed", "failed"].includes(batch.status)) return { status: "discarded" };
const result = await tx.paymentBatchReceiptAttachment.updateMany({
where: { id: attachment.id, payment_batch_id: attachment.payment_batch_id,
receipt_ocr_version: attachment.receipt_ocr_version, file_sha256: attachment.file_sha256,
receipt_ocr_state: "processing", archived_at: null, governance_status: "approved" },
data: { receipt_ocr_state: state },
});
return { status: result.count === 1 ? state : "discarded" };
}, { maxWait: 5000, timeout: 10000 });
} catch {
return { status: "failure_recording_failed" };
}
}
async function runReceiptOcrTick(fastify) {
try {
const result = await runReceiptOcrOnce({ prisma: fastify.prisma, config: fastify.config });
if (!["idle", "matched", "needs_review", "discarded"].includes(result.status)) {
fastify.log.warn({ outcome: result.status }, "Receipt OCR requires attention");
}
return result;
} catch {
fastify.log.warn({ outcome: "tick_failed" }, "Receipt OCR tick failed");
return { status: "tick_failed" };
}
}
function startReceiptOcrJob(fastify) {
const cron = require("node-cron");
let running = false;
const task = cron.schedule(fastify.config.ocrPollCron, async () => {
if (running) return;
running = true;
try { await runReceiptOcrTick(fastify); }
finally { running = false; }
});
fastify.addHook("onClose", async () => { task.stop(); task.destroy(); });
return task;
}
async function startReceiptPgBossJob(fastify, boss) {
const queue = "payment.receipt.ocr";
await boss.createQueue(queue, { retryLimit: 0, expireInSeconds: 900 });
await boss.work(queue, { batchSize: 1 }, async () => runReceiptOcrTick(fastify));
await boss.schedule(queue, fastify.config.ocrPollCron, null, {
tz: fastify.config.appTimezone, singletonKey: queue, retryLimit: 0,
});
}
async function claimReceiptJob(prisma) {
return prisma.$transaction(async (tx) => {
const batches = await tx.$queryRaw`
SELECT batch.id FROM payment_batches batch
WHERE batch.status IN ('reviewed', 'failed') AND EXISTS (
SELECT 1 FROM payment_batch_receipt_attachments attachment
WHERE attachment.payment_batch_id = batch.id
AND attachment.receipt_ocr_state = 'queued'
AND attachment.archived_at IS NULL AND attachment.governance_status = 'approved'
) ORDER BY batch.id FOR UPDATE SKIP LOCKED LIMIT 1
`;
if (!batches.length) return null;
const batch = await tx.paymentBatch.findUnique({ where: { id: batches[0].id }, include: { items: true } });
if (batch?.entity_type !== "reimbursement" || !requiresBankReceipt(batch)) {
await tx.paymentBatchReceiptAttachment.updateMany({
where: { payment_batch_id: batches[0].id, receipt_ocr_state: "queued" },
data: { receipt_ocr_state: "legacy" },
});
return null;
}
const attachments = await tx.$queryRaw`
SELECT id FROM payment_batch_receipt_attachments
WHERE payment_batch_id = ${batches[0].id}::uuid AND receipt_ocr_state = 'queued'
AND archived_at IS NULL AND governance_status = 'approved'
ORDER BY created_at, id FOR UPDATE SKIP LOCKED LIMIT 1
`;
if (!attachments.length) return null;
return tx.paymentBatchReceiptAttachment.update({
where: { id: attachments[0].id },
data: { receipt_ocr_state: "processing", receipt_ocr_started_at: new Date(),
receipt_ocr_version: { increment: 1 } },
});
}, { timeout: 10000 });
}
async function runReceiptOcrOnce({ prisma, config, readBuffer = readFileBuffer, recognize = recognizePaymentReceipt }) {
const attachment = await claimReceiptJob(prisma);
if (!attachment) return { status: "idle" };
if (!resolveFieldEncryptionKey(config)) {
return recordReceiptSystemFailure(prisma, attachment, "key_failed");
}
let recognition;
try {
if (attachment.file_size <= 0 || attachment.file_size > 5 * 1024 * 1024) throw new Error("size");
const buffer = await readBuffer({ config, storagePath: attachment.storage_path });
if (buffer.length !== attachment.file_size ||
createHash("sha256").update(buffer).digest("hex") !== attachment.file_sha256) throw new Error("integrity");
recognition = await recognize({ buffer, mimeType: attachment.mime_type, config });
} catch {
recognition = { status: "ocr_failed", pagesExpected: 0, pagesProcessed: 0, pages: [],
reason: "receipt_processing_failed" };
}
try {
return await prisma.$transaction(async (tx) => {
const verification = await storeReceiptRecognition(tx, config, {
batchId: attachment.payment_batch_id, attachmentId: attachment.id,
sourceSha256: attachment.file_sha256, expectedVersion: attachment.receipt_ocr_version, recognition,
});
await tx.paymentBatchReceiptAttachment.update({
where: { id: attachment.id },
data: { receipt_ocr_state: recognition.status === "ocr_failed" ? "ocr_failed" : verification.status },
});
return { status: recognition.status === "ocr_failed" ? "ocr_failed" : verification.status };
}, { timeout: 10000 });
} catch (error) {
if (["receipt_job_superseded", "receipt_source_changed", "receipt_unavailable", "receipt_batch_not_editable"]
.includes(error.code)) return { status: "discarded" };
const state = ["receipt_key_unavailable", "receipt_key_rotation_required"].includes(error.code) ? "key_failed" :
["receipt_bank_unsupported", "receipt_business_unsupported"].includes(error.code) ? "snapshot_failed" : "system_failed";
return recordReceiptSystemFailure(prisma, attachment, state);
}
}
module.exports = { claimReceiptJob, runReceiptOcrOnce, runReceiptOcrTick, startReceiptOcrJob, startReceiptPgBossJob };
+2
查看文件
@@ -1,4 +1,5 @@
const PgBoss = require("pg-boss");
const { startReceiptPgBossJob } = require("./paymentReceiptOcrJob");
const { revokeExpiredDelegations } = require("./delegationRevokeJob");
const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
@@ -45,6 +46,7 @@ async function startPgBossJobs(fastify) {
});
await boss.createQueue(JOB_OCR_TICK);
await startReceiptPgBossJob(fastify, boss);
await boss.createQueue(JOB_OCR_INVOICE);
await boss.createQueue(JOB_DELEGATION_REVOKE);
await boss.createQueue(JOB_INVOICE_ARCHIVE);
+179 -4
查看文件
@@ -1,5 +1,7 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { ERROR_REASONS } = require("../constants/errorReasons");
const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification");
const { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail } = require("../services/paymentReceiptManualPersistence");
const { resolvePaymentBatchCreationReadiness, paymentReadinessError } = require("../services/paymentBatchReadiness");
const crypto = require("node:crypto");
const path = require("node:path");
@@ -89,6 +91,51 @@ const REASON_SCHEMA = z.object({
const PAYMENT_ORDER_SCHEMA = REASON_SCHEMA.partial();
const RECEIPT_MANUAL_SCHEMA = z.object({
expectedVersion: z.number().int().min(0).max(2147483646),
reason: z.string().trim().min(1).max(500).optional(),
pages: z.array(z.object({
pageNumber: z.number().int().min(1).max(20),
itemId: UUID_SCHEMA,
fields: z.array(z.object({
Name: z.string().max(64), Value: z.string().max(256),
}).strict()).min(1).max(200).optional(),
}).strict()).min(1).max(20),
}).strict();
const RECEIPT_MANUAL_REVIEW_SCHEMA = z.object({
expectedVersion: z.number().int().min(0).max(2147483647),
decision: z.enum(["approve", "reject"]),
reason: z.string().trim().min(1).max(500),
}).strict();
function mapManualReceiptError(error) {
if (!(error instanceof ReceiptVerificationError)) throw error;
if (error.code === "receipt_key_unavailable") {
throw buildBusinessValidationError("payment_batch.receipt_key_unavailable", {}, 409);
}
if (error.code === "receipt_key_rotation_required") {
throw buildBusinessValidationError("payment_batch.receipt_key_rotation_required", {}, 409);
}
if (["receipt_bank_unsupported", "receipt_business_unsupported"].includes(error.code)) {
throw buildBusinessValidationError("payment_batch.receipt_snapshot_unsupported", {}, 409);
}
if (error.code === "receipt_review_identity_unresolved") {
throw buildBusinessValidationError("payment_batch.receipt_identity_unresolved", {}, 409);
}
if (["receipt_review_permission_required", "receipt_review_identity_conflict"].includes(error.code)) {
throw buildBusinessValidationError("payment_batch.receipt_independent_review_required", {}, 403);
}
if (error.code === "receipt_transaction_reused") {
throw buildBusinessValidationError("payment_batch.receipt_transaction_reused", {}, 409);
}
if (["receipt_source_changed", "receipt_job_superseded", "receipt_unavailable",
"receipt_manual_not_available", "receipt_manual_not_reviewable", "receipt_batch_not_editable"].includes(error.code)) {
throw buildBusinessValidationError("payment_batch.receipt_manual_state_changed", {}, 409);
}
throw buildBusinessValidationError("payment_batch.receipt_manual_verification_required", {}, 409);
}
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
mode: z.enum(["download", "preview"]).default("download"),
reason: z.string().max(500).optional(),
@@ -123,9 +170,14 @@ function normalizeNullableString(value) {
return trimmed || null;
}
function mapReceiptAttachment(item) {
function mapReceiptAttachment(item, batch) {
return {
id: item.id,
ocrState: item.receipt_ocr_state ?? "legacy",
ocrVersion: item.receipt_ocr_version ?? 0,
ocrStartedAt: item.receipt_ocr_started_at?.toISOString?.() ?? null,
ocrRetryAvailable: batch?.entity_type === "reimbursement" && requiresBankReceipt(batch) &&
canRetryReceiptRecognition(item),
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
@@ -1770,6 +1822,26 @@ async function paymentBatchesRoutes(fastify) {
},
);
fastify.get("/payment-batches/:id/receipt-readiness", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const result = await fastify.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
const batch = await loadBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (!["reviewed", "failed"].includes(batch.status)) return { ready: false };
try {
if (batch.entity_type === "reimbursement") await claimVerifiedReceipts(tx, fastify.config, batch, { readOnly: true });
return { ready: true };
} catch (error) {
if (!(error instanceof ReceiptVerificationError)) throw error;
return { ready: false };
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, maxWait: 5000, timeout: 10000 });
return sendData(reply, request, { item: result });
});
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchReadPermission(fastify, request);
await ensureFeatureEnabled(fastify);
@@ -1783,6 +1855,9 @@ async function paymentBatchesRoutes(fastify) {
select: {
id: true,
original_name: true,
receipt_ocr_state: true,
receipt_ocr_version: true,
receipt_ocr_started_at: true,
mime_type: true,
file_size: true,
file_sha256: true,
@@ -1807,7 +1882,7 @@ async function paymentBatchesRoutes(fastify) {
},
});
return sendData(reply, request, { items: items.map(mapReceiptAttachment) });
return sendData(reply, request, { items: items.map((item) => mapReceiptAttachment(item, batch)) });
});
fastify.post("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -1857,6 +1932,7 @@ async function paymentBatchesRoutes(fastify) {
file_size: file.fileSize,
file_sha256: file.fileSha256,
uploaded_by: request.employee.id,
receipt_ocr_state: lockedBatch.entity_type === "reimbursement" && requiresBankReceipt(lockedBatch) ? "queued" : "legacy",
...governanceCreateData({}, {
governanceStatus: "approved",
sensitivityLevel: SENSITIVITY_LEVEL.confidential,
@@ -1867,6 +1943,9 @@ async function paymentBatchesRoutes(fastify) {
select: {
id: true,
original_name: true,
receipt_ocr_state: true,
receipt_ocr_version: true,
receipt_ocr_started_at: true,
mime_type: true,
file_size: true,
file_sha256: true,
@@ -1905,7 +1984,7 @@ async function paymentBatchesRoutes(fastify) {
},
}),
});
return created;
return created.map((item) => mapReceiptAttachment(item, lockedBatch));
});
} catch (error) {
@@ -1918,7 +1997,7 @@ async function paymentBatchesRoutes(fastify) {
throw error;
}
return sendData(reply, request, { items: createdItems.map(mapReceiptAttachment) });
return sendData(reply, request, { items: createdItems });
});
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/file", { preHandler: fastify.authenticate }, async (request, reply) => {
@@ -2106,6 +2185,95 @@ async function paymentBatchesRoutes(fastify) {
throw buildBusinessValidationError("payment_batch.receipt_void_required", { receiptAttachment: "void_required" }, 409);
});
fastify.get("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchReadPermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
try {
const result = await fastify.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SET TRANSACTION READ ONLY`;
const batch = await loadBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
return readManualReceiptDetail(tx, fastify.config, { batchId, attachmentId, reviewer: request.employee,
hasReviewPermission: fastify.hasPermission(request.employee, "payment.batch.review") });
}, { isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead, maxWait: 5000, timeout: 10000 });
return sendData(reply, request, { item: result });
} catch (error) {
mapManualReceiptError(error);
}
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const body = parseBody(RECEIPT_MANUAL_SCHEMA, request.body);
try {
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
return saveManualReceiptProposal(tx, fastify.config, request, { ...body, batchId, attachmentId });
}, { timeout: 10000 });
return sendData(reply, request, { item: result });
} catch (error) {
mapManualReceiptError(error);
}
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/manual-verification/:proposalId/review", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchReviewPermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const proposalId = parseId(request.params?.proposalId, "proposalId");
const body = parseBody(RECEIPT_MANUAL_REVIEW_SCHEMA, request.body);
try {
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
return reviewManualReceiptProposal(tx, fastify.config, request, { ...body, batchId, attachmentId, proposalId },
fastify.hasPermission(request.employee, "payment.batch.review"));
}, { timeout: 10000 });
return sendData(reply, request, { item: result });
} catch (error) {
mapManualReceiptError(error);
}
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/ocr-retry", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
const batchId = parseId(request.params?.id);
const attachmentId = parseId(request.params?.attachmentId, "attachmentId");
const { expectedVersion } = parseBody(z.object({ expectedVersion: z.number().int().min(0).max(2147483646) }).strict(), request.body);
const result = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
if (batch.entity_type !== "reimbursement" || !requiresBankReceipt(batch)) {
throw buildBusinessValidationError("payment_batch.receipt_retry_unavailable", {}, 409);
}
if (!["reviewed", "failed"].includes(batch.status)) {
throw buildBusinessValidationError("payment_batch.receipt_not_editable", {}, 409);
}
let queued;
try {
queued = await queueReceiptRetry(tx, { batchId, attachmentId, expectedVersion });
} catch (error) {
if (!(error instanceof ReceiptVerificationError)) throw error;
throw buildBusinessValidationError("payment_batch.receipt_retry_unavailable", {}, 409);
}
await tx.auditLog.create({ data: buildAuditLogData(request, {
actorId: request.employee.id, action: "PAYMENT_BATCH_RECEIPT_OCR_REQUEUED",
targetType: "payment_batch", targetId: batchId,
extra: { attachmentId, version: queued.receipt_ocr_version },
}) });
return { id: queued.id, ocrState: queued.receipt_ocr_state, ocrVersion: queued.receipt_ocr_version };
}, { timeout: 10000 });
return sendData(reply, request, { item: result });
});
fastify.post("/payment-batches/:id/receipt-attachments/:attachmentId/void", { preHandler: fastify.authenticate }, async (request, reply) => {
ensureBatchManagePermission(fastify, request);
await ensureFeatureEnabled(fastify);
@@ -2346,6 +2514,13 @@ async function paymentBatchesRoutes(fastify) {
);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
await ensureBankReceiptForExecutableBatch(tx, batch);
if (batch.entity_type === "reimbursement" && requiresBankReceipt(batch)) {
try {
await claimVerifiedReceipts(tx, fastify.config, batch);
} catch (error) {
mapManualReceiptError(error);
}
}
const finalItemStatuses = new Map(batch.items.map((item) => [item.id, item.status]));
let attemptSuccessCount = 0;
@@ -0,0 +1,288 @@
const { ReceiptVerificationError, batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
const { buildManualReceiptProposal, assertIndependentReceiptReviewer } = require("./paymentReceiptManualReview");
const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { buildAuditLogData } = require("../utils/audit");
const { normalizeBankSlip } = require("./paymentReceiptMatching");
const { maskBankAccount } = require("./crypto");
const { hasPermission, resolvePermissionsForRoles } = require("../constants/permissions");
function requireFact(condition, code) {
if (!condition) throw new ReceiptVerificationError(code);
}
async function lockContext(tx, { batchId, attachmentId, expectedVersion }) {
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${batchId}::uuid FOR UPDATE`;
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
requireFact(batch?.entity_type === "reimbursement" && ["reviewed", "failed"].includes(batch.status),
"receipt_batch_not_editable");
await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`;
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
requireFact(attachment?.payment_batch_id === batchId && !attachment.archived_at &&
attachment.governance_status === "approved", "receipt_unavailable");
requireFact(Number.isInteger(expectedVersion) && expectedVersion >= 0 && expectedVersion <= 2147483647 &&
attachment.receipt_ocr_version === expectedVersion, "receipt_job_superseded");
return { batch, attachment };
}
async function ensureUnused(tx, config, proposal) {
const current = proposal.recognition.pages.map((page) => transactionFingerprint(config, page));
requireFact(await tx.paymentReceiptClaim.count({ where: { key_id: { not: current[0].key_id } } }) === 0,
"receipt_key_rotation_required");
requireFact(await tx.paymentReceiptClaim.count({ where: { OR: [
{ fingerprint: { in: [...proposal.originalFingerprints, ...current.map((entry) => entry.fingerprint)] } },
{ item_id: { in: proposal.associations.map((entry) => entry.itemId) } },
] } }) === 0, "receipt_transaction_reused");
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: current[0].key_id } } }) === 0,
"receipt_key_rotation_required");
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { fingerprint: {
in: [...proposal.originalFingerprints, ...current.map((entry) => entry.fingerprint)],
} } }) === 0, "receipt_transaction_reused");
}
async function loadApprovedManualReceiptEvidence(tx, config, batch, attachmentIds, readOnly = false) {
const proposals = await tx.paymentReceiptManualProposal.findMany({
where: { attachment_id: { in: attachmentIds }, status: { in: ["approved", "association_ready"] } },
include: { attachment: true, original_verification: true },
});
const evidence = [];
for (const saved of proposals) {
const attachment = saved.attachment;
if (attachment.archived_at || attachment.governance_status !== "approved" ||
attachment.receipt_ocr_version !== saved.source_version || attachment.file_sha256 !== saved.source_sha256 ||
!["manual_approved", "association_ready"].includes(attachment.receipt_ocr_state)) continue;
requireFact(attachment.payment_batch_id === batch.id, "receipt_batch_mismatch");
const original = saved.original_verification;
requireFact(original.attachment_id === attachment.id && original.source_sha256 === saved.source_sha256 &&
original.source_version + 1 === saved.source_version, "receipt_source_changed");
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
const payload = JSON.parse(decryptStringOrThrow(config, saved.payload_encrypted));
const proposal = buildManualReceiptProposal(config, {
original: recognition, pages: safePages(payload.pages), candidates: batchCandidates(config, batch),
});
if (proposal.requiresIndependentReview) {
requireFact(saved.status === "approved" && saved.reviewed_by && saved.reviewed_at, "receipt_review_permission_required");
if (!readOnly) {
await tx.$queryRaw`SELECT id FROM employees WHERE id = ${saved.reviewed_by}::uuid FOR SHARE`;
await tx.$queryRaw`SELECT employee_id FROM employee_roles WHERE employee_id = ${saved.reviewed_by}::uuid FOR SHARE`;
}
const employee = await tx.employee.findUnique({ where: { id: saved.reviewed_by }, include: { roles: true } });
requireFact(employee?.status === "active" && !employee.archived_at, "receipt_review_permission_required");
const roleCodes = employee.roles.map((role) => role.role_code);
const sources = await tx.reimbursementForm.findMany({
where: { id: { in: batch.items.map((item) => item.entity_id) } },
select: { id: true, owner_id: true, submitter_id: true },
});
requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size,
"receipt_review_identity_unresolved");
assertIndependentReceiptReviewer({ reviewer: { id: employee.id, roleCodes },
hasReviewPermission: hasPermission(resolvePermissionsForRoles(roleCodes), "payment.batch.review"),
makerId: batch.created_by, proposedBy: saved.proposed_by,
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
} else {
requireFact(saved.status === "association_ready", "receipt_match_changed");
}
evidence.push({ id: original.id, manual_proposal_id: saved.id, matches: proposal.recognition.pages.map((page) => {
const identity = transactionFingerprint(config, page);
const previous = recognition.pages.find((entry) => entry.pageNumber === page.pageNumber);
const aliases = [identity.fingerprint];
if (previous?.payerAccount && previous.reference) aliases.push(transactionFingerprint(config, previous).fingerprint);
return { ...identity, page_number: page.pageNumber,
item_id: proposal.associations.find((entry) => entry.pageNumber === page.pageNumber).itemId,
aliases: [...new Set(aliases)].sort() };
}) });
}
return evidence;
}
function safePages(pages) {
requireFact(Array.isArray(pages) && pages.length > 0 && pages.length <= 20, "receipt_pages_unresolved");
return pages.map((page) => {
requireFact(Array.isArray(page?.fields) && page.fields.length > 0 && page.fields.length <= 200 &&
page.fields.every((field) => typeof field?.Name === "string" && field.Name.length <= 64 &&
typeof field.Value === "string" && field.Value.length <= 256), "receipt_manual_fields_invalid");
return { pageNumber: page.pageNumber, itemId: page.itemId,
fields: page.fields.map(({ Name, Value }) => ({ Name, Value })) };
});
}
function resolvePageFields(pages, original) {
requireFact(Array.isArray(pages), "receipt_pages_unresolved");
return pages.map((page) => {
const source = original.pages?.find((entry) => entry.pageNumber === page?.pageNumber);
requireFact(source || page?.fields, "receipt_manual_fields_invalid");
const minor = source?.amount;
const amount = typeof minor === "string" && /^\d+$/.test(minor) ?
`${BigInt(minor) / 100n}.${(BigInt(minor) % 100n).toString().padStart(2, "0")}` : "";
const values = {
小写金额: amount, 币别: source?.currency ?? "", 付款人账号: source?.payerAccount ?? "",
收款人账号: source?.payeeAccount ?? "", 凭证号: source?.reference ?? "",
单据状态: source?.transactionStatus === "success" ? "交易成功" : "",
};
if (page.fields !== undefined) {
requireFact(Array.isArray(page.fields) && page.fields.length <= 7 &&
new Set(page.fields.map((field) => field?.Name)).size === page.fields.length &&
page.fields.every((field) => Object.hasOwn(values, field?.Name) && typeof field.Value === "string" &&
field.Value.length <= 256), "receipt_manual_fields_invalid");
for (const field of page.fields) values[field.Name] = field.Value;
}
return { pageNumber: page.pageNumber, itemId: page.itemId,
fields: Object.entries(values).map(([Name, Value]) => ({ Name, Value })) };
});
}
function pageSummary(page) {
const amount = typeof page.amount === "string" && /^\d+$/.test(page.amount) ?
`${BigInt(page.amount) / 100n}.${(BigInt(page.amount) % 100n).toString().padStart(2, "0")}` : null;
return { pageNumber: page.pageNumber, amount, currency: page.currency ?? null,
payerAccountMasked: page.payerAccount ? maskBankAccount(page.payerAccount) : null,
payeeAccountMasked: page.payeeAccount ? maskBankAccount(page.payeeAccount) : null,
transactionStatus: page.transactionStatus ?? null, reference: page.reference ?? null };
}
async function readManualReceiptDetail(tx, config, { batchId, attachmentId, reviewer, hasReviewPermission }) {
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
requireFact(attachment?.payment_batch_id === batchId && !attachment.archived_at &&
attachment.governance_status === "approved", "receipt_unavailable");
const proposal = await tx.paymentReceiptManualProposal.findUnique({
where: { attachment_id_source_version: { attachment_id: attachmentId, source_version: attachment.receipt_ocr_version } },
include: { original_verification: true },
});
const original = proposal?.original_verification ?? await tx.paymentReceiptVerification.findFirst({
where: { attachment_id: attachmentId }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
if (!original) return { version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
pagesExpected: 0, pages: [], proposal: null };
requireFact(original.attachment_id === attachmentId && original.source_sha256 === attachment.file_sha256 &&
original.source_version + (proposal ? 1 : 0) === attachment.receipt_ocr_version &&
(!proposal || proposal.source_sha256 === attachment.file_sha256), "receipt_source_changed");
const recognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
const payload = proposal ? JSON.parse(decryptStringOrThrow(config, proposal.payload_encrypted)) : null;
let canReview = false;
if (proposal?.status === "pending_review" && reviewer) {
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
const sources = await tx.reimbursementForm.findMany({
where: { id: { in: batch.items.map((item) => item.entity_id) } },
select: { id: true, owner_id: true, submitter_id: true },
});
if (["reviewed", "failed"].includes(batch.status) &&
sources.length === new Set(batch.items.map((item) => item.entity_id)).size) {
try {
assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId: batch.created_by,
proposedBy: proposal.proposed_by,
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
canReview = true;
} catch (error) {
if (!(error instanceof ReceiptVerificationError)) throw error;
}
}
}
return {
canReview,
version: attachment.receipt_ocr_version, state: attachment.receipt_ocr_state,
pagesExpected: recognition.pagesExpected, pages: recognition.pages.map(pageSummary),
proposal: proposal ? { id: proposal.id, status: proposal.status, proposedBy: proposal.proposed_by,
reviewedBy: proposal.reviewed_by, reason: payload.reason, changedFields: payload.changedFields,
pages: payload.pages.map((page) => ({
...pageSummary({ pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) }), itemId: page.itemId,
})) } : null,
};
}
function summarize(proposal) {
return { id: proposal.id, status: proposal.status, version: proposal.source_version };
}
async function audit(tx, request, batchId, action, proposal) {
await tx.auditLog.create({ data: buildAuditLogData(request, {
actorId: request.employee.id, action, targetType: "payment_batch", targetId: batchId,
extra: { proposalId: proposal.id, attachmentId: proposal.attachment_id,
version: proposal.source_version, status: proposal.status },
}) });
}
async function saveManualReceiptProposal(tx, config, request, input) {
const { batch, attachment } = await lockContext(tx, input);
requireFact(attachment.receipt_ocr_version < 2147483647, "receipt_job_superseded");
requireFact(["needs_review", "ocr_failed"].includes(attachment.receipt_ocr_state),
"receipt_manual_not_available");
const original = await tx.paymentReceiptVerification.findFirst({
where: { attachment_id: attachment.id }, orderBy: [{ created_at: "desc" }, { id: "desc" }],
});
requireFact(original && original.source_version === attachment.receipt_ocr_version &&
original.source_sha256 === attachment.file_sha256, "receipt_source_changed");
const originalRecognition = JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted));
const pages = safePages(resolvePageFields(input.pages, originalRecognition));
const proposal = buildManualReceiptProposal(config, {
original: originalRecognition,
pages, candidates: batchCandidates(config, batch),
});
requireFact(proposal.associations.every((association) => batch.items.some((item) =>
item.id === association.itemId && ["pending", "failed"].includes(item.status))), "receipt_item_unavailable");
await ensureUnused(tx, config, proposal);
requireFact(!proposal.requiresIndependentReview || (typeof input.reason === "string" &&
input.reason.trim().length > 0 && input.reason.length <= 500), "receipt_manual_reason_required");
const status = proposal.requiresIndependentReview ? "pending_review" : "association_ready";
const saved = await tx.paymentReceiptManualProposal.create({ data: {
attachment_id: attachment.id, original_verification_id: original.id,
source_sha256: attachment.file_sha256, source_version: attachment.receipt_ocr_version + 1,
status, proposed_by: request.employee.id,
payload_encrypted: encryptStringOrThrow(config, JSON.stringify({
pages, reason: proposal.requiresIndependentReview ? input.reason.trim() : "明细关联",
reasonSource: proposal.requiresIndependentReview ? "user" : "system", changedFields: proposal.changedFields,
})),
} });
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: {
receipt_ocr_version: { increment: 1 }, receipt_ocr_state: status, receipt_ocr_started_at: null,
} });
await audit(tx, request, batch.id, "PAYMENT_RECEIPT_MANUAL_PROPOSED", saved);
return summarize(saved);
}
async function reviewManualReceiptProposal(tx, config, request, input, hasReviewPermission) {
const { batch, attachment } = await lockContext(tx, input);
const saved = await tx.paymentReceiptManualProposal.findUnique({ where: { id: input.proposalId },
include: { original_verification: true } });
requireFact(saved?.attachment_id === attachment.id && saved.source_version === attachment.receipt_ocr_version &&
saved.source_sha256 === attachment.file_sha256, "receipt_source_changed");
requireFact(saved.status === "pending_review" && attachment.receipt_ocr_state === "pending_review",
"receipt_manual_not_reviewable");
const sources = await tx.reimbursementForm.findMany({
where: { id: { in: batch.items.map((item) => item.entity_id) } },
select: { id: true, owner_id: true, submitter_id: true },
});
requireFact(sources.length === new Set(batch.items.map((item) => item.entity_id)).size,
"receipt_review_identity_unresolved");
assertIndependentReceiptReviewer({ reviewer: request.employee, hasReviewPermission,
makerId: batch.created_by, proposedBy: saved.proposed_by,
participants: sources.map((source) => ({ ownerId: source.owner_id, submitterId: source.submitter_id })) });
requireFact(["approve", "reject"].includes(input.decision), "receipt_manual_decision_invalid");
requireFact(typeof input.reason === "string" && input.reason.trim().length > 0 &&
input.reason.length <= 500, "receipt_manual_reason_required");
if (input.decision === "approve") {
const original = saved.original_verification;
requireFact(original.attachment_id === attachment.id && original.source_sha256 === saved.source_sha256 &&
original.source_version + 1 === saved.source_version, "receipt_source_changed");
const payload = JSON.parse(decryptStringOrThrow(config, saved.payload_encrypted));
const proposal = buildManualReceiptProposal(config, {
original: JSON.parse(decryptStringOrThrow(config, original.normalized_encrypted)),
pages: safePages(payload.pages), candidates: batchCandidates(config, batch),
});
requireFact(proposal.associations.every((association) => batch.items.some((item) =>
item.id === association.itemId && ["pending", "failed"].includes(item.status))), "receipt_item_unavailable");
await ensureUnused(tx, config, proposal);
}
const status = input.decision === "approve" ? "approved" : "rejected";
const reviewed = await tx.paymentReceiptManualProposal.update({ where: { id: saved.id }, data: {
status, reviewed_by: request.employee.id, reviewed_at: new Date(),
review_note_encrypted: encryptStringOrThrow(config, input.reason.trim()),
} });
await tx.paymentBatchReceiptAttachment.update({ where: { id: attachment.id }, data: {
receipt_ocr_state: status === "approved" ? "manual_approved" : "manual_rejected",
} });
await audit(tx, request, batch.id, "PAYMENT_RECEIPT_MANUAL_REVIEWED", reviewed);
return summarize(reviewed);
}
module.exports = { saveManualReceiptProposal, reviewManualReceiptProposal, readManualReceiptDetail, loadApprovedManualReceiptEvidence };
@@ -0,0 +1,80 @@
const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching");
const { ReceiptVerificationError, transactionFingerprint } = require("./paymentReceiptVerification");
const FIELDS = ["amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference"];
const BLOCKED_ISSUES = ["transaction_failed", "transaction_pending"];
function requireFact(condition, code) {
if (!condition) throw new ReceiptVerificationError(code);
}
function assertIndependentReceiptReviewer({ reviewer, hasReviewPermission, makerId, proposedBy, participants }) {
requireFact(reviewer?.id && hasReviewPermission === true &&
Array.isArray(reviewer.roleCodes) &&
reviewer.roleCodes.some((role) => ["manager", "ceo"].includes(role)),
"receipt_review_permission_required");
requireFact(typeof makerId === "string" && makerId.length > 0 &&
typeof proposedBy === "string" && proposedBy.length > 0 &&
Array.isArray(participants) && participants.length > 0 &&
participants.every((entry) => typeof entry?.ownerId === "string" && entry.ownerId.length > 0 &&
typeof entry.submitterId === "string" && entry.submitterId.length > 0),
"receipt_review_identity_unresolved");
requireFact(![makerId, proposedBy, ...participants.flatMap((entry) => [entry.ownerId, entry.submitterId])]
.includes(reviewer.id), "receipt_review_identity_conflict");
}
function buildManualReceiptProposal(config, { original, pages, candidates }) {
requireFact(original && Number.isInteger(original.pagesExpected) &&
original.pagesExpected > 0 && original.pagesExpected <= 20, "receipt_pages_unresolved");
requireFact(Array.isArray(pages) && pages.length === original.pagesExpected &&
pages.every((page, index) => page?.pageNumber === index + 1), "receipt_pages_unresolved");
requireFact(Array.isArray(candidates) && candidates.length > 0 &&
candidates.every((candidate) => typeof candidate.id === "string" && candidate.id.length > 0) &&
new Set(candidates.map((candidate) => candidate.id)).size === candidates.length,
"receipt_candidates_invalid");
requireFact(Array.isArray(original.pages) &&
original.pages.every((page) => Number.isInteger(page.pageNumber) &&
page.pageNumber > 0 && page.pageNumber <= original.pagesExpected) &&
new Set(original.pages.map((page) => page.pageNumber)).size === original.pages.length,
"receipt_original_invalid");
const originalFingerprints = original.pages
.filter((page) => page.payerAccount && page.reference)
.map((page) => transactionFingerprint(config, page).fingerprint);
requireFact(new Set(originalFingerprints).size === originalFingerprints.length,
"receipt_transaction_reused");
const normalizedPages = [];
const associations = [];
const changedFields = [];
const fingerprints = new Set();
let requiresIndependentReview = original.status !== "recognized" ||
original.pagesProcessed !== original.pagesExpected ||
original.pages.length !== original.pagesExpected;
for (const page of pages) {
const previous = original.pages.find((entry) => entry.pageNumber === page.pageNumber);
requireFact(!previous || (!["failed", "pending"].includes(previous.transactionStatus) &&
!(previous.issues ?? []).some((issue) => BLOCKED_ISSUES.includes(issue))),
"receipt_transaction_not_successful");
const normalized = { pageNumber: page.pageNumber, ...normalizeBankSlip(page.fields) };
const candidate = candidates.find((entry) => entry.id === page.itemId);
requireFact(candidate, "receipt_item_unavailable");
const match = matchBankSlip(normalized, [candidate]);
requireFact(match.status === "matched", "receipt_manual_match_required");
const changed = FIELDS.filter((field) => previous?.[field] !== normalized[field]);
if (changed.length || previous?.issues?.length) requiresIndependentReview = true;
const { fingerprint } = transactionFingerprint(config, normalized);
requireFact(!fingerprints.has(fingerprint), "receipt_transaction_reused");
fingerprints.add(fingerprint);
normalizedPages.push(normalized);
associations.push({ pageNumber: page.pageNumber, itemId: candidate.id });
changedFields.push({ pageNumber: page.pageNumber, fields: changed });
}
requireFact(new Set(associations.map((entry) => entry.itemId)).size === associations.length,
"receipt_multiple_transactions");
return {
requiresIndependentReview, changedFields, associations, originalFingerprints, bankVerified: false,
recognition: { status: "recognized", pagesExpected: pages.length,
pagesProcessed: pages.length, pages: normalizedPages },
};
}
module.exports = { buildManualReceiptProposal, assertIndependentReceiptReviewer };
@@ -0,0 +1,105 @@
const FIELD_ALIASES = {
amount: ["小写金额", "交易金额", "转账金额", "付款金额", "金额"],
currency: ["币别", "币种"],
payerAccount: ["付款人账号", "付款账号"],
payeeAccount: ["收款人账号", "收款账号"],
transactionStatus: ["单据状态", "交易状态"],
reference: ["流水号", "交易流水号", "凭证号", "凭证号码"],
};
function normalizeText(value) {
return typeof value === "string" ? value.normalize("NFKC").trim() : "";
}
function parseReceiptMinorUnits(value) {
const text = normalizeText(value).replace(/^[¥¥]\s*/, "").replace(/\s*元$/, "");
if (!/^(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d{1,2})?$/.test(text)) return null;
const [whole, fraction = ""] = text.replace(/,/g, "").split(".");
if (whole.length > 12) return null;
return (BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"))).toString();
}
function normalizeAccount(value) {
const text = normalizeText(value).replace(/\s/g, "");
return /^\d{6,40}$/.test(text) ? text : null;
}
function normalizeCurrency(value) {
const text = normalizeText(value).toUpperCase();
if (["人民币", "人民币元", "RMB", "CNY"].includes(text)) return "CNY";
return /^[A-Z]{3}$/.test(text) ? text : null;
}
function normalizeStatus(value) {
const text = normalizeText(value);
if (["交易成功", "转账成功", "付款成功", "支付成功"].includes(text)) return "success";
if (["交易失败", "转账失败", "付款失败", "支付失败", "已撤销", "已冲正", "已退汇"].includes(text)) return "failed";
if (["已受理", "受理成功", "处理中", "待处理"].includes(text)) return "pending";
return null;
}
function normalizeBankSlip(fields) {
const result = {
amount: null, currency: null, payerAccount: null, payeeAccount: null,
transactionStatus: null, reference: null, issues: [],
};
if (!Array.isArray(fields) || fields.length === 0 || fields.length > 200) {
result.issues.push("response_invalid");
return result;
}
const parsers = {
amount: parseReceiptMinorUnits, currency: normalizeCurrency,
payerAccount: normalizeAccount, payeeAccount: normalizeAccount,
transactionStatus: normalizeStatus, reference: (value) => normalizeText(value) || null,
};
for (const [key, aliases] of Object.entries(FIELD_ALIASES)) {
let entries = fields.filter((field) => aliases.includes(normalizeText(field?.Name)) && normalizeText(field?.Value));
if (key === "reference" && entries.length) {
const preferred = aliases.find((alias) => entries.some((field) => normalizeText(field.Name) === alias));
entries = entries.filter((field) => normalizeText(field.Name) === preferred);
}
const names = entries.map((field) => normalizeText(field.Name));
const values = entries.map((field) => parsers[key](field.Value));
if (key === "transactionStatus") {
if (values.includes("failed")) result.issues.push("transaction_failed");
if (values.includes("pending")) result.issues.push("transaction_pending");
}
if (new Set(names).size !== names.length || new Set(values).size > 1) {
result.issues.push(`${key}_ambiguous`);
} else if (values.length && values[0] !== null) {
result[key] = values[0];
} else {
result.issues.push(`${key}_unresolved`);
}
}
return result;
}
function matchBankSlip(receipt, candidates) {
const finish = (status, reasons, itemId = null) => ({ status, itemId, reasons, bankVerified: false });
if (receipt.transactionStatus === "failed") return finish("mismatch", ["transaction_failed"]);
const unresolved = [...receipt.issues];
if (receipt.transactionStatus !== "success") unresolved.push("transaction_not_confirmed");
if (unresolved.length) return finish("needs_review", [...new Set(unresolved)]);
if (!Array.isArray(candidates) || !candidates.length) return finish("needs_review", ["candidates_missing"]);
const comparisons = candidates.map((candidate) => {
const expected = {
amount: parseReceiptMinorUnits(candidate.amount), currency: normalizeCurrency(candidate.currency),
payerAccount: normalizeAccount(candidate.payerAccount), payeeAccount: normalizeAccount(candidate.payeeAccount),
};
const invalid = !candidate.id || Object.values(expected).some((value) => value === null);
const reasons = [];
if (expected.amount !== receipt.amount) reasons.push("amount_mismatch");
if (expected.currency !== receipt.currency) reasons.push("currency_mismatch");
if (expected.payerAccount !== receipt.payerAccount) reasons.push("payer_account_mismatch");
if (expected.payeeAccount !== receipt.payeeAccount) reasons.push("payee_account_mismatch");
return { id: candidate.id, invalid, reasons };
});
if (comparisons.some((comparison) => comparison.invalid)) return finish("needs_review", ["candidate_invalid"]);
const matches = comparisons.filter((comparison) => comparison.reasons.length === 0);
if (matches.length === 1) return finish("matched", [], matches[0].id);
if (matches.length > 1) return finish("needs_review", ["multiple_candidates"]);
return finish("mismatch", [...new Set(comparisons.flatMap((comparison) => comparison.reasons))]);
}
module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits };
+66
查看文件
@@ -0,0 +1,66 @@
const { PDFDocument } = require("pdf-lib");
const sharp = require("sharp");
const { normalizeBankSlip } = require("./paymentReceiptMatching");
function createReceiptOcrClient(config) {
if (config?.ocrProvider && config.ocrProvider !== "tencent") return null;
const Tencent = require("tencentcloud-sdk-nodejs");
if (!config.tencentSecretId || !config.tencentSecretKey) return null;
return new Tencent.ocr.v20181119.Client({
credential: { secretId: config.tencentSecretId, secretKey: config.tencentSecretKey },
region: config.tencentOcrRegion,
profile: { httpProfile: { endpoint: "ocr.tencentcloudapi.com", reqTimeout: 30 } },
});
}
async function recognizePaymentReceipt({ buffer, mimeType, config, createClient = createReceiptOcrClient }) {
const result = { status: "needs_review", pagesExpected: 0, pagesProcessed: 0, pages: [], reason: null };
if (!Buffer.isBuffer(buffer) || !buffer.length || buffer.length > 5 * 1024 * 1024) {
return { ...result, reason: "receipt_size_invalid" };
}
let payload = buffer;
const isPdf = mimeType === "application/pdf";
try {
if (isPdf) {
if (buffer.subarray(0, 5).toString() !== "%PDF-") throw new Error("invalid_pdf");
const document = await PDFDocument.load(buffer);
result.pagesExpected = document.getPageCount();
if (result.pagesExpected < 1 || result.pagesExpected > 20) return { ...result, reason: "receipt_page_limit" };
} else {
if (!["image/png", "image/jpeg", "image/webp"].includes(mimeType)) {
return { ...result, reason: "receipt_format_unsupported" };
}
const image = sharp(buffer, { limitInputPixels: 40000000 });
const metadata = await image.metadata();
const expectedFormat = { "image/png": "png", "image/jpeg": "jpeg", "image/webp": "webp" }[mimeType];
if (metadata.format !== expectedFormat || (metadata.pages ?? 1) !== 1) {
return { ...result, reason: "receipt_image_invalid" };
}
payload = await image.rotate().png().toBuffer();
result.pagesExpected = 1;
}
} catch {
return { ...result, reason: "receipt_decode_failed" };
}
const encoded = payload.toString("base64");
if (encoded.length > 7 * 1024 * 1024) return { ...result, reason: "receipt_encoded_size_limit" };
let client;
try {
client = createClient(config);
if (!client || typeof client.BankSlipOCR !== "function") {
return { ...result, status: "ocr_failed", reason: "receipt_ocr_unavailable" };
}
for (let pageNumber = 1; pageNumber <= result.pagesExpected; pageNumber += 1) {
const response = await client.BankSlipOCR({
ImageBase64: encoded, IsPdf: isPdf, ...(isPdf ? { PdfPageNumber: pageNumber } : {}),
});
result.pages.push({ pageNumber, ...normalizeBankSlip(response?.BankSlipInfos) });
result.pagesProcessed += 1;
}
} catch {
return { ...result, status: "ocr_failed", reason: "receipt_ocr_request_failed" };
}
return { ...result, status: result.pages.every((page) => page.issues.length === 0) ? "recognized" : "needs_review" };
}
module.exports = { recognizePaymentReceipt };
@@ -0,0 +1,213 @@
const { createHmac, createHash } = require("node:crypto");
const { encryptStringOrThrow, decryptStringOrThrow, resolveFieldEncryptionKey } = require("./crypto");
const { matchBankSlip } = require("./paymentReceiptMatching");
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
const { resolveItemPaymentChannel } = require("./paymentProcessingProgress");
class ReceiptVerificationError extends Error {
constructor(code) {
super(code);
this.name = "ReceiptVerificationError";
this.code = code;
}
}
function requireFact(condition, code) {
if (!condition) throw new ReceiptVerificationError(code);
}
function receiptKey(config) {
const key = resolveFieldEncryptionKey(config);
requireFact(key, "receipt_key_unavailable");
return { key, keyId: createHash("sha256").update(key).digest("hex") };
}
function transactionFingerprint(config, receipt) {
const { key, keyId } = receiptKey(config);
requireFact(receipt.payerAccount && receipt.reference, "receipt_identity_unresolved");
const fingerprint = createHmac("sha256", key)
.update(JSON.stringify(["ccb-receipt-v1", receipt.payerAccount, receipt.reference])).digest("hex");
return { fingerprint, key_id: keyId };
}
function batchCandidates(config, batch) {
receiptKey(config);
requireFact(batch.entity_type === "reimbursement", "receipt_business_unsupported");
const payer = batch.payment_account_snapshot_json;
requireFact(payer?.bankInstitutionCode === "CCB", "receipt_bank_unsupported");
const payerAccount = decryptStringOrThrow(config, payer.accountNumberEncrypted);
return batch.items.filter((item) => resolveItemPaymentChannel(batch, item) === "bank").map((item) => {
const payee = item.snapshot_json?.payee;
requireFact(payee?.bankInstitutionCode === "CCB", "receipt_bank_unsupported");
return {
id: item.id, amount: String(item.amount), currency: item.currency_code,
payerAccount, payeeAccount: decryptStringOrThrow(config, payee.bankAccountEncrypted),
};
});
}
function buildVerificationData(config, batch, attachment, recognition) {
requireFact(attachment.payment_batch_id === batch.id && !attachment.archived_at &&
attachment.governance_status === "approved", "receipt_unavailable");
const candidates = batchCandidates(config, batch);
const pages = recognition.pages ?? [];
const complete = recognition.status === "recognized" && recognition.pagesExpected > 0 &&
recognition.pagesExpected <= 20 && recognition.pagesProcessed === recognition.pagesExpected &&
pages.length === recognition.pagesExpected &&
pages.every((page, index) => page.pageNumber === index + 1);
const matches = [];
if (complete) {
for (const page of pages) {
const match = matchBankSlip(page, candidates);
if (match.status !== "matched") break;
matches.push({ page_number: page.pageNumber, item_id: match.itemId, ...transactionFingerprint(config, page) });
}
}
const matched = complete && matches.length === pages.length &&
new Set(matches.map((match) => match.item_id)).size === matches.length &&
new Set(matches.map((match) => match.fingerprint)).size === matches.length;
return {
attachment_id: attachment.id, source_sha256: attachment.file_sha256,
source_version: attachment.receipt_ocr_version,
status: matched ? "matched" : "needs_review",
pages_expected: recognition.pagesExpected, pages_processed: recognition.pagesProcessed,
normalized_encrypted: encryptStringOrThrow(config, JSON.stringify({
status: recognition.status, pagesExpected: recognition.pagesExpected,
pagesProcessed: recognition.pagesProcessed, reason: recognition.reason,
pages: pages.map((page) => Object.fromEntries(
["pageNumber", "amount", "currency", "payerAccount", "payeeAccount", "transactionStatus", "reference", "issues"]
.map((key) => [key, page[key]]),
)),
})),
...(matched ? { matches: { create: matches } } : {}),
};
}
async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {}) {
const pending = batch.items.filter((item) => ["pending", "failed"].includes(item.status) &&
resolveItemPaymentChannel(batch, item) === "bank");
if (!pending.length) return;
const { keyId } = receiptKey(config);
if (!readOnly) await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
requireFact(await tx.paymentReceiptClaim.count({ where: { key_id: { not: keyId } } }) === 0,
"receipt_key_rotation_required");
requireFact(await tx.paymentReceiptTransactionAlias.count({ where: { key_id: { not: keyId } } }) === 0,
"receipt_key_rotation_required");
const attachments = readOnly ? await tx.paymentBatchReceiptAttachment.findMany({
where: { payment_batch_id: batch.id }, select: { id: true }, orderBy: { id: "asc" },
}) : await tx.$queryRaw`
SELECT id FROM payment_batch_receipt_attachments
WHERE payment_batch_id = ${batch.id}::uuid ORDER BY id FOR UPDATE
`;
const verifications = [];
for (const attachment of attachments) {
const verification = await tx.paymentReceiptVerification.findFirst({
where: { attachment_id: attachment.id },
orderBy: [{ created_at: "desc" }, { id: "desc" }],
include: { attachment: true, matches: true },
});
if (!verification || verification.status !== "matched" || verification.attachment.archived_at ||
verification.attachment.governance_status !== "approved" ||
verification.source_sha256 !== verification.attachment.file_sha256 ||
verification.source_version !== verification.attachment.receipt_ocr_version) continue;
requireFact(verification.attachment.payment_batch_id === batch.id, "receipt_batch_mismatch");
const recognition = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted));
const rebuilt = buildVerificationData(config, batch, verification.attachment, recognition);
requireFact(rebuilt.status === "matched", "receipt_match_changed");
requireFact(verification.pages_expected === recognition.pagesExpected &&
verification.pages_processed === recognition.pagesProcessed, "receipt_pages_changed");
const expected = rebuilt.matches.create;
requireFact(expected.length === verification.matches.length &&
expected.every((match) => verification.matches.some((saved) =>
["page_number", "item_id", "fingerprint", "key_id"].every((key) => match[key] === saved[key]))),
"receipt_match_changed");
verifications.push(verification);
}
const { loadApprovedManualReceiptEvidence } = require("./paymentReceiptManualPersistence");
verifications.push(...await loadApprovedManualReceiptEvidence(tx, config, batch, attachments.map((entry) => entry.id), readOnly));
const claims = [];
for (const item of pending) {
const options = verifications.flatMap((verification) => verification.matches
.filter((match) => match.item_id === item.id)
.map((match) => ({ ...match, aliases: match.aliases ?? [match.fingerprint],
verification_id: verification.id, manual_proposal_id: verification.manual_proposal_id ?? null })));
requireFact(options.length > 0, "receipt_verification_required");
requireFact(new Set(options.map((option) => option.fingerprint)).size === 1, "receipt_multiple_transactions");
requireFact(new Set(options.map((option) => JSON.stringify(option.aliases))).size === 1, "receipt_multiple_transactions");
const match = options[0];
requireFact(match.key_id === keyId, "receipt_key_rotation_required");
const existing = await tx.paymentReceiptClaim.findMany({
where: { OR: [{ fingerprint: { in: match.aliases } }, { item_id: item.id }] },
});
requireFact(existing.every((claim) => claim.fingerprint === match.fingerprint && claim.item_id === item.id),
"receipt_transaction_reused");
const existingAliases = await tx.paymentReceiptTransactionAlias.findMany({
where: { fingerprint: { in: match.aliases } },
});
requireFact(existingAliases.every((alias) => alias.claim_fingerprint === match.fingerprint && alias.key_id === keyId),
"receipt_transaction_reused");
claims.push({ match, exists: existing.length > 0, existingAliases });
}
requireFact(new Set(claims.map(({ match }) => match.fingerprint)).size === claims.length,
"receipt_transaction_reused");
const aliasOwners = new Map();
for (const { match } of claims) {
for (const alias of match.aliases) {
requireFact(!aliasOwners.has(alias) || aliasOwners.get(alias) === match.item_id, "receipt_transaction_reused");
aliasOwners.set(alias, match.item_id);
}
}
if (readOnly) return;
for (const { match, exists, existingAliases } of claims) {
if (!exists) {
await tx.paymentReceiptClaim.create({ data: {
fingerprint: match.fingerprint, key_id: match.key_id,
item_id: match.item_id, verification_id: match.verification_id, manual_proposal_id: match.manual_proposal_id,
} });
}
for (const fingerprint of match.aliases) {
if (!existingAliases.some((alias) => alias.fingerprint === fingerprint)) {
await tx.paymentReceiptTransactionAlias.create({ data: {
fingerprint, key_id: keyId, claim_fingerprint: match.fingerprint,
} });
}
}
}
}
async function storeReceiptRecognition(tx, config, { batchId, attachmentId, sourceSha256, expectedVersion, recognition }) {
await tx.$queryRaw`SELECT id FROM payment_batches WHERE id = ${batchId}::uuid FOR UPDATE`;
const batch = await tx.paymentBatch.findUnique({ where: { id: batchId }, include: { items: true } });
requireFact(batch && ["reviewed", "failed"].includes(batch.status), "receipt_batch_not_editable");
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`;
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
requireFact(attachment && attachment.file_sha256 === sourceSha256, "receipt_source_changed");
requireFact(Number.isInteger(expectedVersion) && expectedVersion >= 0 &&
attachment.receipt_ocr_version === expectedVersion, "receipt_job_superseded");
const data = buildVerificationData(config, batch, attachment, recognition);
return tx.paymentReceiptVerification.create({ data, select: { id: true, status: true } });
}
function canRetryReceiptRecognition(attachment, now = new Date()) {
if (attachment.archived_at || attachment.governance_status !== "approved") return false;
const timedOut = attachment.receipt_ocr_state === "processing" && attachment.receipt_ocr_started_at &&
now.getTime() - new Date(attachment.receipt_ocr_started_at).getTime() >= 15 * 60 * 1000;
return Boolean(timedOut || ["legacy", "ocr_failed", "needs_review", "manual_rejected"].includes(attachment.receipt_ocr_state));
}
async function queueReceiptRetry(tx, { batchId, attachmentId, expectedVersion, now = new Date() }) {
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id = ${attachmentId}::uuid FOR UPDATE`;
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
requireFact(attachment && attachment.payment_batch_id === batchId && !attachment.archived_at &&
attachment.governance_status === "approved", "receipt_unavailable");
requireFact(Number.isInteger(expectedVersion) && attachment.receipt_ocr_version === expectedVersion,
"receipt_job_superseded");
requireFact(canRetryReceiptRecognition(attachment, now), "receipt_retry_not_ready");
return tx.paymentBatchReceiptAttachment.update({
where: { id: attachmentId },
data: { receipt_ocr_state: "queued", receipt_ocr_version: { increment: 1 }, receipt_ocr_started_at: null },
select: { id: true, receipt_ocr_state: true, receipt_ocr_version: true },
});
}
module.exports = { ReceiptVerificationError, buildVerificationData, claimVerifiedReceipts, transactionFingerprint, storeReceiptRecognition, queueReceiptRetry, canRetryReceiptRecognition, batchCandidates };
@@ -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();
});
});