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