const assert = require('node:assert/strict'); const { randomUUID } = require('node:crypto'); // Real HTTP/auth, bank-evidence upload and OCR supplied by the isolated loan fixture. module.exports = async function recoveryFixture(h) { const { prisma, pool, call, app, finance, manager, independent, dual, actor, config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt } = h; const checks = []; const lender = await verifiedLender('合成恢复出借人', null, '2'); const loan = await confirmedLoan(lender.id, '5000.00', '合成恢复借款'); const balances = () => call(finance, 'GET', `/company-loans/${loan.id}`); let amount = 100; async function setup(kind = 'notpaid', who = finance) { const cents = ++amount; const value = (cents / 100).toFixed(2); const batch = await repay(lender.id, loan.id, value); await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id); const [exp] = await prisma.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id } }); const ref = `SYNTHETIC-RECOVERY-${randomUUID()}`.toUpperCase(); const submission = await register(batch.id, ref, exp.id); await attest(batch.id); let evidenceId; let attachmentId; if (kind === 'failed') { const result = await receipt(batch.id, String(cents), ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' }); attachmentId = result.attachment.id; await call(manager, 'POST', `/payment-batches/${batch.id}/bank-failure-review`, { attachmentId }); } else { evidenceId = await uploadEvidence(batch.id); const fact = await proposeFact(batch.id, { submissionId: submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId }); await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id); } const original = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loanRepaymentId } }); return { batch, submission, ref, cents, evidenceId, attachmentId, original, who }; } const url = (s) => `/payment-batches/${s.batch.id}/loan-release-recoveries`; const start = (s, payload = { requestKey: randomUUID(), reason: '合成补充有效释放依据' }, status = 200, reason) => call(s.who, 'POST', url(s), payload, status, reason); const submit = (s, r, status = 200, reason) => call(s.who, 'POST', `${url(s)}/${r.id}/submit`, {}, status, reason); const review = (s, r, decision = 'approve', who = independent, status = 200, reason) => call(who, 'POST', `${url(s)}/${r.id}/review`, { decision, reason: '合成独立恢复复核' }, status, reason); const cancel = (s, r, key = randomUUID(), status = 200, reason) => call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`, { reason: '合成补证后最终取消', ...(r ? { recoveryId: r.id, requestKey: key } : {}) }, status, reason); const withdraw = (s, r) => call(s.who, 'POST', `${url(s)}/${r.id}/withdraw`, { reason: '合成撤回恢复申请' }); async function voidEvidence(s, evidenceId = s.evidenceId) { await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${evidenceId}/void`, { reason: '合成证据换版' }); } async function repair(s) { const evidenceId = await uploadEvidence(s.batch.id); const fact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId }); await reviewFact(s.batch.id, fact.id); s.evidenceId = evidenceId; return evidenceId; } async function ready() { const s = await setup(); await voidEvidence(s); const r = await start(s); await repair(s); await submit(s, r); await review(s, r); return { s, r }; } // Both historical outcomes recover through uploaded evidence and independent // review; neither original terminal field changes, and only cancellation releases. for (const kind of ['notpaid', 'failed']) { const s = await setup(kind); if (kind === 'notpaid') { await voidEvidence(s); const probeEvidence = await uploadEvidence(s.batch.id); await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: probeEvidence }, 409, 'loan.recovery_required'); } else await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${s.attachmentId}/void`, { reason: '合成失败回单换版' }); const input = { requestKey: randomUUID(), reason: '合成补证请求幂等' }; const r = await start(s, input); assert.equal((await start(s, input)).id, r.id); await start(s, undefined, 409, 'loan.recovery_exists'); if (kind === 'notpaid') await repair(s); else { const failed = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' }); const f = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: failed.attachment.id }); await reviewFact(s.batch.id, f.id); } const before = await balances(); await cancel(s, null, undefined, 409, 'loan.recovery_required'); await submit(s, r); await review(s, r, 'approve', finance, 403); await review(s, r); assert.equal((await balances()).reserved, before.reserved); const cancelKey = randomUUID(); await cancel(s, r, cancelKey); await cancel(s, r, cancelKey); await cancel(s, r, randomUUID(), 409); const after = await balances(); assert.equal(after.outstanding, before.outstanding); assert.equal(Number(after.reserved), Number((Number(before.reserved) - s.cents / 100).toFixed(2))); const original = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.original.id } }); assert.equal(original.bank_result_state, s.original.bank_result_state); assert.equal(original.bank_result_ref, s.original.bank_result_ref); await start(s, undefined, 409, 'loan.recovery_unavailable'); await call(finance, 'POST', `/payment-batches/${s.batch.id}/payment-order`, {}, 409); await call(finance, 'POST', `/payment-batches/${s.batch.id}/execute`, {}, 409); assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_RELEASE_RECOVERY_CONSUMED', target_id: s.original.id } }), 1); } checks.push('sr_both_terminal_outcomes_recover_without_rewriting_history_idempotent_release'); // Withdraw/reject never revive the original release path, even when its // original documents remain valid. A new round is required and can succeed. for (const decision of ['withdraw', 'reject']) { const s = await setup(); const r = await start(s); if (decision === 'withdraw') await withdraw(s, r); else { await submit(s, r); await review(s, r, 'reject'); } await cancel(s, null, undefined, 409, 'loan.recovery_required'); const next = await start(s); assert.equal(next.sequence, 2); await submit(s, next); await review(s, next); await cancel(s, next); } checks.push('sr_withdraw_reject_require_fresh_round_no_original_conclusion_fallback'); // Evidence void after approval: the current round becomes stale, can be // superseded and repaired. Returning to old evidence never restores approval. { const { s, r } = await ready(); const before = await balances(); await voidEvidence(s); await cancel(s, r, undefined, 409); const state = await call(finance, 'GET', url(s)); assert.equal(state.current.stale, true); assert.equal((await balances()).reserved, before.reserved); const next = await start(s); await repair(s); await submit(s, next); await review(s, next); await cancel(s, next); assert.equal((await prisma.loanReleaseRecovery.findUnique({ where: { id: r.id } })).status, 'superseded'); } checks.push('sr_stale_approval_can_be_superseded_and_repaired'); // New submission then withdrawal, followed by a fresh attestation, cannot // revive a saved approval. It also cannot erase an unexplained receipt. { const { s, r } = await ready(); const extra = await register(s.batch.id, `SYNTHETIC-EXTRA-${randomUUID()}`.toUpperCase(), null); await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成登记纠错' }); await attest(s.batch.id); await cancel(s, r, undefined, 409, 'loan.recovery_stale'); const next = await start(s); await submit(s, next); await review(s, next); await cancel(s, next); } checks.push('sr_submission_aba_does_not_revive_approval'); // Candidate membership changed in another batch: target's material revision // is unchanged, but its saved candidate set must no longer authorize release. { const { s, r } = await ready(); const competitor = await repay(lender.id, loan.id, (s.cents / 100).toFixed(2)); await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {}); await register(competitor.id, `SYNTHETIC-CANDIDATE-${randomUUID()}`.toUpperCase(), null); await cancel(s, r, undefined, 409, 'loan.recovery_stale'); const next = await start(s); await submit(s, next); await review(s, next); await cancel(s, next); } checks.push('sr_new_same_amount_candidate_invalidates_approval'); // A newly discovered second submission needs its own independently reviewed // terminal fact; the original termination cannot stand in for it. { const s = await setup(); const r = await start(s); const extra = await register(s.batch.id, `SYNTHETIC-MISSING-${randomUUID()}`.toUpperCase(), null); await attest(s.batch.id); await submit(s, r, 409, 'loan.terminal_coverage_incomplete'); const ev = await uploadEvidence(s.batch.id); const fact = await proposeFact(s.batch.id, { submissionId: extra.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: ev }); await reviewFact(s.batch.id, fact.id); await submit(s, r); await review(s, r); await cancel(s, r); } checks.push('sr_every_additional_submission_needs_own_terminal_fact'); // A late successful receipt blocks recovery even after the receipt is voided. { const s = await setup(); const r = await start(s); const paid = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'success' }); await submit(s, r, 409, 'loan.receipt_unresolved_for_release'); await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${paid.attachment.id}/void`, { reason: '合成成功回单作废不抹除事实' }); await submit(s, r, 409, 'loan.receipt_unresolved_for_release'); await cancel(s, null, undefined, 409, 'loan.recovery_required'); } checks.push('sr_success_fact_blocks_release_even_after_void'); // Physical bytes, not just a still-valid database ID, must match the evidence. { const s = await setup(); const r = await start(s); const ev = await prisma.loanBankTerminalEvidence.findUnique({ where: { id: s.evidenceId } }); const { readFileBuffer, writeBuffer } = req('./src/services/storage'); const original = await readFileBuffer({ config, storagePath: ev.storage_path }); await writeBuffer({ config, key: ev.storage_path, buffer: Buffer.from('synthetic corrupted document') }); try { await submit(s, r, 409, 'loan.terminal_evidence_invalid'); } finally { await writeBuffer({ config, key: ev.storage_path, buffer: original }); } await submit(s, r); await review(s, r); await cancel(s, r); } checks.push('sr_physical_evidence_digest_revalidated'); // Re-uploading the same failed transaction after approval changes evidence, // so approval must be replaced even though the bank reference is identical. { const s = await setup('failed'); const r = await start(s); const oldFact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: s.attachmentId }); await reviewFact(s.batch.id, oldFact.id); await submit(s, r); await review(s, r); await call(finance, 'POST', `/payment-batches/${s.batch.id}/receipt-attachments/${s.attachmentId}/void`, { reason: '合成同交易换回单' }); const replacement = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'failed' }); await cancel(s, r, undefined, 409); const next = await start(s); const newFact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: replacement.attachment.id }); await reviewFact(s.batch.id, newFact.id); await submit(s, next); await review(s, next); await cancel(s, next); } checks.push('sr_same_transaction_new_receipt_requires_fresh_review'); // Same-instruction terminal evidence resolves a pending receipt in recovery. { const s = await setup(); const r = await start(s); const pending = await receipt(s.batch.id, String(s.cents), s.ref, { payeeAccount: lender.bankAccount, transactionStatus: 'pending' }); await submit(s, r, 409, 'loan.receipt_unresolved_for_release'); await voidEvidence(s); const ev = await uploadEvidence(s.batch.id); const fact = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: ev, resolvesAttachmentIds: [pending.attachment.id] }); await reviewFact(s.batch.id, fact.id); await submit(s, r); await review(s, r); await cancel(s, r); } checks.push('sr_pending_receipt_resolved_by_same_instruction_terminal_fact'); // Finance+manager cannot review own round; current reviewer departure blocks // approval, while old approver departure does not erase the bank conclusion. { const s = await setup('notpaid', dual); const r = await start(s); await submit(s, r); await review(s, r, 'approve', dual, 403); const departed = await actor(['manager']); await prisma.employee.update({ where: { id: departed.id }, data: { status: 'archived' } }); await review(s, r, 'approve', departed, 403); await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } }); try { await review(s, r); await cancel(s, r); } finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); } } checks.push('sr_current_independence_and_historical_approver_departure'); // Wait applies at submit, approve and final release (test configuration only). { const s = await setup(); const r = await start(s); config.loanReleaseWaitDays = 3; await submit(s, r, 409, 'loan.release_wait_period'); config.loanReleaseWaitDays = 0; await submit(s, r); config.loanReleaseWaitDays = 3; await review(s, r, 'approve', independent, 409, 'loan.release_wait_period'); config.loanReleaseWaitDays = 0; await review(s, r); config.loanReleaseWaitDays = 3; await cancel(s, r, undefined, 409, 'loan.release_wait_period'); config.loanReleaseWaitDays = 0; await cancel(s, r); } checks.push('sr_wait_period_on_submission_approval_and_final_cancel'); // Real SQL rollback: fail at the LAST audit, after recovery consumption, // allocation release and batch cancellation; all must roll back together. { const { s, r } = await ready(); const before = await balances(); await assert.rejects(prisma.loanReleaseRecovery.update({ where: { id: r.id }, data: { status: 'superseded', ended_at: new Date(), snapshot_json: {} } })); await assert.rejects(prisma.loanReleaseMaterialRevision.update({ where: { payment_batch_id: s.batch.id }, data: { revision: 0 } })); await assert.rejects(prisma.loanReleaseMaterialRevision.delete({ where: { payment_batch_id: s.batch.id } })); await assert.rejects(prisma.$transaction(async tx => { await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: s.batch.loanRepaymentId }, data: { reservation_state: 'released', released_at: new Date(), release_reason: '合成绕过恢复批准' } }); await tx.paymentBatch.update({ where: { id: s.batch.id }, data: { status: 'cancelled' } }); })); await pool.query(`CREATE FUNCTION synthetic_sr_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='PAYMENT_BATCH_CANCELLED' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$`); await pool.query('CREATE TRIGGER synthetic_sr_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_sr_audit_failure()'); try { await cancel(s, r, undefined, 500); } finally { await pool.query('DROP TRIGGER synthetic_sr_audit_failure ON audit_logs'); await pool.query('DROP FUNCTION synthetic_sr_audit_failure()'); } assert.equal((await balances()).reserved, before.reserved); assert.equal((await prisma.paymentBatch.findUnique({ where: { id: s.batch.id } })).status, 'reviewed'); assert.equal((await prisma.loanReleaseRecovery.findUnique({ where: { id: r.id } })).status, 'approved'); await cancel(s, r); await assert.rejects(prisma.loanReleaseRecovery.update({ where: { id: r.id }, data: { status: 'approved' } })); await assert.rejects(prisma.loanReleaseRecovery.delete({ where: { id: r.id } })); const ev = await prisma.loanReleaseRecoveryEvent.findFirst({ where: { recovery_id: r.id } }); await assert.rejects(prisma.loanReleaseRecoveryEvent.update({ where: { id: ev.id }, data: { note: '篡改' } })); await assert.rejects(prisma.loanReleaseRecoveryEvent.delete({ where: { id: ev.id } })); } checks.push('sr_atomic_release_rollback_and_database_history_guards'); // Deterministic batch-lock ordering: mutation-first invalidates approval, // release-first closes the evidence. Observe actual SQL waits, not timing luck. const { Pool } = req('pg'); const lockPool = new Pool({ ...pool.options, max: 3 }); const waiters = async (n) => { for (let i = 0; i < 300; i++) { const r = await lockPool.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type='Lock' AND datname=current_database()"); if (r.rows[0].n >= n) return; await new Promise((resolve) => setTimeout(resolve, 10)); } throw new Error('recovery requests did not queue on database lock'); }; try { for (const mutationKind of ['evidence', 'submission']) for (const releaseFirst of [true, false]) { const { s, r } = await ready(); const key = randomUUID(); const holder = await lockPool.connect(); const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers, payload: { reason: '合成并发释放', recoveryId: r.id, requestKey: key } }); const mutation = () => mutationKind === 'evidence' ? app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-terminal-evidences/${s.evidenceId}/void`, headers: finance.headers, payload: { reason: '合成并发作废' } }) : app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-submissions`, headers: finance.headers, payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: `SYNTHETIC-RACE-${randomUUID()}`.toUpperCase(), note: '合成并发补登提交' } }); let first; let second; try { await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [s.batch.id]); first = releaseFirst ? release() : mutation(); await waiters(1); second = releaseFirst ? mutation() : release(); await waiters(2); await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]); } finally { await holder.query('ROLLBACK'); holder.release(); } assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 409); const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } }); assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved'); } // Other-batch candidate formation shares the lender lock with release. for (const releaseFirst of [true, false]) { const { s, r } = await ready(); const competitor = await repay(lender.id, loan.id, (s.cents / 100).toFixed(2)); await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {}); const holder = await lockPool.connect(); const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers, payload: { reason: '合成候选并发释放', recoveryId: r.id, requestKey: randomUUID() } }); const mutation = () => app.inject({ method: 'POST', url: `/api/payment-batches/${competitor.id}/loan-bank-submissions`, headers: finance.headers, payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: `SYNTHETIC-CANDIDATE-RACE-${randomUUID()}`.toUpperCase(), note: '合成候选并发提交' } }); let first; let second; try { await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [lender.id]); first = releaseFirst ? release() : mutation(); await waiters(1); second = releaseFirst ? mutation() : release(); await waiters(2); await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]); } finally { await holder.query('ROLLBACK'); holder.release(); } assert.equal(first.statusCode, 200); assert.equal(second.statusCode, releaseFirst ? 200 : 409); const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } }); assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved'); } // Exercise the actual OCR persistence transaction against final release. // The source recognition is the real normalized output from the failed PDF. for (const releaseFirst of [true, false]) { const s = await setup('failed'); const r = await start(s); const f = await proposeFact(s.batch.id, { submissionId: s.submission.id, kind: 'failure_receipt', receiptAttachmentId: s.attachmentId }); await reviewFact(s.batch.id, f.id); await submit(s, r); await review(s, r); const attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: s.attachmentId } }); const verification = await prisma.paymentReceiptVerification.findFirst({ where: { attachment_id: s.attachmentId }, orderBy: { created_at: 'desc' } }); const recognition = JSON.parse(req('./src/services/crypto').decryptStringOrThrow(config, verification.normalized_encrypted)); const holder = await lockPool.connect(); const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers, payload: { reason: '合成核验落库并发释放', recoveryId: r.id, requestKey: randomUUID() } }); const mutation = () => prisma.$transaction(tx => req('./src/services/paymentReceiptVerification').storeReceiptRecognition(tx, config, { batchId: s.batch.id, attachmentId: s.attachmentId, sourceSha256: attachment.file_sha256, expectedVersion: attachment.receipt_ocr_version, recognition })) .then(() => ({ statusCode: 200 }), error => { if (error.code === 'receipt_batch_not_editable') return { statusCode: 409 }; throw error; }); let first; let second; try { await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [s.batch.id]); first = releaseFirst ? release() : mutation(); await waiters(1); second = releaseFirst ? mutation() : release(); await waiters(2); await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]); } finally { await holder.query('ROLLBACK'); holder.release(); } assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 409); const allocation = await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } }); assert.equal(allocation.reservation_state, releaseFirst ? 'released' : 'reserved'); } } finally { await lockPool.end(); } checks.push('sr_final_release_and_evidence_or_submission_deterministic_both_orders'); checks.push('sr_candidate_formation_and_ocr_persistence_serialize_with_final_release'); return checks; };