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'); // Receipts go through the real local chain: PDF decode, recognizePaymentReceipt, // normalizeBankSlip, verification storage, matching and claims. Only the cloud // BankSlipOCR response is synthetic, built from the intended slip fields. const { recognizePaymentReceipt } = req('./src/services/paymentReceiptOcr'); const { PDFDocument } = req('pdf-lib'); const { syntheticBankReceiptPdf, syntheticDocumentEvidence } = require('./synthetic-bank-receipt.cjs'); const receiptPdf = (pages = 1, options = {}) => syntheticBankReceiptPdf({ pages, ...options }); const onePagePdf = await receiptPdf(1); const STATUS_WORDING = { success: '交易成功', failed: '交易失败', pending: '处理中' }; function bankSlipFields(slip) { const fields = []; const add = (Name, Value) => { if (Value !== null && Value !== undefined) fields.push({ Name, Value: String(Value) }); }; if (slip.amount !== null && slip.amount !== undefined) { const cents = BigInt(slip.amount); add('小写金额', `${cents / 100n}.${String(cents % 100n).padStart(2, '0')}`); } add('币别', slip.currency === 'CNY' ? '人民币' : slip.currency); add('付款人账号', slip.payerAccount); add('收款人账号', slip.payeeAccount); add('凭证号', slip.reference); for (const wording of slip.statusWordings ?? (slip.transactionStatus ? [STATUS_WORDING[slip.transactionStatus]] : [])) { add(fields.some((field) => field.Name === '单据状态') ? '交易状态' : '单据状态', wording); } return fields; } async function receipt(batchId, amount, reference, overrides = {}, { pdfPages = 1, normalizedPage = false, document = {} } = {}) { const boundary = `synthetic-${randomUUID()}`; const status = overrides.transactionStatus === null ? 'booked' : overrides.transactionStatus ?? (overrides.statusWordings?.some((value) => ['交易失败', '转账失败', '付款失败', '支付失败'].includes(value)) ? 'failed' : 'success'); const minor = overrides.amount ?? amount; const storedBatch = minor === null ? await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batchId }, include: { items: true } }) : null; const displayedAmount = minor === null ? String(storedBatch.items[0].amount) : `${BigInt(minor) / 100n}.${String(BigInt(minor) % 100n).padStart(2, '0')}`; const file = await receiptPdf(pdfPages, { status, amount: displayedAmount, currency: overrides.currency ?? '人民币', payerAccount: overrides.payerAccount ?? payerAccount, payeeAccount: overrides.payeeAccount ?? bankAccount, reference: overrides.reference ?? reference ?? 'SYNTHETIC-UNREAD-REFERENCE', ...document }); 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`), file, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); assert.equal(response.statusCode, 200, 'receipt upload'); const result = await processUploadedReceipt(amount, reference, overrides, { normalizedPage }); return { attachment: response.json().data.items[0], result }; } async function processUploadedReceipt(amount, reference, overrides = {}, { normalizedPage = false } = {}) { const slip = { amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount, transactionStatus: 'success', reference, ...overrides }; // normalizedPage: one explicitly synthetic defence-in-depth case that the // real normalizer cannot produce; every other receipt uses the real chain. const recognize = normalizedPage ? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip, document: syntheticDocumentEvidence({ status: slip.transactionStatus ?? 'booked' }) }] }) : (args) => recognizePaymentReceipt({ ...args, createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: bankSlipFields(slip) }) }) }); const result = await runReceiptOcrOnce({ prisma, config, recognize }); return 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 ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().details)}`); 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'); // S1: failure needs a registered bank submission whose completeness was // independently attested (registrar/proposer finance, reviewer `by`). // The submission is registered under the instruction reference that the // batch's current failed slip carries (as finance would read it off the bank). const { decryptStringOrThrow } = req('./src/services/crypto'); async function slipReference(batchId) { const attachments = await prisma.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batchId, archived_at: null }, include: { verifications: { orderBy: [{ created_at: 'desc' }, { id: 'desc' }], take: 1 } }, orderBy: { created_at: 'desc' } }); for (const attachment of attachments) { const verification = attachment.verifications[0]; if (!verification) continue; const page = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted)).pages?.[0]; if (page?.reference) return page.reference; } return `SYN-INSTR-${batchId.slice(0, 8)}`; } async function attestSubmission(batchId, { by = manager, registrar = finance, ref } = {}) { const exported = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: batchId }, orderBy: { sequence: 'desc' } }); const submission = await call(registrar, 'POST', `/payment-batches/${batchId}/loan-bank-submissions`, { ...(exported ? { exportId: exported.id, channel: 'online_banking_import' } : { channel: 'manual_entry' }), submittedAt: new Date().toISOString(), bankInstructionRef: ref ?? await slipReference(batchId), note: '合成网银提交登记' }); const attestation = await call(finance, 'POST', `/payment-batches/${batchId}/loan-submission-attestation`, { note: '合成提交清单完整' }); await call(by, 'POST', `/payment-batches/${batchId}/loan-submission-attestation/${attestation.id}/review`, { decision: 'approve', reason: '合成清单复核' }); return { submission, attestation }; } 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 }, 409, 'loan.submission_list_unverified'); await attestSubmission(failedBank.id); 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: [] }, { document: { reference: 'SYNTHETIC-MANUAL' } }); 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, paymentProof: { basis: 'booked_debit', originalChecked: true, bookedOn: '2026-10-01', note: '合成正式支出回单已记账,无成功状态原文' }, 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); // Fresh lenders below: the first lender keeps unknown-state reservations from // the negative receipt loop above, which by design block its maintenance. async function verifiedLender(name, employeeId, digit, account = digit.repeat(16)) { const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name, bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: account, accountName: name }); const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, { expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' }); return { ...confirmedLender, bankAccount: account }; } async function confirmedLoan(lenderId, principal, ref) { const created = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId, originReference: ref, originalPrincipal: principal, openingOutstandingPrincipal: principal, receivedDate: '2025-03-01', balanceAsOf: '2026-01-01', historicalEntry: true }); for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, created.id); await call(finance, 'POST', `/company-loans/${created.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }); return created; } const repay = async (lenderId, loanId, amount) => (await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId, reason: '合成还本', allocations: [{ loanId, amount }] })).item; async function exportOrder(batchId, status = 200, reason) { const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/payment-order`, headers: finance.headers, payload: {} }); assert.equal(response.statusCode, status, `export ${response.statusCode} ${response.headers['content-type']?.includes('json') ? response.json().details?.reasonCode ?? '' : ''}`); if (reason) assert.equal(response.json().details?.reasonCode, reason); } const lenderState = async (id) => (await call(finance, 'GET', '/loan-lenders')).items.find((item) => item.id === id); const independent = await actor(['ceo']); const runRelated = () => require('./company-loan-related-fixture.cjs')({ prisma, pool, call, app, req, finance, manager, ordinary, independent, config, password, verifiedLender, confirmedLoan, repay, exportOrder, receipt, processUploadedReceipt }); if (process.env.RUN_ISOLATED_LOAN_RELATED_ONLY === '1') { const relatedParty = await runRelated(); if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-related-browser.cjs')({ app, finance, manager, ordinary, password, prisma, config, relatedParty }); return; } // Lender linked to a manager: that manager cannot approve, an independent CEO can. const linked = await verifiedLender('合成出借人乙', manager.id, '4'); const linkedLoan = await confirmedLoan(linked.id, '500.00', '合成乙借入'); const ownPayee = await repay(linked.id, linkedLoan.id, '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_and_payee_self_review'); // B1: any reserved allocation freezes lender identity/account maintenance, // before and after payment-order export, until the repayment settles or is cancelled. const identityChange = (who, version, status, reason) => call(who, 'POST', `/loan-lenders/${linked.id}/identity-confirmation`, { expectedVersion: version, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成在途改身份' }, status, reason); const accountChange = (version, status, reason) => call(finance, 'PATCH', `/loan-lenders/${linked.id}/account`, { expectedVersion: version, bankInstitutionId: bank.id, bankName: '合成变更开户行', bankAccount: '3'.repeat(16), accountName: '合成出借人乙' }, status, reason); assert.equal((await lenderState(linked.id)).hasInFlightRepayment, false); const inFlight = await repay(linked.id, linkedLoan.id, '5.00'); assert.equal((await lenderState(linked.id)).hasInFlightRepayment, true); await identityChange(finance, (await lenderState(linked.id)).identityVersion, 409, 'loan.lender_in_flight'); await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight'); await call(independent, 'POST', `/payment-batches/${inFlight.id}/review`, {}); await exportOrder(inFlight.id); await identityChange(finance, (await lenderState(linked.id)).identityVersion, 409, 'loan.lender_in_flight'); await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight'); const inFlightReceipt = await receipt(inFlight.id, '500', 'SYNTHETIC-IN-FLIGHT', { payeeAccount: linked.bankAccount }); assert.equal(inFlightReceipt.result.status, 'matched'); assert.equal((await call(finance, 'POST', `/payment-batches/${inFlight.id}/execute`, {})).item.status, 'executed'); assert.equal((await lenderState(linked.id)).hasInFlightRepayment, false); // A never-sent request can be cancelled to release the freeze. const unsent = await repay(linked.id, linkedLoan.id, '5.00'); await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight'); await call(finance, 'POST', `/payment-batches/${unsent.id}/cancel`, { reason: '合成未发送取消' }); checks.push('reserved_allocation_blocks_lender_maintenance_before_and_after_export'); // Account change after release revokes identity and blocks new requests until re-verified. const changedAccount = await accountChange((await lenderState(linked.id)).accountVersion); assert.equal(changedAccount.identityStatus, 'unverified'); await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: linked.id, reason: '合成未核实', allocations: [{ loanId: linkedLoan.id, amount: '1.00' }] }, 409, 'loan.identity_required'); await identityChange(finance, changedAccount.identityVersion); checks.push('account_changes_revoke_identity_until_reverified'); // Maintenance racing creation, with deterministic interleaving: a separate // connection holds the lender row lock, both requests queue behind it in a // chosen order, then the lock is released. No reservation may keep stale versions. const { Pool: LockPool } = req('pg'); const lockPool = new LockPool({ ...pool.options, max: 2 }); const lockWaiters = async () => Number((await lockPool.query( "SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n); async function untilWaiting(count) { for (let i = 0; i < 200; i++) { if (await lockWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); } throw new Error('request did not queue behind the lender lock'); } try { for (const first of ['create', 'maintain']) { const current = await lenderState(linked.id); const holder = await lockPool.connect(); let created; let maintained; try { await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [linked.id]); const start = { create: () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: { requestKey: randomUUID(), lenderId: linked.id, reason: '合成并发', allocations: [{ loanId: linkedLoan.id, amount: '1.00' }] } }), maintain: () => app.inject({ method: 'POST', url: `/api/loan-lenders/${linked.id}/identity-confirmation`, headers: finance.headers, payload: { expectedVersion: current.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成并发改身份' } }) }; const second = first === 'create' ? 'maintain' : 'create'; const firstResponse = start[first](); await untilWaiting(1); const secondResponse = start[second](); await untilWaiting(2); await holder.query('COMMIT'); [created, maintained] = first === 'create' ? [await firstResponse, await secondResponse] : [await secondResponse, await firstResponse]; } finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); } if (first === 'create') { assert.equal(created.statusCode, 200); assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().details?.reasonCode, 'loan.lender_in_flight'); } else { assert.equal(maintained.statusCode, 200, 'maintenance first succeeds'); assert.equal(created.statusCode, 200, 'creation after maintenance uses the new versions'); } const after = await prisma.loanLender.findUniqueOrThrow({ where: { id: linked.id } }); const stale = await prisma.loanRepayment.count({ where: { lender_id: linked.id, allocations: { some: { reservation_state: 'reserved' } }, OR: [{ lender_identity_version: { not: after.identity_version } }, { lender_account_version: { not: after.account_version } }] } }); assert.equal(stale, 0, `no reserved repayment may hold stale lender versions (${first} first)`); await call(finance, 'POST', `/payment-batches/${created.json().data.item.id}/cancel`, { reason: '合成并发结束' }); } } finally { await lockPool.end(); } checks.push('lender_maintenance_and_creation_deterministic_interleaving_never_leaves_stale_reservation'); // B: after the bank paid, approval-time eligibility decides. A departed // reviewer or lender employee must not strand settlement via OCR or manual review. const lenderEmployee = await actor(['employee']); const staffLender = await verifiedLender('合成出借人丙', lenderEmployee.id, '5'); const staffLoan = await confirmedLoan(staffLender.id, '500.00', '合成丙借入'); const setStatus = (who, status) => prisma.employee.update({ where: { id: who.id }, data: { status } }); const outstanding = async () => (await call(finance, 'GET', `/company-loans/${staffLoan.id}`)).outstanding; const reviewerA = await actor(['manager']); const viaOcr = await repay(staffLender.id, staffLoan.id, '10.00'); await call(reviewerA, 'POST', `/payment-batches/${viaOcr.id}/review`, {}); await exportOrder(viaOcr.id); await setStatus(reviewerA, 'archived'); await setStatus(lenderEmployee, 'archived'); const ocrResult = await receipt(viaOcr.id, '1000', 'SYNTHETIC-POST-PAY-OCR', { payeeAccount: staffLender.bankAccount }); assert.equal(ocrResult.result.status, 'matched', 'OCR must not fall back to legacy after departures'); assert.equal((await call(finance, 'POST', `/payment-batches/${viaOcr.id}/execute`, {})).item.status, 'executed'); assert.equal(await outstanding(), '490.00'); await setStatus(lenderEmployee, 'active'); const reviewerB = await actor(['manager']); const viaManual = await repay(staffLender.id, staffLoan.id, '20.00'); await call(reviewerB, 'POST', `/payment-batches/${viaManual.id}/review`, {}); await exportOrder(viaManual.id); await setStatus(reviewerB, 'archived'); await setStatus(lenderEmployee, 'archived'); const manualUnclear = await receipt(viaManual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }, { document: { status: 'success', reference: 'SYNTHETIC-POST-PAY-MANUAL', payeeAccount: staffLender.bankAccount } }); assert.equal(manualUnclear.result.status, 'needs_review'); const manualAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manualUnclear.attachment.id } }); const manualPath = `/payment-batches/${viaManual.id}/receipt-attachments/${manualUnclear.attachment.id}/manual-verification`; const manualProposal = await call(finance, 'POST', manualPath, { expectedVersion: manualAttachment.receipt_ocr_version, reason: '合成付款后人工核对', pages: [{ pageNumber: 1, itemId: viaManual.items[0].id, fields: Object.entries({ 小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: staffLender.bankAccount, 凭证号: 'SYNTHETIC-POST-PAY-MANUAL', 单据状态: '交易成功', }).map(([Name, Value]) => ({ Name, Value })) }] }); const manualSaved = manualProposal.item ?? manualProposal; await call(manager, 'POST', `${manualPath}/${manualSaved.id}/review`, { expectedVersion: manualSaved.version, decision: 'approve', reason: '合成当前独立复核' }); assert.equal((await call(finance, 'POST', `/payment-batches/${viaManual.id}/execute`, {})).item.status, 'executed'); assert.equal(await outstanding(), '470.00'); await setStatus(lenderEmployee, 'active'); checks.push('departed_reviewer_or_lender_employee_does_not_strand_paid_repayment_via_ocr_or_manual'); // Approval-time independence is still enforced after payment: a recorded // approver equal to the batch creator/submitter or lender employee is refused. for (const [label, approverId, amount] of [['creator', finance.id, '2.00'], ['lender_employee', lenderEmployee.id, '2.50']]) { const forged = await repay(staffLender.id, staffLoan.id, amount); await call(manager, 'POST', `/payment-batches/${forged.id}/review`, {}); await exportOrder(forged.id); const forgedReceipt = await receipt(forged.id, String(Math.round(Number(amount) * 100)), `SYNTHETIC-FORGED-APPROVER-${label}`, { payeeAccount: staffLender.bankAccount }); assert.equal(forgedReceipt.result.status, 'matched'); await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: approverId } }); await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {}, 403, 'loan.independent_review_required'); await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: manager.id } }); assert.equal((await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {})).item.status, 'executed'); } checks.push('post_payment_still_rejects_non_independent_recorded_approver'); // Pre-payment export still requires the approver to be currently eligible. const reviewerC = await actor(['manager']); const prePayment = await repay(staffLender.id, staffLoan.id, '1.00'); await call(reviewerC, 'POST', `/payment-batches/${prePayment.id}/review`, {}); await setStatus(reviewerC, 'archived'); await exportOrder(prePayment.id, 403, 'loan.independent_review_required'); await setStatus(reviewerC, 'active'); await call(finance, 'POST', `/payment-batches/${prePayment.id}/cancel`, { reason: '合成付款前资格失效' }); checks.push('pre_payment_export_keeps_current_approver_eligibility'); // C: a matched receipt does not prove which same-lender, same-amount batch // the bank paid. Settlement and failure release need an independently // reviewed attribution; OCR matches and manual receipt reviews cannot bypass it. const probeLender = await verifiedLender('合成出借人丁', null, '6'); const probeLoan = await confirmedLoan(probeLender.id, '500.00', '合成丁借入'); const probeBalance = async () => { const b = await call(finance, 'GET', `/company-loans/${probeLoan.id}`); return `${b.outstanding}/${b.reserved}`; }; const attribute = (who, batchId, body, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-receipt-attribution`, { basisKind: 'customer_serial', basisNote: '合成回单客户方流水号与本批次制单流水号一致', exclusionNote: '合成其他候选批次流水号均不一致', ...body }, status, reason); const reviewAttribution = (who, batchId, id, decision = 'approve', status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-receipt-attribution/${id}/review`, { decision, reason: '合成独立复核归属' }, status, reason); const detail = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}`)).item ?? (await call(finance, 'GET', `/payment-batches/${batchId}`)); // C1: two same-amount repayments in flight; P's genuine receipt uploaded to Q. const c1p = await repay(probeLender.id, probeLoan.id, '7.00'); const c1q = await repay(probeLender.id, probeLoan.id, '7.00'); for (const item of [c1p, c1q]) { await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); } const beforeC1 = await probeBalance(); const c1 = await receipt(c1q.id, '700', 'SYNTHETIC-C1-PAYMENT-OF-P', { payeeAccount: probeLender.bankAccount }); assert.equal(c1.result.status, 'matched'); await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); assert.equal(await probeBalance(), beforeC1, 'C1 wrong receipt must not settle or release'); const qState = (await detail(c1q.id)).loanRepayment.receiptAttribution; assert.equal(qState.required, true); assert.deepEqual(qState.competitors.map((item) => item.id), [c1p.id]); // Correct receipt for P still needs attribution because Q is a competitor. await receipt(c1p.id, '700', 'SYNTHETIC-C1-P-OWN', { payeeAccount: probeLender.bankAccount }); await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); await attribute(finance, c1p.id, { basisKind: 'time_alignment' }, 400); const proposed = await attribute(finance, c1p.id, {}); await attribute(finance, c1p.id, {}, 409, 'loan.receipt_attribution_exists'); await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); await reviewAttribution(finance, c1p.id, proposed.id, 'approve', 403); const dual = await actor(['finance', 'manager']); await reviewAttribution(dual, c1p.id, proposed.id, 'approve'); assert.equal((await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {})).item.status, 'executed'); await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); checks.push('same_amount_wrong_batch_receipt_blocked_and_reviewed_attribution_unlocks_settlement'); // A proposer holding manager rights cannot review their own attribution. const selfLender = await verifiedLender('合成出借人戊', null, '7'); const selfLoan = await confirmedLoan(selfLender.id, '100.00', '合成戊借入'); const s1 = await repay(selfLender.id, selfLoan.id, '3.00'); const s2 = await repay(selfLender.id, selfLoan.id, '3.00'); for (const item of [s1, s2]) { await call(independent, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); } await receipt(s1.id, '300', 'SYNTHETIC-SELF-REVIEW', { payeeAccount: selfLender.bankAccount }); const selfProposal = await attribute(dual, s1.id, {}); await reviewAttribution(dual, s1.id, selfProposal.id, 'approve', 403); await reviewAttribution(manager, s1.id, selfProposal.id, 'reject'); await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); // An approval stops covering once a new competitor reaches the bank. const reproposal = await attribute(finance, s1.id, {}); await reviewAttribution(manager, s1.id, reproposal.id, 'approve'); const s3 = await repay(selfLender.id, selfLoan.id, '3.00'); await call(independent, 'POST', `/payment-batches/${s3.id}/review`, {}); await exportOrder(s3.id); await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); const staleState = (await detail(s1.id)).loanRepayment.receiptAttribution.current; assert.equal(staleState.coversCurrentCompetitors, false); assert.equal(staleState.stale, true); checks.push('attribution_self_review_rejection_and_new_competitor_invalidate_approval'); // The stale approval must not deadlock the batch: re-registering supersedes it. const readiness = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}/receipt-readiness`)).item; assert.deepEqual(await readiness(s1.id), { ready: false, receiptAttributionRequired: true }); const renewed = await attribute(finance, s1.id, {}); assert.equal((await prisma.loanReceiptAttribution.findUniqueOrThrow({ where: { id: reproposal.id } })).status, 'superseded'); await reviewAttribution(manager, s1.id, renewed.id, 'approve'); assert.deepEqual(await readiness(s1.id), { ready: true }); assert.equal((await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {})).item.status, 'executed'); checks.push('stale_approval_superseded_by_reregistration_then_settles'); // Swapping the receipt after approval: the old approval cannot unlock the // new transaction, and a new registration for the new receipt works. const swapReviewer = await actor(['manager']); const swapExporter = await actor(['finance', 'manager']); const swap = await repay(selfLender.id, selfLoan.id, '3.00'); await call(independent, 'POST', `/payment-batches/${swap.id}/review`, {}); const swapExport = await app.inject({ method: 'POST', url: `/api/payment-batches/${swap.id}/payment-order`, headers: swapExporter.headers, payload: {} }); assert.equal(swapExport.statusCode, 200, 'exporter with manager role exports'); const firstReceipt = await receipt(swap.id, '300', 'SYNTHETIC-SWAP-FIRST', { payeeAccount: selfLender.bankAccount }); const swapApproved = await attribute(finance, swap.id, {}); await reviewAttribution(swapExporter, swap.id, swapApproved.id, 'approve', 403); await reviewAttribution(swapReviewer, swap.id, swapApproved.id, 'approve'); await call(finance, 'POST', `/payment-batches/${swap.id}/receipt-attachments/${firstReceipt.attachment.id}/void`, { reason: '合成回单上传错误作废' }); await receipt(swap.id, '300', 'SYNTHETIC-SWAP-SECOND', { payeeAccount: selfLender.bankAccount }); await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); assert.equal((await detail(swap.id)).loanRepayment.receiptAttribution.current.stale, true); const swapRenewed = await attribute(finance, swap.id, {}); await reviewAttribution(swapReviewer, swap.id, swapRenewed.id, 'approve'); assert.equal((await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {})).item.status, 'executed'); checks.push('receipt_swap_invalidates_approval_exporter_cannot_review_and_reregistration_settles'); // A proposal whose receipt was voided can still be rejected (no receipt needed). const orphanReceipt = await receipt(s2.id, '300', 'SYNTHETIC-ORPHAN', { payeeAccount: selfLender.bankAccount }); const orphan = await attribute(finance, s2.id, {}); await call(finance, 'POST', `/payment-batches/${s2.id}/receipt-attachments/${orphanReceipt.attachment.id}/void`, { reason: '合成回单作废' }); assert.equal((await detail(s2.id)).loanRepayment.receiptAttribution.receiptAvailable, false); await attribute(finance, s2.id, {}, 409, 'loan.receipt_attribution_unavailable'); await reviewAttribution(swapReviewer, s2.id, orphan.id, 'reject'); checks.push('proposal_without_receipt_can_be_rejected'); // Same transaction, different evidence (re-scanned receipt with the same bank // reference): the approval is bound to the reviewed evidence and stops unlocking. const rescan = await repay(selfLender.id, selfLoan.id, '3.00'); await call(independent, 'POST', `/payment-batches/${rescan.id}/review`, {}); await exportOrder(rescan.id); const rescanFirst = await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount }); const rescanApproved = await attribute(finance, rescan.id, {}); await reviewAttribution(swapReviewer, rescan.id, rescanApproved.id, 'approve'); await call(finance, 'POST', `/payment-batches/${rescan.id}/receipt-attachments/${rescanFirst.attachment.id}/void`, { reason: '合成重新扫描' }); await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount }); await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); assert.equal((await detail(rescan.id)).loanRepayment.receiptAttribution.current.stale, true); const rescanRenewed = await attribute(finance, rescan.id, {}); const supersedeAudit = await prisma.auditLog.findFirst({ where: { action: 'LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED' }, orderBy: { created_at: 'desc' } }); assert.ok(JSON.stringify(supersedeAudit).includes('receipt_evidence_changed')); await reviewAttribution(swapReviewer, rescan.id, rescanRenewed.id, 'approve'); assert.equal((await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {})).item.status, 'executed'); checks.push('same_transaction_new_evidence_invalidates_approval_until_rereviewed'); // Settlement's competitor check and a competitor's first receipt upload are // serialized by the lender lock. Deterministic interleaving in both orders. const raceLender = await verifiedLender('合成出借人庚', null, '2'); const raceLoan = await confirmedLoan(raceLender.id, '100.00', '合成庚借入'); const uploadOnly = (batchId) => { const boundary = `synthetic-${randomUUID()}`; return 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`)]) }); }; const executeRequest = (batchId) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} }); const racePool = new LockPool({ ...pool.options, max: 2 }); const raceWaiters = async () => Number((await racePool.query( "SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n); async function raceWaiting(count) { for (let i = 0; i < 300; i++) { if (await raceWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); } throw new Error(`expected ${count} requests queued behind the lender lock`); } try { for (const [order, amount, cents] of [['settle_first', '9.00', '900'], ['upload_first', '8.00', '800']]) { const settling = await repay(raceLender.id, raceLoan.id, amount); await call(manager, 'POST', `/payment-batches/${settling.id}/review`, {}); await exportOrder(settling.id); assert.equal((await receipt(settling.id, cents, `SYNTHETIC-RACE-${order}`, { payeeAccount: raceLender.bankAccount })).result.status, 'matched'); const competitor = await repay(raceLender.id, raceLoan.id, amount); await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {}); const holder = await racePool.connect(); let executed; let uploaded; try { await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [raceLender.id]); if (order === 'settle_first') { executed = executeRequest(settling.id); await raceWaiting(1); uploaded = uploadOnly(competitor.id); await raceWaiting(2); } else { uploaded = uploadOnly(competitor.id); await raceWaiting(1); executed = executeRequest(settling.id); await raceWaiting(2); } await holder.query('COMMIT'); [executed, uploaded] = [await executed, await uploaded]; } finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); } assert.equal(uploaded.statusCode, 200, `${order} competitor upload`); if (order === 'settle_first') assert.equal(executed.statusCode, 200, 'settlement checked before the competitor existed'); else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().details?.reasonCode, 'loan.receipt_attribution_required'); } // Keep the shared OCR queue clean for later scenarios. await call(finance, 'POST', `/payment-batches/${competitor.id}/receipt-attachments/${uploaded.json().data.items[0].id}/void`, { reason: '合成并发验证结束' }); } } finally { await racePool.end(); } checks.push('competitor_upload_and_settlement_serialize_on_lender_lock_both_orders'); // S1: exports and bank submissions are separate facts; failure confirmation // is gated on one attested submission and on voided receipts being resolved. const s1Lender = await verifiedLender('合成出借人辛', null, '1', '1357913579135791'); const s1Loan = await confirmedLoan(s1Lender.id, '1000.00', '合成辛借入'); const s1Batch = async (amount) => { const item = await repay(s1Lender.id, s1Loan.id, amount); await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); return item; }; const failReceipt = (batchId, cents, ref) => receipt(batchId, cents, ref, { payeeAccount: s1Lender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); const confirmFailure = (batchId, attachmentId, status = 200, reason) => call(manager, 'POST', `/payment-batches/${batchId}/bank-failure-review`, { attachmentId }, status, reason); // Two exports: recorded separately; one failed receipt cannot close both. const twice = await s1Batch('11.00'); await exportOrder(twice.id); await exportOrder(twice.id); const exportsState = (await detail(twice.id)).loanRepayment.bankSubmissions.exports; assert.deepEqual(exportsState.map((item) => item.sequence), [1, 2]); assert.ok(exportsState.every((item) => Array.isArray(item.customerSerials) && item.customerSerials.length === 1)); const twiceFail = await failReceipt(twice.id, '1100', 'SYNTHETIC-S1-TWICE'); await attestSubmission(twice.id); await confirmFailure(twice.id, twiceFail.attachment.id, 409, 'loan.multiple_submissions_unresolved'); checks.push('two_exports_recorded_and_single_failure_cannot_close_them'); // A voided receipt that once showed success or pending keeps blocking. for (const [label, status, cents] of [['success', 'success', '1200'], ['pending', 'pending', '1300']]) { const voidedCase = await s1Batch(String(Number(cents) / 100) + '.00'); const earlier = await receipt(voidedCase.id, cents, `SYNTHETIC-S1-VOIDED-${label}`, { payeeAccount: s1Lender.bankAccount, transactionStatus: status, issues: status === 'pending' ? ['transaction_pending'] : [] }); await call(finance, 'POST', `/payment-batches/${voidedCase.id}/receipt-attachments/${earlier.attachment.id}/void`, { reason: '合成作废' }); const laterFail = await failReceipt(voidedCase.id, cents, `SYNTHETIC-S1-AFTER-${label}`); await attestSubmission(voidedCase.id); await confirmFailure(voidedCase.id, laterFail.attachment.id, 409, 'loan.voided_receipt_unresolved'); } checks.push('voided_success_or_pending_receipt_keeps_blocking_failure'); // Voided material fails closed: without readable proof that it is a failure // or a clearly different transaction, it keeps blocking. const voidedBlocks = [ ['before_ocr', null], ['unreadable', { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }], ['ambiguous_status', { transactionStatus: null, statusWordings: ['交易失败', '处理中'] }], // Explicitly synthetic (real normalization cannot produce it): defence in depth. ['failed_with_pending_issue', { transactionStatus: 'failed', transactionStatusRaw: '交易失败', issues: ['transaction_failed', 'transaction_pending'] }], // Same payer and payee, different amount: possibly a misread of this payment. ['same_accounts_other_amount', { amount: '1' }], // A voided "revoked" slip is not a clear failure: it keeps blocking. ['voided_revoked', { transactionStatus: 'failed', statusWordings: ['已撤销'] }], ['voided_failed_plus_revoked', { transactionStatus: 'failed', statusWordings: ['交易失败', '已撤销'] }], ]; const voidedAmounts = { before_ocr: '17', unreadable: '18', ambiguous_status: '19', failed_with_pending_issue: '21', same_accounts_other_amount: '22', voided_revoked: '23', voided_failed_plus_revoked: '24' }; for (const [label, overrides] of voidedBlocks) { const blocked = await s1Batch(`${voidedAmounts[label]}.00`); const cents = `${voidedAmounts[label]}00`; let voidedId; if (overrides === null) { const boundary = `synthetic-${randomUUID()}`; const raw = await app.inject({ method: 'POST', url: `/api/payment-batches/${blocked.id}/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`)]) }); voidedId = raw.json().data.items[0].id; } else { voidedId = (await receipt(blocked.id, cents, `SYNTHETIC-S1-${label}`, { payeeAccount: s1Lender.bankAccount, ...overrides }, { normalizedPage: label === 'failed_with_pending_issue' })).attachment.id; } await call(finance, 'POST', `/payment-batches/${blocked.id}/receipt-attachments/${voidedId}/void`, { reason: '合成作废' }); const laterFail = await failReceipt(blocked.id, cents, `SYNTHETIC-S1-AFTER-${label}`); await attestSubmission(blocked.id); await confirmFailure(blocked.id, laterFail.attachment.id, 409, 'loan.voided_receipt_unresolved'); } checks.push('voided_without_readable_proof_fails_closed'); // A voided, readable success receipt of a different payment no longer blocks; // attestation independence; any later add or withdraw invalidates approval. const misupload = await s1Batch('14.00'); const otherPayment = await receipt(misupload.id, '9900', 'SYNTHETIC-S1-OTHER-PAYMENT', { payeeAccount: '9'.repeat(16) }); await call(finance, 'POST', `/payment-batches/${misupload.id}/receipt-attachments/${otherPayment.attachment.id}/void`, { reason: '合成误传他笔回单' }); const misuploadFail = await failReceipt(misupload.id, '1400', 'SYNTHETIC-S1-MISUPLOAD'); await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成无提交清单' }, 409, 'loan.submission_required'); await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'online_banking_import', submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-NO-EXPORT', note: '合成网银导入无导出' }, 409, 'loan.submission_export_invalid'); const registrarDual = await actor(['finance', 'manager']); const firstSubmission = await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'manual_entry', submittedAt: new Date().toISOString(), bankInstructionRef: 'SYNTHETIC-S1-MISUPLOAD', note: '合成手工提交登记' }); await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'manual_entry', submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-MANAGER', note: '合成经理不能登记' }, 403); // Registered but not yet attested: still refused. await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified'); const misAttest = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成提交清单完整' }); await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified'); await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成重复提交清单' }, 409, 'loan.submission_attestation_exists'); await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${misAttest.id}/review`, { decision: 'approve', reason: '合成登记人自审' }, 403); await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${misAttest.id}/review`, { decision: 'approve', reason: '合成清单复核' }); const extra = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-EXTRA', note: '合成补登第二次提交' }); await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.multiple_submissions_unresolved'); await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成误登撤回' }); await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成重复撤回' }, 409); // The unreviewed add-then-withdraw does not revive the earlier approval. assert.equal((await detail(misupload.id)).loanRepayment.bankSubmissions.attestation.stale, true); await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified'); const reAttest = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成撤回后重新确认清单' }); assert.equal((await prisma.loanBankSubmissionAttestation.findUniqueOrThrow({ where: { id: misAttest.id } })).status, 'superseded'); await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${reAttest.id}/review`, { decision: 'approve', reason: '合成重新复核' }); // The submission registrar (even with manager rights) cannot confirm the failure. await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/bank-failure-review`, { attachmentId: misuploadFail.attachment.id }, 403); await confirmFailure(misupload.id, misuploadFail.attachment.id); await call(finance, 'POST', `/payment-batches/${misupload.id}/cancel`, { reason: '合成确认失败后释放' }); assert.ok(firstSubmission.id); checks.push('voided_other_payment_ignored_attestation_independent_and_any_list_change_invalidates'); // A pre-existing batch carries only a known export count: never treated as // a real export or submission; failure confirmation stays blocked. const legacyBatch = await s1Batch('15.00'); await prisma.$executeRaw`INSERT INTO loan_legacy_export_summaries (payment_batch_id, known_export_count) VALUES (${legacyBatch.id}::uuid, 2)`; const legacyFail = await failReceipt(legacyBatch.id, '1500', 'SYNTHETIC-S1-LEGACY'); const legacyState = (await detail(legacyBatch.id)).loanRepayment.bankSubmissions; assert.equal(legacyState.legacy.knownExportCount, 2); assert.equal(legacyState.exports.length, 0); await confirmFailure(legacyBatch.id, legacyFail.attachment.id, 409, 'loan.submission_history_unverified'); checks.push('legacy_export_summary_keeps_known_count_and_blocks_failure'); // Failure confirmation is a post-payment fact: a departed lender employee // does not block it; the confirming reviewer is still judged currently. const departingLender = await actor(['employee']); const staffFailLender = await verifiedLender('合成出借人壬', departingLender.id, '2', '2468024680246802'); const staffFailLoan = await confirmedLoan(staffFailLender.id, '100.00', '合成壬借入'); const staffFail = await repay(staffFailLender.id, staffFailLoan.id, '16.00'); await call(manager, 'POST', `/payment-batches/${staffFail.id}/review`, {}); const staffFailReceipt = await receipt(staffFail.id, '1600', 'SYNTHETIC-S1-DEPARTED', { payeeAccount: staffFailLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); await attestSubmission(staffFail.id); await setStatus(departingLender, 'archived'); await call(manager, 'POST', `/payment-batches/${staffFail.id}/bank-failure-review`, { attachmentId: staffFailReceipt.attachment.id }); await setStatus(departingLender, 'active'); checks.push('failure_confirmation_after_lender_employee_departure'); // Failure wording: only the four "failed" wordings prove no money left; // revoked/reversed/returned, incomplete or mismatched slips are refused. const wordingCases = [ ['转账失败', {}, 200, null], ['付款失败', {}, 200, null], ['支付失败', {}, 200, null], // Two status fields that both normalize to "failed": refused in either order. ['交易失败', { statusWordings: ['交易失败', '已撤销'] }, 409, 'loan.bank_state_unsafe'], ['已撤销', { statusWordings: ['已撤销', '交易失败'] }, 409, 'loan.bank_state_unsafe'], ['已撤销', {}, 409, 'loan.bank_state_unsafe'], ['已冲正', {}, 409, 'loan.bank_state_unsafe'], ['已退汇', {}, 409, 'loan.bank_state_unsafe'], ['交易失败', { reference: null }, 409, 'loan.bank_state_unsafe'], ['交易失败', { pdfPages: 2 }, 409, 'loan.bank_state_unsafe'], ['交易失败', { amount: '1' }, 409, 'loan.bank_state_unsafe'], // Explicitly synthetic: an extra issue besides the failure itself. ['交易失败', { normalizedPage: true, transactionStatusRaw: '交易失败', issues: ['transaction_failed', 'payerAccount_ambiguous'] }, 409, 'loan.bank_state_unsafe'], ]; let wordingAmount = 30; for (const [wording, variant, status, reason] of wordingCases) { wordingAmount += 1; const item = await s1Batch(`${wordingAmount}.00`); const { pdfPages, normalizedPage, ...slip } = variant; const failed = await receipt(item.id, `${wordingAmount}00`, `SYNTHETIC-WORDING-${wordingAmount}`, { payeeAccount: s1Lender.bankAccount, transactionStatus: 'failed', statusWordings: [wording], ...slip }, { pdfPages: pdfPages ?? 1, normalizedPage }); assert.notEqual(failed.result.status, 'matched'); await attestSubmission(item.id); await confirmFailure(item.id, failed.attachment.id, status, reason); } checks.push('only_four_failure_wordings_with_complete_single_page_slip_confirm_failure'); // ===== S3: bank terminal facts and "not paid" release ===== config.loanReleaseWaitDays = 0; const s3Lender = await verifiedLender('合成出借人癸', null, '3', '3579135791357913'); const s3Loan = await confirmedLoan(s3Lender.id, '1000.00', '合成癸借入'); const s3Batch = async (amount) => { const item = await repay(s3Lender.id, s3Loan.id, amount); await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); return item; }; const s3Exports = async (batchId) => prisma.paymentOrderExport.findMany({ where: { payment_batch_id: batchId }, orderBy: { sequence: 'asc' } }); const register = (batchId, ref, exportId, who = finance, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-bank-submissions`, { ...(exportId ? { exportId, channel: 'online_banking_import' } : { channel: 'manual_entry' }), submittedAt: new Date().toISOString(), bankInstructionRef: ref, note: '合成银行提交登记' }, status, reason); const attest = async (batchId) => { const proposal = await call(finance, 'POST', `/payment-batches/${batchId}/loan-submission-attestation`, { note: '合成提交清单完整' }); await call(manager, 'POST', `/payment-batches/${batchId}/loan-submission-attestation/${proposal.id}/review`, { decision: 'approve', reason: '合成清单复核' }); }; // Each synthetic bank letter is a distinct document (unique title). const bankLetterPdf = async () => { const doc = await PDFDocument.create(); doc.setTitle(`合成银行回复 ${randomUUID()}`); doc.addPage([200, 200]); return Buffer.from(await doc.save()); }; async function uploadEvidence(batchId, bytes = null) { const letter = bytes ?? await bankLetterPdf(); const boundary = `synthetic-${randomUUID()}`; const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-terminal-evidences`, headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), letter, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); assert.equal(response.statusCode, 200, `terminal evidence upload ${response.body.slice(0, 120)}`); return response.json().data.id; } const proposeFact = (batchId, body, status = 200, reason) => call(finance, 'POST', `/payment-batches/${batchId}/loan-terminal-facts`, { note: '合成银行终态说明', ...body }, status, reason); const reviewFact = (batchId, factId, who = manager, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-terminal-facts/${factId}/review`, { decision: 'approve', reason: '合成终态复核' }, status, reason); const confirmNotPaid = (batchId, who = manager, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-not-paid/review`, {}, status, reason); const cancelBatch = (batchId, status = 200, reason) => call(finance, 'POST', `/payment-batches/${batchId}/cancel`, { reason: '合成确认未付款后释放' }, status, reason); const s3Available = async () => Number((await call(finance, 'GET', `/company-loans/${s3Loan.id}`)).available); const s3Slip = (batchId, cents, ref, status, wording) => receipt(batchId, cents, ref, { payeeAccount: s3Lender.bankAccount, transactionStatus: status, ...(wording ? { statusWordings: [wording] } : {}) }); // Two exports, two submissions: each needs its own terminal fact; one // instruction's terminal state does not end the other. const multi = await s3Batch('41.00'); await exportOrder(multi.id); await exportOrder(multi.id); const [mE1, mE2] = await s3Exports(multi.id); const mS1 = await register(multi.id, 'S3-MULTI-1', mE1.id); const mS2 = await register(multi.id, 'S3-MULTI-2', mE2.id, registrarDual); const conflictBatch = await s3Batch('42.00'); await register(conflictBatch.id, 's3-multi-1', null, finance, 409, 'loan.instruction_ref_conflict'); await attest(multi.id); await confirmNotPaid(multi.id, manager, 409, 'loan.terminal_coverage_incomplete'); const evidence1 = await uploadEvidence(multi.id); assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: multi.loanRepaymentId } })).bank_result_state, 'unknown'); assert.equal(await prisma.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: multi.id } }), 0, 'evidence is not a payment receipt'); const fact1 = await proposeFact(multi.id, { submissionId: mS1.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: evidence1 }); await proposeFact(multi.id, { submissionId: mS1.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: evidence1 }, 409, 'loan.terminal_fact_exists'); await reviewFact(multi.id, fact1.id, finance, 403); await reviewFact(multi.id, fact1.id); await confirmNotPaid(multi.id, manager, 409, 'loan.terminal_coverage_incomplete'); const mFail = await s3Slip(multi.id, '4100', 'S3-MULTI-2', 'failed'); // A failed slip of a different instruction cannot cover this submission. await proposeFact(multi.id, { submissionId: mS1.id, kind: 'failure_receipt', receiptAttachmentId: mFail.attachment.id }, 409, 'loan.terminal_fact_exists'); const fact2 = await proposeFact(multi.id, { submissionId: mS2.id, kind: 'failure_receipt', receiptAttachmentId: mFail.attachment.id }); await reviewFact(multi.id, fact2.id); await confirmNotPaid(multi.id, registrarDual, 403); const beforeMulti = await s3Available(); await confirmNotPaid(multi.id); await cancelBatch(multi.id); assert.equal(await s3Available(), beforeMulti + 41); checks.push('s3_each_submission_needs_own_terminal_fact_then_release'); // A failed slip whose reference is another instruction is rejected for this submission. const wrongRef = await s3Batch('43.00'); await exportOrder(wrongRef.id); const [wE] = await s3Exports(wrongRef.id); const wS = await register(wrongRef.id, 'S3-WRONGREF-1', wE.id); await attest(wrongRef.id); const wFail = await s3Slip(wrongRef.id, '4300', 'S3-SOMETHING-ELSE', 'failed'); await proposeFact(wrongRef.id, { submissionId: wS.id, kind: 'failure_receipt', receiptAttachmentId: wFail.attachment.id }, 409, 'loan.terminal_fact_invalid'); checks.push('s3_failure_slip_bound_to_submission_instruction_ref'); // Export not linked to any submission blocks release. const unlinked = await s3Batch('44.00'); await exportOrder(unlinked.id); const uS = await register(unlinked.id, 'S3-UNLINKED-1', null); await attest(unlinked.id); const uEv = await uploadEvidence(unlinked.id); const uFact = await proposeFact(unlinked.id, { submissionId: uS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: uEv }); await reviewFact(unlinked.id, uFact.id); await confirmNotPaid(unlinked.id, manager, 409, 'loan.export_unlinked'); checks.push('s3_unlinked_export_blocks_release'); // Pending slip of the same instruction is settled by its terminal fact, // whether still active or already voided; another instruction cannot. for (const voidFirst of [false, true]) { const pend = await s3Batch(voidFirst ? '46.00' : '45.00'); await exportOrder(pend.id); await exportOrder(pend.id); const [pE1, pE2] = await s3Exports(pend.id); const ref1 = voidFirst ? 'S3-PEND-V-1' : 'S3-PEND-A-1'; const ref2 = voidFirst ? 'S3-PEND-V-2' : 'S3-PEND-A-2'; const pS1 = await register(pend.id, ref1, pE1.id); const pS2 = await register(pend.id, ref2, pE2.id); const pending = await s3Slip(pend.id, voidFirst ? '4600' : '4500', ref1, 'pending'); if (voidFirst) await call(finance, 'POST', `/payment-batches/${pend.id}/receipt-attachments/${pending.attachment.id}/void`, { reason: '合成处理中回单作废' }); await attest(pend.id); const pEv = await uploadEvidence(pend.id); // Another submission's terminal fact cannot settle this pending slip. await proposeFact(pend.id, { submissionId: pS2.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pEv, resolvesAttachmentIds: [pending.attachment.id] }, 409, 'loan.terminal_fact_invalid'); const pF1 = await proposeFact(pend.id, { submissionId: pS1.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pEv, resolvesAttachmentIds: [pending.attachment.id] }); if (voidFirst) { const url = `/api/payment-batches/${pend.id}/receipt-attachments/${pending.attachment.id}/file`; assert.equal((await app.inject({ method: 'GET', url, headers: manager.headers })).statusCode, 409); const query = '?purpose=loan_reconciliation&reason=' + encodeURIComponent('合成复核已作废的处理中银行事实'); const historical = await app.inject({ method: 'GET', url: url + query, headers: manager.headers }); assert.equal(historical.statusCode, 200); assert.match(historical.headers['cache-control'], /no-store/); assert.equal((await app.inject({ method: 'GET', url: url + query, headers: ordinary.headers })).statusCode, 403); assert.equal((await app.inject({ method: 'GET', url: url + '?purpose=loan_reconciliation', headers: manager.headers })).statusCode, 400); assert.ok(await prisma.auditLog.count({ where: { target_id: pend.id, action: 'PAYMENT_BATCH_RECEIPT_DOWNLOADED' } })); } await reviewFact(pend.id, pF1.id); const pF2 = await proposeFact(pend.id, { submissionId: pS2.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: pEv }); await reviewFact(pend.id, pF2.id); await confirmNotPaid(pend.id); await cancelBatch(pend.id); } // Without a resolving fact the pending slip keeps blocking. const pendBlocked = await s3Batch('47.00'); await exportOrder(pendBlocked.id); const [pbE] = await s3Exports(pendBlocked.id); const pbS = await register(pendBlocked.id, 'S3-PEND-B-1', pbE.id); await s3Slip(pendBlocked.id, '4700', 'S3-PEND-B-1', 'pending'); await attest(pendBlocked.id); const pbEv = await uploadEvidence(pendBlocked.id); const pbF = await proposeFact(pendBlocked.id, { submissionId: pbS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pbEv }); await reviewFact(pendBlocked.id, pbF.id); await confirmNotPaid(pendBlocked.id, manager, 409, 'loan.receipt_unresolved_for_release'); checks.push('s3_pending_slip_resolved_only_by_same_instruction_terminal_fact'); // Internal wait period. const waiting = await s3Batch('48.00'); await exportOrder(waiting.id); const [waE] = await s3Exports(waiting.id); const waS = await register(waiting.id, 'S3-WAIT-1', waE.id); await attest(waiting.id); const waEv = await uploadEvidence(waiting.id); const waF = await proposeFact(waiting.id, { submissionId: waS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: waEv }); await reviewFact(waiting.id, waF.id); config.loanReleaseWaitDays = 3; await confirmNotPaid(waiting.id, manager, 409, 'loan.release_wait_period'); config.loanReleaseWaitDays = 0; await confirmNotPaid(waiting.id); // Final release re-checks: evidence voided after confirmation blocks the cancel. await call(finance, 'POST', `/payment-batches/${waiting.id}/loan-terminal-evidences/${waEv}/void`, { reason: '合成证据作废' }); await cancelBatch(waiting.id, 409, 'loan.terminal_coverage_incomplete'); checks.push('s3_wait_period_and_voided_evidence_blocks_final_release'); // Final release re-checks in the cancel transaction, with deterministic // interleaving against a late receipt and a new submission (batch lock). const s3LockPool = new LockPool({ ...pool.options, max: 2 }); const s3Waiters = async () => Number((await s3LockPool.query( "SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n); const s3Waiting = async (count) => { for (let i = 0; i < 300; i++) { if (await s3Waiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); } throw new Error('not queued'); }; const readyToRelease = async (amount, ref) => { const item = await s3Batch(amount); await exportOrder(item.id); const [e] = await s3Exports(item.id); const sub = await register(item.id, ref, e.id); await attest(item.id); const ev = await uploadEvidence(item.id); const f = await proposeFact(item.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: ev }); await reviewFact(item.id, f.id); await confirmNotPaid(item.id); return item; }; const lateUpload = (batchId, ref) => { const boundary = `synthetic-${randomUUID()}`; return 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="late.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), onePagePdf, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); }; const lateSubmission = (batchId, ref) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-bank-submissions`, headers: finance.headers, payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: ref, note: '合成晚到的另一次提交' } }); const cancelRequest = (batchId) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/cancel`, headers: finance.headers, payload: { reason: '合成并发释放' } }); try { let n = 50; for (const [label, makeLate] of [['receipt', lateUpload], ['submission', lateSubmission]]) { for (const order of ['release_first', 'late_first']) { n += 1; const item = await readyToRelease(`${n}.00`, `S3-RACE-${label}-${order}`.toUpperCase()); const holder = await s3LockPool.connect(); let released; let late; try { await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id = $1 FOR UPDATE', [item.id]); if (order === 'release_first') { released = cancelRequest(item.id); await s3Waiting(1); late = makeLate(item.id, `S3-LATE-${label}-${n}`.toUpperCase()); await s3Waiting(2); } else { late = makeLate(item.id, `S3-LATE-${label}-${n}`.toUpperCase()); await s3Waiting(1); released = cancelRequest(item.id); await s3Waiting(2); } await holder.query('COMMIT'); [released, late] = [await released, await late]; } finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); } if (order === 'release_first') { assert.equal(released.statusCode, 200, `${label} release first`); assert.equal(late.statusCode, 409, `${label} after release is refused`); } else { assert.equal(late.statusCode, 200, `${label} late first`); assert.equal(released.statusCode, 409, `${label} release after late ${released.body.slice(0, 100)}`); } } } } finally { await s3LockPool.end(); } // Drain the OCR queue of the late uploads (recognized as unreadable) so later // scenarios process their own receipts. for (let i = 0; i < 10; i++) { const drained = await runReceiptOcrOnce({ prisma, config, recognize: async () => ({ status: 'needs_review', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, amount: null, currency: null, payerAccount: null, payeeAccount: null, transactionStatus: null, reference: null, issues: ['amount_unresolved'] }] }) }); if (drained.status === 'idle') break; } checks.push('s3_final_release_rechecks_and_serializes_with_late_receipt_and_submission'); // The single-failure path also re-checks at release: a late receipt blocks it. const failPath = await s3Batch('60.00'); const fpFail = await s3Slip(failPath.id, '6000', 'S3-FAILPATH-1', 'failed'); await attestSubmission(failPath.id); await call(manager, 'POST', `/payment-batches/${failPath.id}/bank-failure-review`, { attachmentId: fpFail.attachment.id }); await s3Slip(failPath.id, '6000', 'S3-FAILPATH-LATE', 'success'); await cancelBatch(failPath.id, 409, 'loan.bank_state_unsafe'); checks.push('s3_failure_path_rechecks_at_release'); // The single failure path also honours the internal wait after the last export, // both when confirming and again at the final cancel. const failWait = await s3Batch('68.00'); await exportOrder(failWait.id); const fwFail = await s3Slip(failWait.id, '6800', 'S3-FAILWAIT-1', 'failed'); await attestSubmission(failWait.id, { ref: 'S3-FAILWAIT-1' }); config.loanReleaseWaitDays = 3; await call(manager, 'POST', `/payment-batches/${failWait.id}/bank-failure-review`, { attachmentId: fwFail.attachment.id }, 409, 'loan.release_wait_period'); config.loanReleaseWaitDays = 0; await call(manager, 'POST', `/payment-batches/${failWait.id}/bank-failure-review`, { attachmentId: fwFail.attachment.id }); config.loanReleaseWaitDays = 3; await cancelBatch(failWait.id, 409, 'loan.release_wait_period'); config.loanReleaseWaitDays = 0; await cancelBatch(failWait.id); checks.push('s3_failure_path_honours_wait_period_at_confirm_and_cancel'); // B1: registering a submission ends "never sent"; it cannot be cancelled directly. const neverSent = await s3Batch('61.00'); await register(neverSent.id, 'S3-NEVERSENT-1', null); assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: neverSent.loanRepaymentId } })).bank_result_state, 'unknown'); await cancelBatch(neverSent.id, 409, 'loan.bank_state_unsafe'); await register(neverSent.id, 's3-neversent-1', null, finance, 409, 'loan.instruction_ref_conflict'); checks.push('s3_submission_registration_ends_never_sent_and_refs_unique_within_batch'); // B3: reviewers can view the evidence they approve, with a reason and audit. const viewEvidence = await prisma.loanBankTerminalEvidence.findFirstOrThrow({ where: { payment_batch_id: multi.id } }); const viewPath = `/api/payment-batches/${multi.id}/loan-terminal-evidences/${viewEvidence.id}/file`; assert.equal((await app.inject({ method: 'GET', url: viewPath, headers: manager.headers })).statusCode, 400, 'reason required'); const viewed = await app.inject({ method: 'GET', url: viewPath, headers: { ...manager.headers, 'x-access-reason': encodeURIComponent('合成复核查看证据') } }); assert.equal(viewed.statusCode, 200); assert.equal(require('node:crypto').createHash('sha256').update(viewed.rawPayload).digest('hex'), viewEvidence.file_sha256, 'evidence bytes served unchanged'); assert.equal(viewed.headers['cache-control'], 'no-store'); assert.equal((await app.inject({ method: 'GET', url: viewPath, headers: { ...ordinary.headers, 'x-access-reason': 'x1x' } })).statusCode, 403); assert.ok(await prisma.auditLog.count({ where: { action: 'LOAN_TERMINAL_EVIDENCE_VIEWED' } }) >= 1); checks.push('s3_terminal_evidence_viewable_by_reviewer_with_reason_and_audit'); // Non-blocking hardening: no export after not-paid; no void after release. await call(finance, 'POST', `/payment-batches/${multi.id}/loan-terminal-evidences/${viewEvidence.id}/void`, { reason: '合成释放后作废' }, 409, 'loan.payment_not_ready'); const noReexport = await readyToRelease('62.00', 'S3-NOREEXPORT-1'); await exportOrder(noReexport.id, 409, 'loan.bank_state_unsafe'); await cancelBatch(noReexport.id); await exportOrder(noReexport.id, 409, 'loan.payment_not_ready'); checks.push('s3_no_reexport_after_conclusion_or_release_and_no_void_after_release'); // B2: with same-amount competitors a failure_receipt terminal fact needs a // per-submission failure attribution; one failed transaction backs only one batch. const compA = await s3Batch('63.00'); const compB = await s3Batch('63.00'); for (const item of [compA, compB]) await exportOrder(item.id); const [cAE] = await s3Exports(compA.id); const cAS = await register(compA.id, 'S3-COMP-A', cAE.id); await attest(compA.id); const cAFail = await s3Slip(compA.id, '6300', 'S3-COMP-A', 'failed'); await proposeFact(compA.id, { submissionId: cAS.id, kind: 'failure_receipt', receiptAttachmentId: cAFail.attachment.id }, 409, 'loan.receipt_attribution_required'); const perSubmission = await attribute(finance, compA.id, { kind: 'failure', attachmentId: cAFail.attachment.id, submissionId: cAS.id, basisKind: 'bank_instruction_ref', basisNote: '合成失败回单流水号即本批次登记的指令号', exclusionNote: '合成同额批次未登记该指令号' }); await reviewAttribution(independent, compA.id, perSubmission.id, 'approve'); const cAFact = await proposeFact(compA.id, { submissionId: cAS.id, kind: 'failure_receipt', receiptAttachmentId: cAFail.attachment.id }); await reviewFact(compA.id, cAFact.id); await confirmNotPaid(compA.id); await cancelBatch(compA.id); // The same failed transaction cannot also release batch B through the single path. const cBFail = await s3Slip(compB.id, '6300', 'S3-COMP-A', 'failed'); const [cBE] = await s3Exports(compB.id); await register(compB.id, 'S3-COMP-B', cBE.id); await attest(compB.id); // Bound to its own instruction, B cannot even attribute A's failed slip; the // evidence-use registry stays as defence in depth behind this binding. await attribute(finance, compB.id, { kind: 'failure', attachmentId: cBFail.attachment.id, basisKind: 'bank_instruction_ref', basisNote: '合成试图复用他批失败回单', exclusionNote: '合成试图复用他批失败回单' }, 409, 'loan.terminal_fact_invalid'); await call(manager, 'POST', `/payment-batches/${compB.id}/bank-failure-review`, { attachmentId: cBFail.attachment.id }, 409, 'loan.terminal_fact_invalid'); assert.equal((await prisma.loanFailureEvidenceUse.findMany({ where: { payment_batch_id: compA.id } })).length, 1, 'A registered its failure evidence use'); checks.push('s3_failure_fact_needs_attribution_with_competitors_and_failure_evidence_backs_one_batch'); // N1: a failed slip of another batch's instruction cannot release this batch // through the single path, even when that batch only registered a submission. const p1A = await s3Batch('64.00'); const p1B = await s3Batch('64.00'); await exportOrder(p1A.id); await register(p1B.id, 'S3-P1-B-REF', null); const p1Fail = await s3Slip(p1A.id, '6400', 'S3-P1-B-REF', 'failed'); await attestSubmission(p1A.id, { ref: 'S3-P1-A-REF' }); await call(manager, 'POST', `/payment-batches/${p1A.id}/bank-failure-review`, { attachmentId: p1Fail.attachment.id }, 409, 'loan.terminal_fact_invalid'); assert.ok((await detail(p1A.id)).loanRepayment.receiptAttribution.competitors.some((item) => item.id === p1B.id), 'submission-only batch is a competitor'); checks.push('s3_single_path_failure_slip_bound_to_own_instruction_and_submission_counts_as_competitor'); // Batch-scope and submission-scope failure attributions can coexist. const both = await s3Batch('65.00'); const bothRival = await s3Batch('65.00'); await exportOrder(both.id); await exportOrder(bothRival.id); const bothFail = await s3Slip(both.id, '6500', 'S3-BOTH-REF', 'failed'); const { submission: bothSub } = await attestSubmission(both.id, { ref: 'S3-BOTH-REF' }); const bothBatchScope = await attribute(finance, both.id, { kind: 'failure', attachmentId: bothFail.attachment.id, basisKind: 'bank_instruction_ref', basisNote: '合成失败回单流水号即本批次指令号', exclusionNote: '合成同额批次指令号不同' }); await reviewAttribution(independent, both.id, bothBatchScope.id, 'approve'); const bothSubmissionScope = await attribute(finance, both.id, { kind: 'failure', attachmentId: bothFail.attachment.id, submissionId: bothSub.id, basisKind: 'bank_instruction_ref', basisNote: '合成失败回单流水号即本批次指令号', exclusionNote: '合成同额批次指令号不同' }); await reviewAttribution(independent, both.id, bothSubmissionScope.id, 'approve'); checks.push('s3_batch_and_submission_scope_failure_attributions_coexist'); // A withdrawn instruction reference cannot be re-registered on another batch, // and the same bank letter cannot be uploaded to two batches. const wdA = await s3Batch('66.00'); const wdB = await s3Batch('67.00'); const wdSub = await register(wdA.id, 'S3-WITHDRAWN-REF', null); await call(finance, 'POST', `/payment-batches/${wdA.id}/loan-bank-submissions/${wdSub.id}/withdraw`, { reason: '合成撤回指令' }); await register(wdB.id, 'S3-WITHDRAWN-REF', null, finance, 409, 'loan.instruction_ref_conflict'); const sharedLetter = await bankLetterPdf(); await uploadEvidence(wdA.id, sharedLetter); const dupBoundary = `synthetic-${randomUUID()}`; const dupEvidence = await app.inject({ method: 'POST', url: `/api/payment-batches/${wdB.id}/loan-terminal-evidences`, headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${dupBoundary}` }, payload: Buffer.concat([Buffer.from(`--${dupBoundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), sharedLetter, Buffer.from(`\r\n--${dupBoundary}--\r\n`)]) }); assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().details?.reasonCode, 'loan.terminal_evidence_invalid'); checks.push('s3_withdrawn_ref_and_bank_letter_cannot_move_to_another_batch'); // Evidence separation and history. const sepEvidence = await prisma.loanBankTerminalEvidence.findFirstOrThrow({ where: { payment_batch_id: multi.id } }); await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: sepEvidence.id }, data: { original_name: '改名.pdf' } })); await assert.rejects(prisma.loanBankTerminalFact.update({ where: { id: fact1.id }, data: { terminal_status: 'failed' } })); await assert.rejects(prisma.loanBankTerminalFact.delete({ where: { id: fact1.id } })); checks.push('s3_evidence_and_fact_history_immutable'); checks.push(...await require('./company-loan-recovery-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor, config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt })); checks.push(...await require('./company-loan-not-submitted-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor, config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt })); checks.push(...await require('./company-loan-freeze-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor, config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt })); checks.push(...await require('./company-loan-bank-results-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor, config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt })); const relatedParty = await runRelated(); checks.push(...relatedParty.checks); delete config.loanReleaseWaitDays; // History guards in the database. const anyExport = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: twice.id } }); await assert.rejects(prisma.paymentOrderExport.update({ where: { id: anyExport.id }, data: { sequence: 9 } })); await assert.rejects(prisma.paymentOrderExport.delete({ where: { id: anyExport.id } })); await assert.rejects(prisma.loanBankSubmission.update({ where: { id: firstSubmission.id }, data: { note: '合成改写提交' } })); await assert.rejects(prisma.loanLegacyExportSummary.update({ where: { payment_batch_id: legacyBatch.id }, data: { known_export_count: 1 } })); await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: misAttest.id }, data: { status: 'rejected' } })); await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: reAttest.id }, data: { all_submission_ids: [] } })); await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: reAttest.id }, data: { export_ids: ['00000000-0000-0000-0000-000000000000'] } })); await assert.rejects(prisma.loanBankSubmission.update({ where: { id: extra.id }, data: { status: 'active', withdrawn_at: null, withdrawn_reason: null } })); checks.push('reconciliation_history_append_only_in_database'); // C2: old batch confirmed failed and cancelled; its late success receipt // lands on a new same-amount batch, via OCR and via manual receipt review. const c2old = await repay(probeLender.id, probeLoan.id, '6.00'); await call(manager, 'POST', `/payment-batches/${c2old.id}/review`, {}); const c2fail = await receipt(c2old.id, '600', 'SYNTHETIC-C2-OLD-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); await attestSubmission(c2old.id); await call(manager, 'POST', `/payment-batches/${c2old.id}/bank-failure-review`, { attachmentId: c2fail.attachment.id }); await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' }); const c2new = await repay(probeLender.id, probeLoan.id, '6.00'); await call(manager, 'POST', `/payment-batches/${c2new.id}/review`, {}); await exportOrder(c2new.id); const beforeC2 = await probeBalance(); const c2 = await receipt(c2new.id, '600', 'SYNTHETIC-C2-OLD-LATE-SUCCESS', { payeeAccount: probeLender.bankAccount }); assert.equal(c2.result.status, 'matched'); await call(finance, 'POST', `/payment-batches/${c2new.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); assert.equal(await probeBalance(), beforeC2, 'C2 late receipt must not settle the new batch'); const c2manualBatch = await repay(probeLender.id, probeLoan.id, '6.00'); await call(manager, 'POST', `/payment-batches/${c2manualBatch.id}/review`, {}); await exportOrder(c2manualBatch.id); const beforeManual = await probeBalance(); const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }, { document: { status: 'success', reference: 'SYNTHETIC-C2-MANUAL', payeeAccount: probeLender.bankAccount } }); const c2attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: c2unclear.attachment.id } }); const c2path = `/payment-batches/${c2manualBatch.id}/receipt-attachments/${c2unclear.attachment.id}/manual-verification`; const c2proposal = await call(dual, 'POST', c2path, { expectedVersion: c2attachment.receipt_ocr_version, reason: '合成人工核对', pages: [{ pageNumber: 1, itemId: c2manualBatch.items[0].id, fields: Object.entries({ 小写金额: '6.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: probeLender.bankAccount, 凭证号: 'SYNTHETIC-C2-MANUAL', 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value })) }] }); const c2saved = c2proposal.item ?? c2proposal; await call(manager, 'POST', `${c2path}/${c2saved.id}/review`, { expectedVersion: c2saved.version, decision: 'approve', reason: '合成人工复核' }); await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {}, 409, 'loan.receipt_attribution_required'); assert.equal(await probeBalance(), beforeManual, 'manual receipt review must not bypass attribution'); // The person who keyed the manual receipt cannot also approve its attribution. const c2attribution = await attribute(finance, c2manualBatch.id, {}); await reviewAttribution(dual, c2manualBatch.id, c2attribution.id, 'approve', 403); await reviewAttribution(independent, c2manualBatch.id, c2attribution.id, 'approve'); assert.equal((await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {})).item.status, 'executed'); checks.push('late_receipt_of_cancelled_batch_blocked_via_ocr_and_manual_review'); // A failed receipt cannot release a reservation while competitors exist. const c2failNew = await repay(probeLender.id, probeLoan.id, '6.00'); await call(manager, 'POST', `/payment-batches/${c2failNew.id}/review`, {}); const c2failReceipt = await receipt(c2failNew.id, '600', 'SYNTHETIC-C2-AMBIGUOUS-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] }); const c2failAttachment = c2failReceipt.attachment.id; await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }, 409, 'loan.submission_list_unverified'); const failureAttribution = { kind: 'failure', attachmentId: c2failAttachment, basisKind: 'bank_instruction_ref', basisNote: '合成失败回单载明本批次网银指令号', exclusionNote: '合成其他同额批次的指令号与本回单不同' }; await attribute(finance, c2failNew.id, failureAttribution, 409, 'loan.submission_list_unverified'); await attestSubmission(c2failNew.id); await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }, 409, 'loan.receipt_attribution_required'); await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '歧义失败不得释放' }, 409, 'loan.bank_state_unsafe'); checks.push('ambiguous_failed_receipt_cannot_release_reservation'); // S2: a reviewed failure attribution covering the attested submission unlocks it. const failureProposal = await attribute(finance, c2failNew.id, failureAttribution); await attribute(finance, c2failNew.id, failureAttribution, 409, 'loan.receipt_attribution_exists'); await reviewAttribution(finance, c2failNew.id, failureProposal.id, 'approve', 403); await reviewAttribution(independent, c2failNew.id, failureProposal.id, 'approve'); const settlementSlot = (await detail(c2failNew.id)).loanRepayment.receiptAttribution.current; assert.equal(settlementSlot, null, 'failure attribution does not occupy the settlement slot'); const beforeFailureRelease = (await call(finance, 'GET', `/company-loans/${probeLoan.id}`)).available; await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }); await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '合成失败归属确认后释放' }); assert.equal(Number((await call(finance, 'GET', `/company-loans/${probeLoan.id}`)).available), Number(beforeFailureRelease) + 6); checks.push('failure_attribution_reviewed_unlocks_ambiguous_failure_release'); // No competitor: attribution is neither required nor accepted. const soloLender = await verifiedLender('合成出借人己', null, '1'); const soloLoan = await confirmedLoan(soloLender.id, '50.00', '合成己借入'); const solo = await repay(soloLender.id, soloLoan.id, '4.00'); await call(manager, 'POST', `/payment-batches/${solo.id}/review`, {}); await exportOrder(solo.id); await receipt(solo.id, '400', 'SYNTHETIC-SOLO', { payeeAccount: soloLender.bankAccount }); await attribute(finance, solo.id, {}, 409, 'loan.receipt_attribution_not_required'); assert.equal((await call(finance, 'POST', `/payment-batches/${solo.id}/execute`, {})).item.status, 'executed'); checks.push('unique_amount_settles_without_attribution'); // Database guards: reviewed history immutable, reviewer differs from proposer. await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { review_note: '改写' } })); await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { status: 'rejected' } })); await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: reproposal.id }, data: { status: 'approved' } })); await assert.rejects(prisma.loanReceiptAttribution.delete({ where: { id: proposed.id } })); checks.push('attribution_history_immutable_in_database'); if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') { // Attribution UI scenario: a matched receipt on one of two same-amount exported batches. const uiLender = await verifiedLender('合成浏览器归属出借人', null, '0'); const uiLoan = await confirmedLoan(uiLender.id, '100.00', '合成浏览器归属借入'); const uiExported = async () => { const item = await repay(uiLender.id, uiLoan.id, '5.00'); await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); return item; }; const uiTarget = await uiExported(); await uiExported(); assert.equal((await receipt(uiTarget.id, '500', 'SYNTHETIC-UI-ATTRIBUTION', { payeeAccount: uiLender.bankAccount })).result.status, 'matched'); const attributionUi = { batchId: uiTarget.id, addCompetitor: uiExported, execute: () => app.inject({ method: 'POST', url: `/api/payment-batches/${uiTarget.id}/execute`, headers: finance.headers, payload: {} }) }; // HTTP cases above verify the real three-day gate. UI fixtures use the // supported synthetic wait override so this run does not wait real days. config.loanReleaseWaitDays = 0; const reconciliationUi = { batches: [], independent, bankLetterPdf }; const uiManualLender = await verifiedLender('合成特别复核界面出借人', null, '6'); const uiManualLoan = await confirmedLoan(uiManualLender.id, '100.00', '合成特别复核界面借款'); for (const amount of ['1.00', '2.00']) { const batch = await repay(uiManualLender.id, uiManualLoan.id, amount); await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id); await attest(batch.id); reconciliationUi.batches.push(batch.id); } reconciliationUi.terminalBatches = []; reconciliationUi.recoveryBatches = []; for (const recovery of [false, true]) for (const amount of (recovery ? ['5.00', '6.00'] : ['3.00', '4.00'])) { const batch = await repay(uiManualLender.id, uiManualLoan.id, amount); await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id); const exported = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: batch.id } }); const ref = `UI-TERMINAL-${amount}`; const sub = await register(batch.id, ref, exported.id); await attest(batch.id); if (recovery) { const evidenceId = await uploadEvidence(batch.id); const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId }); await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id); await call(finance, 'POST', `/payment-batches/${batch.id}/loan-terminal-evidences/${evidenceId}/void`, { reason: '合成旧证据撤回,浏览器重新补证' }); } else { for (let i = 0; i < 2; i++) { const uploaded = await receipt(batch.id, String(Number(amount) * 100), ref, { payeeAccount: uiManualLender.bankAccount, transactionStatus: 'pending' }); if (i === 0) await call(finance, 'POST', `/payment-batches/${batch.id}/receipt-attachments/${uploaded.attachment.id}/void`, { reason: '合成作废的处理中事实仍供复核' }); } await uploadEvidence(batch.id); } (recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).push({ id: batch.id, instruction: ref }); } reconciliationUi.failures = []; reconciliationUi.late = []; for (const mode of ['desktop', 'mobile']) { const failLender = await verifiedLender(`合成失败界面-${mode}`, null, '7'); const failLoan = await confirmedLoan(failLender.id, '100.00', `合成失败归属-${mode}`); const target = await repay(failLender.id, failLoan.id, '7.00'); const competitor = await repay(failLender.id, failLoan.id, '7.00'); for (const b of [target, competitor]) { await call(manager, 'POST', `/payment-batches/${b.id}/review`, {}); await exportOrder(b.id); } const reference = `UI-FAIL-${mode}`.toUpperCase(); await receipt(target.id, '700', reference, { payeeAccount: failLender.bankAccount, transactionStatus: 'failed' }); reconciliationUi.failures.push({ id: target.id, repaymentId: target.loanRepaymentId, reference }); const lateLender = await verifiedLender(`合成追回界面-${mode}`, null, '8'); const lateLoan = await confirmedLoan(lateLender.id, '100.00', `合成晚到追回-${mode}`); const original = await repay(lateLender.id, lateLoan.id, '80.00'); await call(manager, 'POST', `/payment-batches/${original.id}/review`, {}); await exportOrder(original.id); const exp = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: original.id } }); const sub = await register(original.id, `UI-ORIGINAL-${mode}`, exp.id); await attest(original.id); const fact = await proposeFact(original.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: await uploadEvidence(original.id) }); await reviewFact(original.id, fact.id); await confirmNotPaid(original.id); await cancelBatch(original.id); const paid = await repay(lateLender.id, lateLoan.id, '60.00'); await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id); await receipt(paid.id, '6000', `UI-PAID-${mode}`, { payeeAccount: lateLender.bankAccount }); await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {}); const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: original.id } }); reconciliationUi.late.push({ id: original.id, lenderId: lateLender.id, reference: `UI-LATE-${mode}`, payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted), payee: lateLender.bankAccount }); } await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty }); } console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks })); } finally { await app.close(); await prisma.$disconnect(); } };