const assert = require('node:assert/strict'); const { randomUUID } = require('node:crypto'); // Two real authenticated employees perform every related-party action. Other // actors are used only for refusal tests; all documents and accounts are synthetic. module.exports = async function relatedFixture(h) { const { prisma, pool, app, req, call, finance, manager, ordinary, independent, config, verifiedLender, confirmedLoan, repay, exportOrder, receipt } = h; const checks = []; const base = b => `/payment-batches/${b.id}`; const approve = (b, who = manager, status = 200) => call(who, 'POST', `${base(b)}/review`, {}, status); const cancel = (b, extra = {}, status = 200, reason) => call(finance, 'POST', `${base(b)}/cancel`, { reason: '合成有依据释放占用', ...extra }, status, reason); const balance = s => call(finance, 'GET', `/company-loans/${s.loan.id}`); const action = (a, name, who = finance, status = 200) => call(who, 'POST', `/loan-related-authorizations/${a.id}/actions`, { action: name, reason: '合成公司授权范围确认或停止' }, status); async function authorization(lender, loans, confirm = true) { const body = { lenderId: lender.id, loanIds: loans.map(l => l.id), basis: '合成已确认的公司代表权及指定借款范围', requestKey: randomUUID() }; const a = await call(finance, 'POST', '/loan-related-authorizations', body); assert.equal((await call(finance, 'POST', '/loan-related-authorizations', body)).id, a.id); if (confirm) await action(a, 'confirm', manager); return a; } async function setup(principal = '500.00', confirm = true) { const lender = await verifiedLender(`合成关联方${randomUUID().slice(0, 6)}`, manager.id, '6'); const loan = await confirmedLoan(lender.id, principal, `合成关联借入${randomUUID().slice(0, 6)}`); const auth = confirm === null ? null : await authorization(lender, [loan], confirm); return { lender, loan, auth }; } async function batch(s, amount = '10.00', exported = true) { const b = await repay(s.lender.id, s.loan.id, amount); assert.equal(b.loanRepayment.approvalMode, 'related_party'); await approve(b); if (exported) await exportOrder(b.id); return b; } const fields = (s, amount, ref, incoming = false) => [ { Name: '付款账号', Value: incoming ? s.lender.bankAccount : '8'.repeat(16) }, { Name: '收款账号', Value: incoming ? '8'.repeat(16) : s.lender.bankAccount }, { Name: '金额', Value: amount }, { Name: '币种', Value: 'CNY' }, { Name: '交易状态', Value: '交易成功' }, { Name: '流水号', Value: ref }, ]; const pdf = async () => { const doc = await req('pdf-lib').PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]); return Buffer.from(await doc.save()); }; async function evidence(b, kind = 'bank_terminal', who = finance, status = 200) { const boundary = `synthetic-${randomUUID()}`; const res = await app.inject({ method: 'POST', url: `/api${base(b)}/loan-terminal-evidences?kind=${kind}`, headers: { ...who.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), await pdf(), Buffer.from(`\r\n--${boundary}--\r\n`)]) }); assert.equal(res.statusCode, status, `related evidence: ${res.statusCode}`); return res.json().data?.id; } async function submission(b, ref = `RP-${randomUUID()}`, exported = null) { const exp = exported ?? await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: b.id }, orderBy: { sequence: 'desc' } }); return call(finance, 'POST', `${base(b)}/loan-bank-submissions`, { channel: exp ? 'online_banking_import' : 'manual_entry', ...(exp ? { exportId: exp.id } : {}), bankInstructionRef: ref, submittedAt: new Date().toISOString(), note: '合成实际向银行提交的指令' }); } async function attest(b) { const p = await call(finance, 'POST', `${base(b)}/loan-submission-attestation`, { note: '合成逐笔核对全部实际银行提交' }); await call(manager, 'POST', `${base(b)}/loan-submission-attestation/${p.id}/review`, { decision: 'approve', reason: '合成公司代表确认清单完整' }); return p; } async function fact(b, sub, input = {}) { return call(finance, 'POST', `${base(b)}/loan-terminal-facts`, { submissionId: sub.id, kind: 'termination_evidence', evidenceId: await evidence(b), terminalStatus: 'failed', note: '合成银行明确该指令失败且不会再执行', ...input }); } const review = (b, p, type = 'loan-terminal-facts', status = 200, who = manager, more = {}) => call(who, 'POST', `${base(b)}/${type}/${p.id}/review`, { decision: 'approve', reason: '合成公司代表按原件确认银行事实', ...more }, status); const notPaid = (b, status = 200, reason) => call(manager, 'POST', `${base(b)}/loan-not-paid/review`, {}, status, reason); async function terminate(s, b, release = false) { const sub = await submission(b); await attest(b); const f = await fact(b, sub); await review(b, f); await notPaid(b); if (release) await cancel(b); return { sub, f, evidenceId: (await prisma.loanBankTerminalFact.findUniqueOrThrow({ where: { id: f.id } })).evidence_id }; } async function attribution(b, extra = {}) { return call(finance, 'POST', `${base(b)}/loan-receipt-attribution`, { basisKind: 'bank_written_confirmation', basisNote: '合成银行查询唯一定位此交易对应本批次', exclusionNote: '合成逐笔排除其余同金额批次的付款指令', ...extra }); } async function bankEvent(s, b, kind = 'late_success', amount = '80.00', parentId, ref = `RP-EVENT-${randomUUID()}`) { return call(finance, 'POST', `${base(b)}/loan-bank-results`, { kind, ...(parentId ? { parentId } : {}), evidenceId: await evidence(b, kind === 'return' ? 'incoming_return' : 'late_success'), requestKey: randomUUID(), fields: fields(s, amount, ref, kind === 'return'), attributionNote: '合成原件明确对应原批次银行付款', exclusionNote: '合成逐笔排除其他所有相同金额候选' }); } const apply = (b, e, status = 200) => call(finance, 'POST', `${base(b)}/loan-bank-results/${e.id}/apply`, {}, status); // Deliberately corrupt only this isolated fixture's decision metadata to // verify the final consumer independently of the DB write guard. Always // restore both the original context and trigger before leaving the scenario. async function damagedDecision(table, id, check) { assert.ok(['payment_batches', 'payment_receipt_manual_proposals'].includes(table)); const original = (await pool.query(`SELECT loan_approval_context FROM ${table} WHERE id=$1`, [id])).rows[0].loan_approval_context; async function put(context) { const tx = await pool.connect(); try { await tx.query('BEGIN'); await tx.query(`ALTER TABLE ${table} DISABLE TRIGGER loan_decision_context`); await tx.query(`UPDATE ${table} SET loan_approval_context=$1 WHERE id=$2`, [JSON.stringify(context), id]); await tx.query('SET CONSTRAINTS ALL IMMEDIATE'); await tx.query(`ALTER TABLE ${table} ENABLE TRIGGER loan_decision_context`); await tx.query('COMMIT'); } catch (e) { await tx.query('ROLLBACK'); throw e; } finally { tx.release(); } } await put({ ...original, authorizationEventId: 'damaged-synthetic-event' }); try { await check(); } finally { await put(original); } } async function legacyVerification(attachmentId) { const last = await prisma.paymentReceiptVerification.findFirstOrThrow({ where: { attachment_id: attachmentId }, include: { matches: true }, orderBy: { created_at: 'desc' } }); const crypto = req('./src/services/crypto'); const old = JSON.parse(crypto.decryptStringOrThrow(config, last.normalized_encrypted)); delete old.documentPages; for (const page of old.pages) delete page.document; // Seed an old-policy record, preserving every existing immutable record. return prisma.paymentReceiptVerification.create({ data: { attachment_id: last.attachment_id, source_sha256: last.source_sha256, source_version: last.source_version, status: last.status, pages_expected: last.pages_expected, pages_processed: last.pages_processed, normalized_encrypted: crypto.encryptStringOrThrow(config, JSON.stringify(old)), ...(last.matches.length ? { matches: { create: last.matches.map(({ page_number, item_id, fingerprint, key_id }) => ({ page_number, item_id, fingerprint, key_id })) } } : {}) } }); } const originalWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0; const ui = []; try { if (process.env.RUN_ISOLATED_LOAN_RELATED_UI_ONLY !== '1') { { const s = await setup('100.00', null); const old = await repay(s.lender.id, s.loan.id, '1.00'); assert.equal(old.loanRepayment.approvalMode, 'independent'); const a = await authorization(s.lender, [s.loan], false); await action(a, 'confirm', finance, 403); await action(a, 'confirm', ordinary, 403); await approve(old, manager, 403); await action(a, 'confirm', manager); await approve(old, manager, 403); await approve(old, independent); await cancel(old); const outside = await confirmedLoan(s.lender.id, '10.00', `RP-OUT-${randomUUID()}`); await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成范围外申请', allocations: [{ loanId: outside.id, amount: '1.00' }] }, 409, 'loan.related_scope_invalid'); const other = await setup(); await call(finance, 'POST', '/loan-related-authorizations', { lenderId: s.lender.id, loanIds: [other.loan.id], basis: '合成不得跨出借人混入范围', requestKey: randomUUID() }, 409); await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成客户端伪造模式', approvalMode: 'related_party', allocations: [{ loanId: s.loan.id, amount: '1.00' }] }, 400); const b = await batch(s, '2.00', false); await call(ordinary, 'GET', `/loan-related-authorizations/${a.id}`, undefined, 403); await call(independent, 'GET', `/loan-related-authorizations/${a.id}`, undefined, 403); await call(manager, 'GET', '/company-loans', undefined, 403); await call(ordinary, 'GET', base(b), undefined, 403); const scoped = await call(manager, 'GET', `/loan-related-authorizations/${a.id}`); assert.deepEqual(scoped.loans.map(l => l.id), [s.loan.id]); assert.ok(!JSON.stringify(scoped).includes(s.lender.bankAccount)); await call(manager, 'POST', `/company-loans/${outside.id}/evidences/${outside.evidences?.[0]?.id ?? randomUUID()}/download`, { reason: '合成范围外材料', authorizationId: a.id }, 404); await assert.rejects(pool.query("UPDATE loan_repayments SET approval_mode='related_party',authorization_id=$1,approval_context=$2 WHERE id=$3", [a.id, JSON.stringify(b.loanRepayment), old.loanRepaymentId]), e => e.code === '23514'); await assert.rejects(pool.query('UPDATE loan_related_authorizations SET basis=$1 WHERE id=$2', ['合成禁止改写授权', a.id]), e => e.code === '23514'); await assert.rejects(pool.query('DELETE FROM loan_related_authorization_events WHERE authorization_id=$1', [a.id]), e => e.code === '23514'); await assert.rejects(pool.query('INSERT INTO loan_related_authorization_loans(authorization_id,loan_id) VALUES($1,$2)', [a.id, outside.id]), e => e.code === '23514'); const stored = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } }); assert.equal(stored.loan_approval_mode, 'related_party'); assert.equal(stored.loan_authorization_id, a.id); await assert.rejects(pool.query("UPDATE loan_repayments SET approval_context=jsonb_set(approval_context,'{authorizationVersion}','99') WHERE id=$1", [b.loanRepaymentId]), e => e.code === '23514'); await assert.rejects(pool.query('UPDATE payment_batches SET reviewed_by=$1 WHERE id=$2', [finance.id, b.id]), e => e.code === '23514'); await assert.rejects(pool.query("UPDATE payment_batches SET loan_approval_context=jsonb_set(loan_approval_context,'{purpose}','\"reconcile\"') WHERE id=$1", [b.id]), e => e.code === '23514'); await cancel(b); } checks.push('rp_scope_activation_exact_identity_old_mode_immutable_and_scoped_reads'); { const s = await setup('100.00', false); await pool.query(`CREATE FUNCTION synthetic_related_authority_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='LOAN_RELATED_AUTHORIZATION_DECIDED' THEN RAISE EXCEPTION 'synthetic authority audit failure'; END IF; RETURN NEW; END $$`); await pool.query('CREATE TRIGGER synthetic_related_authority_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_related_authority_audit_failure()'); try { await action(s.auth, 'confirm', manager, 500); } finally { await pool.query('DROP TRIGGER synthetic_related_authority_audit_failure ON audit_logs'); await pool.query('DROP FUNCTION synthetic_related_authority_audit_failure()'); } assert.equal(await prisma.loanRelatedAuthorizationEvent.count({ where: { authorization_id: s.auth.id } }), 0); await action(s.auth, 'confirm', manager); const b = await batch(s, '1.00', false); await cancel(b); } checks.push('rp_authority_confirmation_audit_failure_rolls_back_activation'); { const s = await setup(); const oldBatch = await batch(s, '2.00', false); const renewed = await authorization(s.lender, [s.loan]); await action(s.auth, 'stop_payment', finance, 409); await action(s.auth, 'stop_reconcile', manager, 409); assert.equal((await call(manager, 'GET', `/loan-related-authorizations/${renewed.id}`)).paymentEnabled, true); await assert.rejects(pool.query("INSERT INTO loan_related_authorization_events(id,authorization_id,sequence,action,payment_enabled,reconcile_enabled,actor_id,reason) VALUES($1,$2,2,'stop_payment',false,true,$3,'合成旧授权不得假装停止当前范围')", [randomUUID(), s.auth.id, finance.id]), e => e.code === '23514'); const currentBatch = await batch(s, '3.00', false); const pending = await repay(s.lender.id, s.loan.id, '1.00'); const context = (await prisma.paymentBatch.findUniqueOrThrow({ where: { id: currentBatch.id } })).loan_approval_context; const forged = { ...context, targetId: pending.id, batchId: pending.id, repaymentId: pending.loanRepaymentId, approverId: finance.id }; await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed',reviewed_by=$1,reviewed_at=now(),loan_approval_mode='related_party',loan_authorization_id=$2,loan_approval_context=$3 WHERE id=$4", [manager.id, renewed.id, JSON.stringify(forged), pending.id]), e => e.code === '23514'); assert.equal((await prisma.paymentBatch.findUniqueOrThrow({ where: { id: oldBatch.id } })).loan_authorization_id, s.auth.id); await cancel(pending); await cancel(currentBatch); await cancel(oldBatch); } checks.push('rp_renewed_scope_rejects_stale_stop_and_forged_first_decision_actor'); { const s = await setup(); const b = await repay(s.lender.id, s.loan.id, '9.00'); const endpoint = `${base(b)}/review`; await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed',reviewed_by=$1,reviewed_at=now() WHERE id=$2", [manager.id, b.id]), e => e.code === '23514'); await call(finance, 'POST', endpoint, { decision: 'reject', comment: '合成经办人不得代替公司代表退回' }, 403); await call(manager, 'POST', endpoint, { decision: 'reject' }, 400); const before = await balance(s); await call(manager, 'POST', endpoint, { decision: 'reject', comment: '合成付款金额需更正,退回原申请' }); const returned = (await call(finance, 'GET', base(b))).item; assert.equal(returned.status, 'draft'); assert.equal(returned.reviewRequirement.nextStep, 'returned'); assert.equal(returned.relatedPaymentDecision, 'reject'); assert.equal(returned.processingProgress.stages[1].result, '已退回'); assert.equal(returned.processingProgress.stages[2].result, '待财务处理'); assert.equal((await balance(s)).outstanding, before.outstanding); assert.equal((await balance(s)).reserved, before.reserved); await approve(b, manager, 409); await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed' WHERE id=$1", [b.id]), e => e.code === '23514'); await assert.rejects(pool.query("UPDATE payment_batches SET review_comment='合成篡改退回理由' WHERE id=$1", [b.id]), e => e.code === '23514'); const deniedExport = await app.inject({ method: 'POST', url: `/api${base(b)}/payment-order`, headers: finance.headers, payload: {} }); assert.ok(deniedExport.statusCode >= 400 && deniedExport.statusCode < 500); assert.equal(await prisma.paymentOrderExport.count({ where: { payment_batch_id: b.id } }), 0); const notified = await prisma.notification.findMany({ where: { target_id: b.id, type: 'payment_batch.returned' } }); assert.deepEqual(notified.map(n => n.recipient_employee_id), [finance.id]); await cancel(b); assert.equal((await balance(s)).reserved, '0.00'); assert.equal((await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } })).loan_approval_context.decision, 'reject'); const replacement = await batch(s, '8.00', false); assert.notEqual(replacement.id, b.id); await cancel(replacement); } checks.push('rp_return_keeps_reservation_immutable_reason_and_requires_finance_cancel_before_resubmit'); { const s = await setup(); const b = await batch(s); const detail = (await call(manager, 'GET', base(b))).item; assert.equal(detail.loanRepayment.actionCapabilities.canPrepare, false); assert.equal(detail.loanRepayment.actionCapabilities.canReviewReconciliation, true); assert.equal(detail.processingProgress.stages[1].label, '公司代表关联方确认'); const notifications = await prisma.notification.findMany({ where: { target_id: b.id, type: 'payment_batch.review_requested' } }); assert.deepEqual(notifications.map(n => n.recipient_employee_id), [manager.id]); assert.ok(notifications[0].title.includes('关联方')); const audits = await prisma.auditLog.findMany({ where: { target_id: b.id, action: 'PAYMENT_BATCH_REVIEWED' } }); assert.ok(JSON.stringify(audits).includes(s.auth.id)); await action(s.auth, 'stop_payment'); await exportOrder(b.id, 409, 'loan.related_authorization_required'); await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } }); try { const rr = await receipt(b.id, '1000', `RP-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }); assert.equal(rr.result.status, 'matched'); await call(finance, 'POST', `${base(b)}/execute`, {}); assert.equal((await balance(s)).outstanding, '490.00'); } finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); } await call(finance, 'POST', `/loan-repayments/${b.loanRepaymentId}/accounting-handoff`, { reference: '合成已付会计交接' }); } checks.push('rp_two_person_ocr_settlement_survives_payment_stop_and_historical_departure'); { const s = await setup(); const b = await batch(s); await receipt(b.id, '1000', `RP-DAMAGED-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }); const before = await balance(s); await damagedDecision('payment_batches', b.id, async () => { await call(finance, 'POST', `${base(b)}/execute`, {}, 409); const after = await balance(s); assert.equal(after.outstanding, before.outstanding); assert.equal(after.reserved, before.reserved); }); await call(finance, 'POST', `${base(b)}/execute`, {}); } checks.push('rp_final_settlement_rejects_corrupt_approval_provenance_without_money_change'); { const s = await setup(); const b = await batch(s); const rr = await receipt(b.id, null, `RP-MANUAL-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }); assert.equal(rr.result.status, 'needs_review'); const attachment = await prisma.paymentBatchReceiptAttachment.findUniqueOrThrow({ where: { id: rr.attachment.id } }); const endpoint = `${base(b)}/receipt-attachments/${attachment.id}/manual-verification`; const p = (await call(finance, 'POST', endpoint, { expectedVersion: attachment.receipt_ocr_version, reason: '合成原件金额清晰人工录入', pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: [{ Name: '小写金额', Value: '10.00' }] }] })).item; const d = (await call(manager, 'GET', endpoint)).item; assert.equal(d.canReview, true); assert.equal(d.proposal.approvalMode, 'related_party'); await call(manager, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成代表公司核对原件金额' }); await damagedDecision('payment_receipt_manual_proposals', p.id, async () => { await call(finance, 'POST', `${base(b)}/execute`, {}, 409); assert.equal((await balance(s)).outstanding, '500.00'); assert.equal((await balance(s)).reserved, '10.00'); }); await action(s.auth, 'stop_payment'); await action(s.auth, 'stop_reconcile'); // Cold detail/readiness and final consumer must rebuild the saved evidence // from its actual authorization, not apply independent-payee rules again. assert.equal((await call(finance, 'GET', `${base(b)}/receipt-readiness`)).item.ready, true); await call(finance, 'POST', `${base(b)}/execute`, {}); assert.equal((await balance(s)).outstanding, '490.00'); const stored = await prisma.paymentReceiptManualProposal.findUniqueOrThrow({ where: { id: p.id } }); assert.equal(stored.loan_approval_mode, 'related_party'); } checks.push('rp_manual_receipt_decision_cold_reconstruction_and_historical_consumption'); { const s = await setup(); const b = await batch(s); const rr = await receipt(b.id, '1000', `RP-NONFINAL-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }, { document: { final: false } }); assert.equal(rr.result.status, 'needs_review'); const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`; const d = (await call(finance, 'GET', endpoint)).item; assert.equal(d.pages[0].document.kind, 'non_final'); await call(finance, 'POST', `${base(b)}/execute`, {}, 409); await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成不得覆盖非最终说明', pages: [{ pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'explicit_success', originalChecked: true, note: '合成强行声明仍然必须被拦截' }, }] }, 409, 'payment_batch.receipt_final_evidence_required'); assert.equal((await balance(s)).outstanding, '500.00'); assert.equal((await balance(s)).reserved, '10.00'); } checks.push('receipt_nonfinal_fields_match_but_final_settlement_and_manual_override_rejected'); { const s = await setup(); const b = await batch(s); const rr = await receipt(b.id, '1000', `RP-BOOKED-${randomUUID()}`, { payeeAccount: s.lender.bankAccount, transactionStatus: null }); assert.equal(rr.result.status, 'needs_review'); const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`; const d = (await call(finance, 'GET', endpoint)).item; assert.equal(d.pages[0].document.kind, 'bank_receipt'); assert.equal(d.pages[0].transactionStatus, null); await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成无状态也无记账依据', pages: [{ pageNumber: 1, itemId: b.items[0].id }] }, 409, 'payment_batch.receipt_final_evidence_required'); const p = (await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成原件列明公司借方记账信息,未写成功字样', pages: [{ pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'booked_debit', originalChecked: true, bookedOn: '2026-10-01', note: '合成正式回单记载公司付款账户借方已记账支出' }, }] })).item; const original = (await call(manager, 'GET', endpoint)).item; assert.equal(original.pages[0].transactionStatus, null); assert.equal(original.proposal.pages[0].document.proof.basis, 'booked_debit'); await call(finance, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成自审被拒' }, 403); await call(finance, 'POST', `${base(b)}/execute`, {}, 409); await call(manager, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成代表公司核对完整原件及已记账依据' }); assert.equal((await call(finance, 'GET', `${base(b)}/receipt-readiness`)).item.ready, true); await call(finance, 'POST', `${base(b)}/execute`, {}); assert.equal((await balance(s)).outstanding, '490.00'); assert.equal((await balance(s)).reserved, '0.00'); } checks.push('receipt_booked_debit_without_invented_status_two_person_review_and_final_settlement'); { const s = await setup(); const b = await batch(s); const rr = await receipt(b.id, '1000', `RP-CREDIT-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }, { document: { direction: 'credit' } }); assert.equal(rr.result.status, 'needs_review'); const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`; const d = (await call(finance, 'GET', endpoint)).item; await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成借入回单不能用于还本', pages: [{ pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'explicit_success', originalChecked: true, note: '合成方向矛盾必须拒绝' }, }] }, 409, 'payment_batch.receipt_final_evidence_required'); await call(finance, 'POST', `${base(b)}/execute`, {}, 409); assert.equal((await balance(s)).outstanding, '500.00'); } checks.push('receipt_incoming_direction_cannot_settle_outgoing_principal'); { const s = await setup(); const b = await batch(s); const rr = await receipt(b.id, '1000', `RP-LEGACY-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }); const old = await legacyVerification(rr.attachment.id); const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}`; const d = (await call(finance, 'GET', `${endpoint}/manual-verification`)).item; assert.equal(d.documentInspectionRequired, true); await call(finance, 'POST', `${base(b)}/execute`, {}, 409); await call(manager, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 403); await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version - 1 }, 409); const verificationCount = await prisma.paymentReceiptVerification.count({ where: { attachment_id: rr.attachment.id } }); await pool.query(`CREATE FUNCTION synthetic_document_audit_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='PAYMENT_RECEIPT_DOCUMENT_CHECKED' THEN RAISE EXCEPTION 'synthetic document audit unavailable'; END IF; RETURN NEW; END $$`); await pool.query('CREATE TRIGGER synthetic_document_audit_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_document_audit_fail()'); try { await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 500); assert.equal((await prisma.paymentBatchReceiptAttachment.findUniqueOrThrow({ where: { id: rr.attachment.id } })).receipt_ocr_version, d.version); assert.equal(await prisma.paymentReceiptVerification.count({ where: { attachment_id: rr.attachment.id } }), verificationCount); assert.equal((await balance(s)).reserved, '10.00'); } finally { await pool.query('DROP TRIGGER synthetic_document_audit_fail ON audit_logs'); await pool.query('DROP FUNCTION synthetic_document_audit_fail()'); } const checked = (await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version })).item; assert.equal(checked.version, d.version + 1); assert.equal((await call(finance, 'GET', `${endpoint}/manual-verification`)).item.documentInspectionRequired, false); await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 409); assert.ok(await prisma.auditLog.findFirst({ where: { target_id: b.id, action: 'PAYMENT_RECEIPT_DOCUMENT_CHECKED' } })); assert.equal((await prisma.paymentReceiptVerification.findUniqueOrThrow({ where: { id: old.id } })).normalized_encrypted, old.normalized_encrypted); await call(finance, 'POST', `${base(b)}/execute`, {}); assert.equal((await balance(s)).outstanding, '490.00'); } checks.push('receipt_legacy_matched_requires_versioned_local_inspection_without_cloud_retransmission'); checks.push('receipt_local_inspection_audit_failure_rolls_back_new_version_and_evidence'); { const s = await setup(); const b = await batch(s); const reference = `RP-OLD-MANUAL-${randomUUID()}`; const rr = await receipt(b.id, null, reference, { payeeAccount: s.lender.bankAccount }); const old = await legacyVerification(rr.attachment.id); const id = randomUUID(); const crypto = req('./src/services/crypto'); const oldFields = Object.entries({ 小写金额: '10.00', 币别: '人民币', 付款人账号: '8'.repeat(16), 收款人账号: s.lender.bankAccount, 凭证号: reference, 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value })); // Represent a historically approved proposal without new document fields; // current HTTP approval must never manufacture this obsolete evidence. const saved = await prisma.$transaction(async tx => { const storedBatch = await tx.paymentBatch.findUniqueOrThrow({ where: { id: b.id }, include: { items: true } }); const context = await req('./src/services/loanApprovalPolicy').decisionFields(tx, storedBatch, manager.id, { targetId: id, conflictIds: [finance.id] }); const p = await tx.paymentReceiptManualProposal.create({ data: { id, attachment_id: rr.attachment.id, original_verification_id: old.id, source_sha256: old.source_sha256, source_version: old.source_version + 1, status: 'approved', proposed_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), ...context, payload_encrypted: crypto.encryptStringOrThrow(config, JSON.stringify({ pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: oldFields }], reason: '合成旧策略人工核验', changedFields: [{ pageNumber: 1, fields: ['amount'] }] })), review_note_encrypted: crypto.encryptStringOrThrow(config, '合成旧策略公司确认') } }); await tx.paymentBatchReceiptAttachment.update({ where: { id: rr.attachment.id }, data: { receipt_ocr_version: old.source_version + 1, receipt_ocr_state: 'manual_approved' } }); return p; }); const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}`; await call(finance, 'POST', `${base(b)}/execute`, {}, 409); assert.equal((await balance(s)).outstanding, '500.00'); assert.equal((await call(finance, 'GET', `${endpoint}/manual-verification`)).item.documentInspectionRequired, true); const checked = (await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: saved.source_version })).item; assert.equal(checked.status, 'needs_review'); await call(finance, 'POST', `${base(b)}/execute`, {}, 409); const renewed = (await call(finance, 'POST', `${endpoint}/manual-verification`, { expectedVersion: checked.version, reason: '合成本地更新后重新核对原件金额', pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: [{ Name: '小写金额', Value: '10.00' }] }] })).item; await call(manager, 'POST', `${endpoint}/manual-verification/${renewed.id}/review`, { expectedVersion: renewed.version, decision: 'approve', reason: '合成公司代表重新确认本轮依据' }); await call(finance, 'POST', `${base(b)}/execute`, {}); assert.equal((await prisma.paymentReceiptManualProposal.findUniqueOrThrow({ where: { id: saved.id } })).payload_encrypted, saved.payload_encrypted); assert.equal((await prisma.paymentReceiptClaim.findFirstOrThrow({ where: { item_id: b.items[0].id } })).manual_proposal_id, renewed.id); assert.equal((await balance(s)).outstanding, '490.00'); } checks.push('receipt_old_manual_approval_needs_reinspection_and_new_confirmation_without_rewriting_history'); { const s = await setup(); const a = await batch(s); await batch(s); await receipt(a.id, '1000', `RP-ATTR-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }); const before = await balance(s); await call(finance, 'POST', `${base(a)}/execute`, {}, 409, 'loan.receipt_attribution_required'); assert.equal((await balance(s)).reserved, before.reserved); const p = await attribution(a); await review(a, p, 'loan-receipt-attribution'); await batch(s); await call(finance, 'POST', `${base(a)}/execute`, {}, 409, 'loan.receipt_attribution_required'); const p2 = await attribution(a); await review(a, p2, 'loan-receipt-attribution'); await action(s.auth, 'stop_payment'); await call(finance, 'POST', `${base(a)}/execute`, {}); assert.equal((await balance(s)).outstanding, '490.00'); assert.equal((await prisma.loanReceiptAttribution.findUniqueOrThrow({ where: { id: p.id } })).status, 'superseded'); } checks.push('rp_same_amount_attribution_staleness_and_reconfirmation_before_settlement'); { const s = await setup(); const b = await batch(s); await batch(s); const ref = `RP-FAILED-${randomUUID()}`; const sub = await submission(b, ref); await attest(b); const rr = await receipt(b.id, '1000', ref, { payeeAccount: s.lender.bankAccount, transactionStatus: 'failed' }); const endpoint = `${base(b)}/bank-failure-review`; await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id }, 409, 'loan.receipt_attribution_required'); const p = await attribution(b, { kind: 'failure', attachmentId: rr.attachment.id, basisKind: 'bank_instruction_ref' }); await review(b, p, 'loan-receipt-attribution'); config.loanReleaseWaitDays = 3; await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id }, 409, 'loan.release_wait_period'); config.loanReleaseWaitDays = 0; await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id }); await action(s.auth, 'stop_reconcile'); await cancel(b, {}, 409, 'loan.related_authorization_required'); const renew = await authorization(s.lender, [s.loan]); await action(renew, 'stop_payment'); await cancel(b); assert.equal((await balance(s)).outstanding, '500.00'); assert.equal(await prisma.loanRepaymentAllocation.count({ where: { repayment_id: b.loanRepaymentId, reservation_state: 'reserved' } }), 0); assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: b.loanRepaymentId } })).authorization_id, s.auth.id); assert.ok(sub.id); } checks.push('rp_failed_attribution_wait_current_release_authority_and_new_grant_version'); { const s = await setup(); const b = await batch(s); await exportOrder(b.id); const exps = await prisma.paymentOrderExport.findMany({ where: { payment_batch_id: b.id }, orderBy: { sequence: 'asc' } }); const one = await submission(b, `RP-ONE-${randomUUID()}`, exps[0]); const two = await submission(b, `RP-TWO-${randomUUID()}`, exps[1]); await attest(b); const f1 = await fact(b, one); await review(b, f1); await notPaid(b, 409, 'loan.terminal_coverage_incomplete'); const f2 = await fact(b, two); await review(b, f2); await notPaid(b); const ev = (await prisma.loanBankTerminalFact.findUniqueOrThrow({ where: { id: f1.id } })).evidence_id; await call(finance, 'POST', `${base(b)}/loan-terminal-evidences/${ev}/void`, { reason: '合成补证换版' }); await cancel(b, {}, 409); const recovery = await call(finance, 'POST', `${base(b)}/loan-release-recoveries`, { requestKey: randomUUID(), reason: '合成重新补齐第一条指令终态' }); const replacement = await fact(b, one); await review(b, replacement); await call(finance, 'POST', `${base(b)}/loan-release-recoveries/${recovery.id}/submit`, {}); await review(b, recovery, 'loan-release-recoveries'); await cancel(b, { recoveryId: recovery.id, requestKey: randomUUID() }); assert.equal((await balance(s)).reserved, '0.00'); assert.equal((await balance(s)).outstanding, '500.00'); } checks.push('rp_every_export_submission_terminal_and_evidence_recovery_full_consumer'); { const s = await setup(); const b = await batch(s); await prisma.employeeRole.create({ data: { employee_id: manager.id, role_code: 'finance' } }); await prisma.employeeRole.create({ data: { employee_id: finance.id, role_code: 'manager' } }); try { await call(manager, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成经办冲突', allocations: [{ loanId: s.loan.id, amount: '1.00' }] }, 403, 'loan.related_actor_conflict'); for (const suffix of ['payment-order', 'loan-bank-submissions', 'loan-submission-attestation', 'loan-terminal-facts', 'loan-release-recoveries', 'loan-receipt-attribution', 'loan-bank-results', 'execute', 'cancel']) { await call(manager, 'POST', `${base(b)}/${suffix}`, {}, 403, 'loan.related_actor_conflict'); } await evidence(b, 'bank_terminal', manager, 403); const sub = await submission(b); const f = await fact(b, sub); await review(b, f, 'loan-terminal-facts', 403, finance); const q = { exportIds: [(await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: b.id } })).id], queryEvidenceId: randomUUID(), statementEvidenceId: randomUUID(), exporterStatements: [{ exporterId: finance.id, evidenceId: randomUUID() }], coverageFrom: new Date().toISOString(), coverageThrough: new Date().toISOString(), note: '合成不可用的人工未提交例外' }; await call(finance, 'POST', `${base(b)}/loan-not-submitted`, q, 409, 'loan.related_q6_unavailable'); await call(manager, 'POST', `${base(b)}/loan-not-submitted/${randomUUID()}/review`, { decision: 'approve', reason: '合成不能伪造双人特别复核' }, 409, 'loan.related_q6_unavailable'); const policy = req('./src/services/loanApprovalPolicy'); const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } }); await assert.rejects(() => policy.assertNotRelatedQ6(prisma, saved), e => e.details?.reasonCode === 'loan.related_q6_unavailable'); const caps = (await call(manager, 'GET', base(b))).item.loanRepayment.actionCapabilities; assert.equal(caps.canPrepare, false); assert.equal(caps.canUseNotSubmitted, false); // A second token for the same employee is still the same identity. const login = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { identifier: manager.email, password: h.password }, headers: { 'x-device-fingerprint': randomUUID() } }); assert.equal(login.statusCode, 200); await call({ headers: { authorization: `Bearer ${login.json().data.accessToken}` } }, 'POST', `${base(b)}/loan-bank-submissions`, {}, 403, 'loan.related_actor_conflict'); } finally { await prisma.employeeRole.deleteMany({ where: { employee_id: manager.id, role_code: 'finance' } }); await prisma.employeeRole.deleteMany({ where: { employee_id: finance.id, role_code: 'manager' } }); } } checks.push('rp_only_payee_conflict_waived_multiple_roles_tokens_and_q6_refused'); for (const [cancelled, paidAmount, reserveAmount] of [[false, '0.00', '0.00'], [true, '0.00', '70.00'], [true, '60.00', '0.00'], [true, '100.00', '0.00']]) { const s = await setup('100.00'); const b = await batch(s, '80.00'); await terminate(s, b, cancelled); if (paidAmount !== '0.00') { const p = await batch(s, paidAmount); await receipt(p.id, String(Math.round(Number(paidAmount) * 100)), `RP-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }); await call(finance, 'POST', `${base(p)}/execute`, {}); } const reserved = reserveAmount !== '0.00' ? await repay(s.lender.id, s.loan.id, reserveAmount) : null; const count = await prisma.paymentBatch.count(); const e = await bankEvent(s, b); await review(b, e, 'loan-bank-results'); if (reserved) { await apply(b, e); assert.equal((await balance(s)).outstanding, '100.00'); await cancel(reserved); } await action(s.auth, 'stop_payment'); await action(s.auth, 'stop_reconcile'); await apply(b, e); await apply(b, e); const excess = Math.max(0, Number(paidAmount) - 20); const result = await prisma.loanSupplementalSettlement.findFirstOrThrow({ where: { event_id: e.id } }); assert.equal(result.bank_amount.toFixed(2), '80.00'); assert.equal(result.receivable_amount.toFixed(2), excess.toFixed(2)); assert.equal(await prisma.paymentBatch.count(), count); assert.equal((await balance(s)).reserved, '0.00'); if (excess) { await authorization(s.lender, [s.loan]); for (const value of [15, excess - 15]) { const ret = await bankEvent(s, b, 'return', value.toFixed(2), e.id); await review(b, ret, 'loan-bank-results'); await apply(b, ret); if (value === 15) assert.ok(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } })); } } assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0); } checks.push('rp_late_success_reserved_conflict_partial_and_full_excess_historical_booking_returns'); { const s = await setup('100.00'); const b = await batch(s, '80.00'); await terminate(s, b, true); const e = await bankEvent(s, b); const e2 = await bankEvent(s, b, 'late_success', '80.00', undefined, `RP-OTHER-${randomUUID()}`); await review(b, e, 'loan-bank-results', 409, manager, { decision: 'reject' }); const correction = await evidence(b); await review(b, e, 'loan-bank-results', 200, manager, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: correction }); assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1); await review(b, e2, 'loan-bank-results', 200, manager, { decision: 'reject', correctionKind: 'duplicate', correctionEvidenceId: await evidence(b) }); assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0); assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event: { payment_batch_id: b.id } } }), 0); } checks.push('rp_evidenced_false_alarm_closes_only_its_own_freeze'); { const { Pool } = req('pg'); const locks = new Pool({ ...pool.options, max: 3 }); async function waitFor(n) { for (let i = 0; i < 300; i++) { const rows = await locks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'"); if (rows.rows[0].n >= n) return; await new Promise(resolve => setTimeout(resolve, 10)); } throw new Error(`related authority race did not queue ${n} requests`); } try { for (const kind of ['approval', 'export', 'fact', 'release']) for (const stopFirst of [true, false]) { const s = await setup('100.00'); const b = await repay(s.lender.id, s.loan.id, '10.00'); let f; if (kind !== 'approval') await approve(b); if (['fact', 'release'].includes(kind)) { await exportOrder(b.id); const sub = await submission(b); await attest(b); f = await fact(b, sub); } if (kind === 'release') { await review(b, f); await notPaid(b); } const reconcile = ['fact', 'release'].includes(kind); const request = () => app.inject({ method: 'POST', headers: (['approval', 'fact'].includes(kind) ? manager : finance).headers, url: `/api${base(b)}/${kind === 'approval' ? 'review' : kind === 'export' ? 'payment-order' : kind === 'fact' ? `loan-terminal-facts/${f.id}/review` : 'cancel'}`, payload: kind === 'fact' ? { decision: 'approve', reason: '合成并发公司确认' } : kind === 'release' ? { reason: '合成并发最终释放' } : {} }); const stop = () => app.inject({ method: 'POST', url: `/api/loan-related-authorizations/${s.auth.id}/actions`, headers: finance.headers, payload: { action: reconcile ? 'stop_reconcile' : 'stop_payment', reason: '合成并发停止授权' } }); const holder = await locks.connect(); let decision; let stopped; try { await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]); if (stopFirst) { stopped = stop(); await waitFor(1); decision = request(); } else { decision = request(); await waitFor(1); stopped = stop(); } await waitFor(2); await holder.query('COMMIT'); const results = await Promise.all([decision, stopped]); assert.equal(results[1].statusCode, 200, `stop during ${kind}`); assert.equal(results[0].statusCode, stopFirst ? 409 : 200, `${kind} stopFirst=${stopFirst}: ${results[0].statusCode}`); if (stopFirst) assert.equal(results[0].json().details.reasonCode, 'loan.related_authorization_required'); } finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); } const state = await balance(s); assert.equal(state.outstanding, '100.00'); assert.equal(state.reserved, kind === 'release' && !stopFirst ? '0.00' : '10.00'); } for (const checkFirst of [true, false]) { const s = await setup(); const b = await batch(s); const rr = await receipt(b.id, '1000', `RP-DOCUMENT-RACE-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }); const old = await legacyVerification(rr.attachment.id); const check = () => app.inject({ method: 'POST', url: `/api${base(b)}/receipt-attachments/${rr.attachment.id}/document-check`, headers: finance.headers, payload: { expectedVersion: old.source_version } }); const execute = () => app.inject({ method: 'POST', url: `/api${base(b)}/execute`, headers: finance.headers, payload: {} }); const holder = await locks.connect(); let checked; let paid; try { await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [b.id]); if (checkFirst) { checked = check(); await waitFor(1); paid = execute(); } else { paid = execute(); await waitFor(1); checked = check(); } await waitFor(2); await holder.query('COMMIT'); const results = await Promise.all([checked, paid]); assert.equal(results[0].statusCode, 200, 'local reinspection serializes with settlement'); assert.equal(results[1].statusCode, checkFirst ? 200 : 409, 'settlement uses evidence available at its turn'); assert.equal((await balance(s)).reserved, checkFirst ? '0.00' : '10.00'); } finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); } if (!checkFirst) await call(finance, 'POST', `${base(b)}/execute`, {}); assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: b.items[0].id } }), 1); } } finally { await locks.end(); } } checks.push('rp_authority_stop_serializes_with_approval_export_fact_and_release_both_orders'); checks.push('receipt_local_inspection_and_final_settlement_serialize_in_both_orders'); } if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) { const s = await setup('100.00', null); ui.push({ ...s, mode }); } } finally { if (originalWait === undefined) delete config.loanReleaseWaitDays; else config.loanReleaseWaitDays = originalWait; } console.log(JSON.stringify({ stage: 'loan_related_http', status: checks.length ? 'PASS' : 'NOT_RUN', checks })); return { checks, ui, helpers: { execute: b => call(finance, 'POST', `${base(b)}/execute`, {}), draft: (s, amount) => repay(s.lender.id, s.loan.id, amount), setup, authorization, batch, receipt, processUploadedReceipt: h.processUploadedReceipt, submission, attest, evidence, fact, review, notPaid, terminate, pdf, bankEvent } }; };