const assert = require('node:assert/strict'); const path = require('node:path'); const { randomBytes, randomUUID } = require('node:crypto'); const { createRequire } = require('node:module'); const root = path.resolve(__dirname, '../..'); const req = createRequire(path.join(root, 'server/package.json')); const { PrismaClient } = req('@prisma/client'); const { PrismaPg } = req('@prisma/adapter-pg'); const { hashPassword } = req('./src/utils/crypto'); module.exports = async function runLoanHttpFixture(pool, scratch) { const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) }); const app = req('fastify')({ logger: false }); const config = { fieldEncryptionKey: randomBytes(32).toString('base64'), jwtSecret: randomBytes(48).toString('hex'), accessTokenTtl: '30m', refreshTtlSeconds: 3600, refreshCookieName: 'synthetic_refresh', cookieSecure: false, rateLimitEnabled: false, emailEnabled: false, appOrigin: 'http://127.0.0.1', storageDir: path.join(scratch, 'materials'), storageDriver: 'local', appTimezone: 'Asia/Shanghai', paymentBatchEnabled: true, }; app.decorate('config', config); app.decorate('prisma', prisma); app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ code: error.code ?? 'SYS_001', details: error.details ?? {}, })); await app.register(req('@fastify/cookie')); await app.register(req('@fastify/multipart')); await app.register(req('./src/plugins/auth')); await app.register(req('./src/routes/auth'), { prefix: '/api/auth' }); await app.register(req('./src/routes/companyLoans'), { prefix: '/api' }); await app.register(req('./src/routes/paymentBatches'), { prefix: '/api' }); for (const route of ['me', 'systemWorkspace', 'notifications', 'masterData', 'paymentAccounts']) { await app.register(req(`./src/routes/${route}`), { prefix: '/api' }); } if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') { await app.register(req('@fastify/static'), { root: path.join(root, 'client/dist') }); app.setNotFoundHandler((request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ code: 'SYS_404' }) : reply.sendFile('index.html')); } await app.ready(); const checks = []; try { const company = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成公司' } }); const bank = await prisma.bankInstitution.upsert({ where: { code: 'CCB' }, update: {}, create: { code: 'CCB', name: '合成建行' } }); const department = await prisma.department.create({ data: { name: '合成财务部' } }); await prisma.featureFlag.create({ data: { flag_key: 'payment_batch_v1', override_mode: 'force_on', rollout_type: 'global' } }); const password = `SyntH!${randomBytes(16).toString('hex')}`; const passwordHash = await hashPassword(password); async function actor(roleCodes) { const id = randomUUID(); const employee = await prisma.employee.create({ data: { id, email: `${id}@example.invalid`, name: '合成用户', department_id: department.id, legal_entity_id: company.id, title: '合成岗位', hire_date: new Date('2025-01-01'), bank_name: '合成', bank_account_encrypted: req('./src/services/crypto').encryptStringOrThrow(config, '7'.repeat(16)), password_hash: passwordHash, roles: { create: roleCodes.map(role_code => ({ role_code })) }, } }); const login = await app.inject({ method: 'POST', url: '/api/auth/login', headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: employee.email, password } }); assert.equal(login.statusCode, 200, 'real synthetic password login'); return { id, email: employee.email, headers: { authorization: `Bearer ${login.json().data.accessToken}` } }; } const finance = await actor(['finance']); const manager = await actor(['manager']); const system = await actor(['system_admin']); const admin = await actor(['admin']); const ordinary = await actor(['employee']); async function call(who, method, url, payload, status = 200, reason) { const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload }); assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`); if (reason) assert.equal(response.json().details?.reasonCode, reason); return response.json().data; } for (const who of [manager, system, admin, ordinary]) { await call(who, 'GET', '/company-loans', undefined, 403); await call(who, 'POST', '/loan-lenders', {}, 403); } await call(null, 'GET', '/company-loans', undefined, 401); checks.push('real_login_and_explicit_finance_permissions'); const bankAccount = '9'.repeat(16); // synthetic account, never log its value const lenderInput = { requestKey: randomUUID(), legalEntityId: company.id, name: '合成出借人', bankInstitutionId: bank.id, bankName: '合成银行', bankAccount, accountName: '合成出借人' }; const lender = await call(finance, 'POST', '/loan-lenders', lenderInput); assert.equal((await call(finance, 'POST', '/loan-lenders', lenderInput)).id, lender.id); await call(finance, 'POST', '/loan-lenders', { ...lenderInput, name: 'different' }, 409, 'loan.duplicate_registration'); assert.equal(lender.identityStatus, 'unverified'); assert.ok(!JSON.stringify(lender).includes(bankAccount)); assert.ok(!JSON.stringify(lender).includes('v1:')); await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { expectedVersion: 1, employeeId: null, externalPersonConfirmed: false, reason: '合成核实', }, 400, 'loan.invalid_input'); const verified = await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { expectedVersion: 1, employeeId: null, externalPersonConfirmed: true, reason: '合成非员工核实', }); assert.equal(verified.identityStatus, 'verified'); checks.push('lender_encryption_identity_confirmation_and_idempotency'); const input = { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成原借入凭据一', originalPrincipal: '1000.10', openingOutstandingPrincipal: '800.05', receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true }; const loan = await call(finance, 'POST', '/company-loans', input); assert.equal(loan.available, '0.00'); assert.equal(loan.outstanding, '800.05'); assert.equal((await call(finance, 'POST', '/company-loans', input)).id, loan.id); await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID() }, 409, 'loan.duplicate_registration'); await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), receivedDate: '2025-02-30' }, 400); await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required'); const material = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n'); async function upload(kind, data = material, filename = 'synthetic.pdf', type = 'application/pdf', status = 200, loanId = loan.id) { const boundary = `synthetic-${randomUUID()}`; const payload = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); const response = await app.inject({ method: 'POST', url: `/api/company-loans/${loanId}/evidences/${kind}`, headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload }); assert.equal(response.statusCode, status, `upload ${kind}`); return response.json().data; } await upload('agreement', Buffer.from(''), 'bad.pdf', 'application/pdf', 400); const evidence = await upload('agreement'); await upload('incoming'); await upload('accounting_confirmation'); await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required'); await upload('adjustment'); const confirmed = await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }); assert.equal(confirmed.available, '800.05'); assert.equal(confirmed.version, 2); await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 2, accountingConfirmationRef: '改写' }, 409, 'loan.confirmed_immutable'); checks.push('historical_balance_evidence_and_outside_repayment_required'); await call(ordinary, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, { reason: '合成读取' }, 403); await call(finance, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, {}, 400); const downloaded = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${evidence.id}/download`, headers: finance.headers, payload: { reason: '合成核对读取' } }); assert.equal(downloaded.statusCode, 200); assert.ok(downloaded.headers['content-disposition'].startsWith('attachment;')); assert.equal(downloaded.headers['x-content-type-options'], 'nosniff'); assert.ok(downloaded.headers['cache-control'].includes('no-store')); checks.push('scoped_evidence_download_and_headers'); // Real database audit failure must roll back the source write and not merely log it. await pool.query(`CREATE FUNCTION synthetic_fail_loan_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='COMPANY_LOAN_REGISTERED' THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`); await pool.query('CREATE TRIGGER synthetic_loan_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_loan_audit()'); const count = await prisma.companyLoan.count(); try { await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成失败' }, 500); assert.equal(await prisma.companyLoan.count(), count); } finally { await pool.query('DROP TRIGGER synthetic_loan_audit ON audit_logs'); await pool.query('DROP FUNCTION synthetic_fail_loan_audit()'); } checks.push('real_audit_failure_atomic_rollback'); const { encryptStringOrThrow } = req('./src/services/crypto'); const payerAccount = '8'.repeat(16); await prisma.paymentAccount.create({ data: { legal_entity_id: company.id, bank_institution_id: bank.id, account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } }); const second = await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成原借入凭据二', originalPrincipal: '300.05', openingOutstandingPrincipal: '300.05' }); for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, second.id); await call(finance, 'POST', `/company-loans/${second.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对二' }); const repaymentInput = { requestKey: randomUUID(), lenderId: lender.id, reason: '合成两笔借入部分还本', allocations: [{ loanId: loan.id, amount: '100.10' }, { loanId: second.id, amount: '200.05' }] }; const { item: batch } = await call(finance, 'POST', '/loan-repayments', repaymentInput); assert.equal(batch.items.length, 1); assert.equal(String(batch.totalAmount), '300.15'); assert.equal((await call(finance, 'POST', '/loan-repayments', repaymentInput)).item.id, batch.id); await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, reason: 'changed' }, 409, 'loan.duplicate_registration'); const balance = await call(finance, 'GET', `/company-loans/${loan.id}`); assert.equal(balance.outstanding, '800.05'); assert.equal(balance.reserved, '100.10'); assert.equal(balance.available, '699.95'); await call(finance, 'POST', `/payment-batches/${batch.id}/review`, {}, 403); await call(admin, 'POST', `/payment-batches/${batch.id}/execute`, {}, 403); await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 409, 'payment_batch.bank_receipt_required'); await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '提前交接' }, 409, 'loan.handoff_not_ready'); checks.push('two_loans_one_payment_partial_allocation_idempotency_and_review'); const { runReceiptOcrOnce } = req('./src/jobs/paymentReceiptOcrJob'); async function receipt(batchId, amount, reference, overrides = {}) { const boundary = `synthetic-${randomUUID()}`; const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`, headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); assert.equal(response.statusCode, 200, 'receipt upload'); const recognition = { status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount, transactionStatus: 'success', reference, issues: [], ...overrides }] }; // Only the cloud recognizer is replaced. Queue claiming, local storage, // normalization, verification, transaction claims and HTTP writes are real. const result = await runReceiptOcrOnce({ prisma, config, recognize: async () => recognition }); return { attachment: response.json().data.items[0], result }; } const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} }); assert.equal(exportResponse.statusCode, 200, 'loan bank-order export'); assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1'); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe'); checks.push('actual_biff8_export_keeps_principal_and_prevents_unsafe_release'); const receiptResult = await receipt(batch.id, '30015', 'SYNTHETIC-BANK-1'); assert.equal(receiptResult.result.status, 'matched'); await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '未知银行结果不能释放' }, 409, 'loan.bank_state_unsafe'); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); await pool.query(`CREATE FUNCTION synthetic_fail_settlement_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='LOAN_PRINCIPAL_SETTLED' THEN RAISE EXCEPTION 'synthetic settlement audit unavailable'; END IF; RETURN NEW; END $$`); await pool.query('CREATE TRIGGER synthetic_settlement_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_settlement_audit()'); try { await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 500); assert.equal(await prisma.paymentReceiptClaim.count(), 0); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05'); } finally { await pool.query('DROP TRIGGER synthetic_settlement_audit ON audit_logs'); await pool.query('DROP FUNCTION synthetic_fail_settlement_audit()'); } const paid = await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}); assert.equal(paid.item.status, 'executed'); const after = await call(finance, 'GET', `/company-loans/${loan.id}`); assert.equal(after.outstanding, '699.95'); assert.equal(after.reserved, '0.00'); assert.equal(await prisma.paymentReceiptClaim.count(), 1); await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95'); await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '合成会计凭证' }); checks.push('real_receipt_worker_atomic_claim_settlement_rollback_retry_and_handoff'); const nextInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '300.15' }] }; const { item: duplicate } = await call(finance, 'POST', '/loan-repayments', nextInput); await call(manager, 'POST', `/payment-batches/${duplicate.id}/review`, {}); await receipt(duplicate.id, '30015', 'SYNTHETIC-BANK-1'); await call(finance, 'POST', `/payment-batches/${duplicate.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused'); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95'); const concurrentInput = { ...repaymentInput, allocations: [{ loanId: loan.id, amount: '300.00' }] }; const concurrent = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: { ...concurrentInput, requestKey: randomUUID() } }))); assert.deepEqual(concurrent.map(r => r.statusCode).sort(), [200, 409]); const winner = concurrent.find(r => r.statusCode === 200).json().data.item; await call(finance, 'POST', `/payment-batches/${winner.id}/cancel`, { reason: '合成未发送取消' }); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80'); checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel'); const { item: failedBank } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '10.00' }] }); await call(manager, 'POST', `/payment-batches/${failedBank.id}/review`, {}); const failureReceipt = await receipt(failedBank.id, '1000', 'SYNTHETIC-FAILED', { transactionStatus: 'failed', issues: ['transaction_failed'] }); await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409); await call(finance, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 403); await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }); await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409); await call(finance, 'POST', `/payment-batches/${failedBank.id}/cancel`, { reason: '合成银行明确失败' }); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80'); checks.push('confirmed_bank_failure_requires_independent_matching_evidence'); const { item: manual } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '20.00' }] }); await call(manager, 'POST', `/payment-batches/${manual.id}/review`, {}); const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }); assert.equal(unclear.result.status, 'needs_review'); const currentAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: unclear.attachment.id } }); const manualBase = `/payment-batches/${manual.id}/receipt-attachments/${unclear.attachment.id}/manual-verification`; const proposal = await call(finance, 'POST', manualBase, { expectedVersion: currentAttachment.receipt_ocr_version, reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id, fields: Object.entries({ 小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: bankAccount, 凭证号: 'SYNTHETIC-MANUAL', 单据状态: '交易成功', }).map(([Name, Value]) => ({ Name, Value })) }] }); const saved = proposal.item ?? proposal; await call(finance, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成不允许自审' }, 403); await call(manager, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成独立复核' }); await call(finance, 'POST', `/payment-batches/${manual.id}/execute`, {}); assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95'); checks.push('external_lender_two_person_manual_receipt_and_real_settlement'); // Cross-source claim protection: seed only the prior reimbursement facts, // then use actual receipt upload/worker/execute HTTP. No reimbursement paid // result is seeded or asserted by this setup. const source = await prisma.reimbursementForm.create({ data: { form_number: `SYN-${randomUUID()}`, submitter_id: ordinary.id, owner_id: ordinary.id, department_id: department.id, reimbursement_date: new Date('2026-01-01'), total_amount: '300.15', status: 'approved', } }); const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id }, include: { items: true } }); const crossBatch = await prisma.paymentBatch.create({ data: { batch_no: `SYN-${randomUUID()}`, name: '合成跨业务去重', entity_type: 'reimbursement', status: 'reviewed', created_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), legal_entity_id: company.id, payment_account_id: savedBatch.payment_account_id, payment_account_snapshot_json: savedBatch.payment_account_snapshot_json, default_payment_channel: 'bank', items: { create: { entity_id: source.id, entity_code: source.form_number, entity_title: '合成报销来源', amount: '300.15', snapshot_json: savedBatch.items[0].snapshot_json } }, } }); await receipt(crossBatch.id, '30015', 'SYNTHETIC-BANK-1'); await call(finance, 'POST', `/payment-batches/${crossBatch.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused'); assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: source.id } })).status, 'approved'); checks.push('same_bank_transaction_rejected_across_loan_and_reimbursement_http'); for (const [label, overrides] of [ ['amount', { amount: '101' }], ['currency', { currency: 'USD' }], ['payer', { payerAccount: '1'.repeat(16) }], ['payee', { payeeAccount: '2'.repeat(16) }], ['pending', { transactionStatus: 'pending', issues: ['transaction_pending'] }], ]) { const { item: rejected } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }); await call(manager, 'POST', `/payment-batches/${rejected.id}/review`, {}); const mismatch = await receipt(rejected.id, '100', `SYNTHETIC-NEGATIVE-${label}`, overrides); assert.equal(mismatch.result.status, 'needs_review'); await call(finance, 'POST', `/payment-batches/${rejected.id}/execute`, {}, 409); await call(manager, 'POST', `/payment-batches/${rejected.id}/bank-failure-review`, { attachmentId: mismatch.attachment.id }, 409); await call(finance, 'POST', `/payment-batches/${rejected.id}/cancel`, { reason: '不明或不符不得释放' }, 409, 'loan.bank_state_unsafe'); } assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95'); checks.push('wrong_amount_currency_both_accounts_and_pending_never_settle_or_release'); const noReviewerInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }; await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } }); try { await call(finance, 'POST', '/loan-repayments', noReviewerInput, 409, 'loan.reviewer_required'); } finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); } const sameKey = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: noReviewerInput }))); assert.deepEqual(sameKey.map(r => r.statusCode), [200, 200]); assert.equal(sameKey[0].json().data.item.id, sameKey[1].json().data.item.id); await call(finance, 'POST', `/payment-batches/${sameKey[0].json().data.item.id}/cancel`, { reason: '合成并发验证结束' }); checks.push('no_eligible_reviewer_blocks_and_concurrent_same_key_is_idempotent'); const evidencePath = `/company-loans/${loan.id}/evidences/${evidence.id}/download`; const reviewerDownload = await app.inject({ method: 'POST', url: `/api${evidencePath}`, headers: manager.headers, payload: { reason: '合成还款审批材料', paymentBatchId: manual.id } }); assert.equal(reviewerDownload.statusCode, 200); await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403); const { item: stale } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] }); await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { expectedVersion: verified.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成收款人关联经理', }); await call(manager, 'POST', `/payment-batches/${stale.id}/review`, {}, 403); const independent = await actor(['ceo']); await call(independent, 'POST', `/payment-batches/${stale.id}/review`, {}, 409, 'loan.version_changed'); await call(finance, 'POST', `/payment-batches/${stale.id}/cancel`, { reason: '合成未发起身份更正' }); const { item: ownPayee } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] }); await call(manager, 'POST', `/payment-batches/${ownPayee.id}/review`, {}, 403); await call(independent, 'POST', `/payment-batches/${ownPayee.id}/review`, {}); await call(finance, 'POST', `/payment-batches/${ownPayee.id}/cancel`, { reason: '合成验证结束' }); checks.push('scoped_reviewer_materials_payee_self_review_and_identity_version_guards'); const { item: oldAccount } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }); const changedAccount = await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, { expectedVersion: lender.accountVersion, bankInstitutionId: bank.id, bankName: '合成变更开户行', bankAccount: '3'.repeat(16), accountName: '合成出借人', }); assert.equal(changedAccount.identityStatus, 'unverified'); await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.identity_required'); await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, { expectedVersion: changedAccount.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成账户变更后重新核实', }); await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.version_changed'); await call(finance, 'POST', `/payment-batches/${oldAccount.id}/cancel`, { reason: '合成旧账户未发起取消' }); checks.push('account_changes_revoke_identity_and_invalidate_old_unpaid_snapshot'); if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') { await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma }); } console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks })); } finally { await app.close(); await prisma.$disconnect(); } };