新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
220 行
13 KiB
JavaScript
220 行
13 KiB
JavaScript
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(["reimbursement", "loan_repayment"].includes(batch.entity_type), "receipt_business_unsupported");
|
|
if (batch.entity_type === "loan_repayment") requireFact(batch.loan_repayment_id && batch.items.length === 1 &&
|
|
batch.items[0].entity_id === batch.loan_repayment_id && batch.items[0].currency_code === "CNY" &&
|
|
batch.items[0].snapshot_json?.payee?.accountNameEncrypted, "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", "transactionStatusRaw", "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");
|
|
requireFact(await tx.loanBankEventClaim.count({ where: { OR: [{ fingerprint: { in: match.aliases } }, { key_id: { not: keyId } }] } }) === 0, "receipt_transaction_reused");
|
|
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, options, 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 claims.map(({ match, options }) => ({ itemId: match.item_id, fingerprint: match.fingerprint, keyId: match.key_id,
|
|
verificationIds: [...new Set(options.map((option) => option.verification_id))],
|
|
manualProposalIds: [...new Set(options.map((option) => option.manual_proposal_id).filter(Boolean))] }));
|
|
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 };
|