const assert = require('node:assert/strict'); const { randomUUID, createHash } = require('node:crypto'); const { existsSync, readdirSync } = require('node:fs'); const path = require('node:path'); const { syntheticBankReceiptPdf, syntheticCcbIncomingPdf } = require('./synthetic-bank-receipt.cjs'); // N5-P: original borrow-in receipt prefill. Synthetic PDFs only; the provider is // unconfigured here, so scanned/unreadable files take the manual path. module.exports = async function incomingReceiptFixture(h) { const { prisma, pool, call, app, req, finance, manager, ordinary, config, actor, company, verifiedLender } = h; const incoming = req('./src/services/loanIncomingReceipt'); const checks = []; const sha = (buffer) => createHash('sha256').update(buffer).digest('hex'); const storedOriginals = () => { const dir = path.join(config.storageDir, 'company-loan-evidence', 'incoming'); return existsSync(dir) ? readdirSync(dir).length : 0; }; function multipart(who, url, file, payload, { name = 'receipt.pdf', type = 'application/pdf' } = {}) { const boundary = `synthetic-${randomUUID()}`; const parts = []; if (payload !== undefined) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="payload"\r\n\r\n${JSON.stringify(payload)}\r\n`)); parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`), file, Buffer.from(`\r\n--${boundary}--\r\n`)); return app.inject({ method: 'POST', url: `/api${url}`, headers: { ...(who?.headers ?? {}), 'content-type': `multipart/form-data; boundary=${boundary}` }, payload: Buffer.concat(parts) }); } async function expectStatus(promise, status, reason) { const response = await promise; assert.equal(response.statusCode, status, `${response.statusCode} ${response.json().error?.details?.reasonCode ?? ''}`); if (reason) assert.equal(response.json().error?.details?.reasonCode, reason); return response.json().data; } const recognize = (file, who = finance, status = 200, reason) => expectStatus(multipart(who, '/company-loans/incoming-receipt/recognize', file), status, reason); const save = (file, payload, who = finance, status = 200, reason) => expectStatus(multipart(who, '/company-loans/from-incoming-receipt', file, payload), status, reason); const receiptPdf = (lenderName, options = {}) => syntheticBankReceiptPdf({ direction: 'credit', status: 'booked', amount: '50,000.00', reference: `SYN-IN-${randomUUID().slice(0, 8)}`, bookedOn: '2025-03-14', extraLines: [`付款人名称 ${lenderName}`, `收款人名称 ${company.name}`], ...options }); const lender = await verifiedLender('合成借入回单出借人', null, '6'); const file = await receiptPdf(lender.name); const before = { loans: await prisma.companyLoan.count(), evidences: await prisma.loanEvidence.count(), stored: storedOriginals() }; for (const [who, status] of [[null, 401], [manager, 403], [ordinary, 403]]) await recognize(file, who, status); await recognize(Buffer.from('%PNG not a receipt'), finance, 400, 'loan.incoming_receipt_invalid'); await expectStatus(multipart(finance, '/company-loans/incoming-receipt/recognize', Buffer.alloc(5 * 1024 * 1024 + 10, 37)), 400); const prefill = await recognize(file); assert.deepEqual(prefill.blocks, []); assert.equal(prefill.ocr, 'not_needed'); assert.equal(prefill.direction, 'credit'); assert.deepEqual({ ...prefill.fields, originReference: undefined }, { originalPrincipal: '50000.00', receivedDate: '2025-03-14', originReference: undefined, referenceKind: 'voucher', currency: 'CNY', payerName: lender.name, payerAccountMasked: prefill.fields.payerAccountMasked, payeeName: company.name, payeeAccountMasked: prefill.fields.payeeAccountMasked }); assert.deepEqual(prefill.suggestions, { legalEntityId: company.id, lenderId: lender.id }); assert.ok(!JSON.stringify(prefill).includes('000011112222'), 'recognition returns masked accounts only'); assert.deepEqual({ loans: await prisma.companyLoan.count(), evidences: await prisma.loanEvidence.count(), stored: storedOriginals() }, before, 'recognition never creates a loan, an original or a stored object'); checks.push('incoming_recognition_prefill_masked_and_non_persistent'); const payload = { context: prefill.context, requestKey: randomUUID(), lenderId: lender.id, expectedLenderIdentityVersion: lender.identityVersion, expectedLenderAccountVersion: lender.accountVersion, originReference: prefill.fields.originReference, originalPrincipal: prefill.fields.originalPrincipal, openingOutstandingPrincipal: '30000.00', receivedDate: '2025-03-15', balanceAsOf: '2026-01-01', historicalEntry: true, originalChecked: true, natureConfirmed: true }; for (const change of [{ originalChecked: false }, { natureConfirmed: undefined }, { confirmationStatus: 'confirmed' }, { balanceAsOf: '2999-01-01' }, { openingOutstandingPrincipal: '60000.00' }]) { await save(file, { ...payload, ...change }, finance, 400); } for (const who of [manager, ordinary]) await save(file, payload, who, 403); const saved = await save(file, payload); assert.equal(saved.created, true); const loan = saved.item; assert.equal(loan.confirmationStatus, 'unconfirmed'); assert.equal(loan.available, '0.00'); assert.equal(loan.receivedDate, '2025-03-15'); const originals = loan.evidences.filter((e) => e.kind === 'incoming'); assert.equal(originals.length, 1); const row = await prisma.loanEvidence.findUniqueOrThrow({ where: { id: originals[0].id } }); assert.equal(row.file_sha256, sha(file)); assert.equal(row.governance_status, 'approved'); const audit = await prisma.auditLog.findFirstOrThrow({ where: { target_id: loan.id, action: 'COMPANY_LOAN_REGISTERED' } }); assert.equal(audit.extra.source, 'incoming_receipt'); assert.deepEqual(audit.extra.correctedFields, ['receivedDate']); assert.ok(!JSON.stringify(audit.extra).includes('000011112222'), 'audit keeps no account or raw text'); assert.equal(await prisma.auditLog.count({ where: { target_id: loan.id, action: 'COMPANY_LOAN_EVIDENCE_UPLOADED' } }), 1); assert.equal(storedOriginals(), before.stored + 1); checks.push('incoming_save_creates_unconfirmed_loan_with_original_and_correction_audit'); const retried = await save(file, payload); assert.equal(retried.created, false); assert.equal(retried.item.id, loan.id); assert.equal(await prisma.loanEvidence.count({ where: { loan_id: loan.id } }), 1); assert.equal(storedOriginals(), before.stored + 1, 'an idempotent retry removes its own new object'); const otherFile = await receiptPdf(lender.name, { amount: '10.00' }); const otherPrefill = await recognize(otherFile); await save(otherFile, { ...payload, context: otherPrefill.context }, finance, 409, 'loan.duplicate_registration'); await save(file, { ...payload, requestKey: randomUUID(), originReference: '合成另一引用' }, finance, 409, 'loan.incoming_receipt_duplicate'); const manualLoan = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成手工登记', originalPrincipal: '10.00', openingOutstandingPrincipal: '10.00', receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true }); await expectStatus(multipart(finance, `/company-loans/${manualLoan.id}/evidences/incoming`, file), 409, 'loan.incoming_receipt_duplicate'); assert.equal(storedOriginals(), before.stored + 1); checks.push('incoming_retry_request_key_and_cross_entry_original_duplicates'); const otherFinance = await actor(['finance']); await save(file, { ...payload, requestKey: randomUUID() }, otherFinance, 409, 'loan.incoming_receipt_context_invalid'); await save(otherFile, { ...payload, requestKey: randomUUID() }, finance, 409, 'loan.incoming_receipt_context_invalid'); await save(file, { ...payload, requestKey: randomUUID(), context: `${prefill.context.slice(0, -6)}AAAAAA` }, finance, 409, 'loan.incoming_receipt_context_invalid'); const expired = incoming.sealContext(config, { v: 1, purpose: incoming.PURPOSE, parserVersion: incoming.PARSER_VERSION, actorId: finance.id, fileSha256: sha(otherFile), issuedAt: Date.now() - 3_600_000, expiresAt: Date.now() - 1000, fields: {}, blocks: [] }); await save(otherFile, { ...payload, requestKey: randomUUID(), originReference: '合成过期', context: expired }, finance, 409, 'loan.incoming_receipt_context_invalid'); // The lender checked in the form must still be the same version at save time. const changedFile = await receiptPdf(lender.name, { amount: '15.00' }); const changedPrefill = await recognize(changedFile); const changedInput = { ...payload, requestKey: randomUUID(), context: changedPrefill.context, originReference: '合成出借人已变更', originalPrincipal: '15.00', openingOutstandingPrincipal: '15.00' }; await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: { increment: 1 } } }); try { await save(changedFile, changedInput, finance, 409, 'loan.version_changed'); await save(changedFile, { ...changedInput, expectedLenderAccountVersion: lender.accountVersion + 1, expectedLenderIdentityVersion: lender.identityVersion + 1 }, finance, 409, 'loan.version_changed'); } finally { await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: lender.accountVersion } }); } assert.equal(await prisma.companyLoan.count({ where: { origin_reference: '合成出借人已变更' } }), 0); checks.push('incoming_context_bound_to_actor_file_integrity_lifetime_and_lender_version'); for (const [options, block] of [[{ final: false }, 'non_final'], [{ status: 'failed' }, 'transaction_failed'], [{ currency: 'USD' }, 'non_cny']]) { const restricted = await receiptPdf(lender.name, options); const result = await recognize(restricted); assert.ok(result.blocks.includes(block), `${block} reported`); await save(restricted, { ...payload, requestKey: randomUUID(), originReference: `合成受限-${block}`, context: result.context }, finance, 409, 'loan.incoming_receipt_blocked'); } // Company expense has no payee line, so only the expense rule can refuse it. for (const extraLines of [[`付款人名称 ${company.name}`], [`付款人名称 ${lender.name}`, '收款人名称 合成另一收款主体']]) { const misdirected = await receiptPdf(lender.name, { direction: extraLines[0].includes(company.name) ? 'debit' : 'credit', extraLines }); const result = await recognize(misdirected); await save(misdirected, { ...payload, requestKey: randomUUID(), originReference: `合成方向-${randomUUID().slice(0, 6)}`, context: result.context }, finance, 409, 'loan.incoming_receipt_blocked'); } assert.equal(storedOriginals(), before.stored + 1, 'refused saves leave no stored object'); checks.push('incoming_non_final_failed_foreign_currency_company_expense_and_other_payee_blocked'); const { PDFDocument } = req('pdf-lib'); const blankDoc = await PDFDocument.create(); blankDoc.addPage(); blankDoc.setTitle(`synthetic-scan-${randomUUID()}`); const scanned = Buffer.from(await blankDoc.save()); const manual = await recognize(scanned); assert.equal(manual.ocr, 'unavailable'); assert.deepEqual(manual.fieldIssues, { amount: 'missing', receivedDate: 'missing', reference: 'missing' }); assert.equal(manual.fields.originalPrincipal, null); const manualSaved = await save(scanned, { ...payload, requestKey: randomUUID(), context: manual.context, originReference: '合成扫描人工补录', originalPrincipal: '20.00', openingOutstandingPrincipal: '20.00', receivedDate: '2025-04-01' }); assert.equal(manualSaved.created, true); assert.deepEqual((await prisma.auditLog.findFirstOrThrow({ where: { target_id: manualSaved.item.id, action: 'COMPANY_LOAN_REGISTERED' } })).extra.correctedFields, []); checks.push('incoming_unreadable_without_provider_completes_by_manual_entry'); // The CCB table layout of the real borrow-in receipts (synthetic replica). const ccbFile = await syntheticCcbIncomingPdf({ payerName: lender.name, payeeName: company.name, serial: `CCB${randomUUID().replace(/-/g, '').slice(0, 16)}`, amount: '12,345.67', capital: '壹万贰仟叁佰肆拾伍元陆角柒分' }); const ccb = await recognize(ccbFile); assert.deepEqual(ccb.blocks, []); assert.equal(ccb.ocr, 'not_needed'); assert.equal(ccb.direction, 'credit'); assert.equal(ccb.fields.originalPrincipal, '12345.67'); assert.equal(ccb.fields.receivedDate, '2025-03-14'); assert.equal(ccb.fields.referenceKind, 'serial'); assert.equal(ccb.fields.payerName, lender.name); assert.equal(ccb.fields.payeeName, company.name); assert.deepEqual(ccb.suggestions, { legalEntityId: company.id, lenderId: lender.id }); const ccbSaved = await save(ccbFile, { ...payload, requestKey: randomUUID(), context: ccb.context, originReference: ccb.fields.originReference, originalPrincipal: ccb.fields.originalPrincipal, openingOutstandingPrincipal: '12345.67', receivedDate: ccb.fields.receivedDate }); assert.equal(ccbSaved.created, true); assert.deepEqual((await prisma.auditLog.findFirstOrThrow({ where: { target_id: ccbSaved.item.id, action: 'COMPANY_LOAN_REGISTERED' } })).extra.correctedFields, []); checks.push('incoming_ccb_table_layout_prefills_suggests_parties_and_saves_without_provider'); // Real rollback: the second audit fails inside the transaction. const rollbackFile = await receiptPdf(lender.name, { amount: '30.00' }); const rollbackPrefill = await recognize(rollbackFile); await pool.query(`CREATE FUNCTION synthetic_fail_incoming_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic incoming audit failure'; END $$`); await pool.query(`CREATE TRIGGER synthetic_fail_incoming_audit BEFORE INSERT ON audit_logs FOR EACH ROW WHEN (NEW.action = 'COMPANY_LOAN_EVIDENCE_UPLOADED' AND NEW.extra->>'kind' = 'incoming') EXECUTE FUNCTION synthetic_fail_incoming_audit()`); const rollbackKey = randomUUID(); try { await save(rollbackFile, { ...payload, requestKey: rollbackKey, context: rollbackPrefill.context, originReference: '合成审计失败回滚', originalPrincipal: '30.00', openingOutstandingPrincipal: '30.00' }, finance, 500); } finally { await pool.query('DROP TRIGGER synthetic_fail_incoming_audit ON audit_logs'); await pool.query('DROP FUNCTION synthetic_fail_incoming_audit()'); } assert.equal(await prisma.companyLoan.count({ where: { request_key: rollbackKey } }), 0); assert.equal(await prisma.loanEvidence.count({ where: { file_sha256: sha(rollbackFile) } }), 0); assert.equal(storedOriginals(), before.stored + 3, 'failed transaction removes the object it wrote'); checks.push('incoming_audit_failure_rolls_back_loan_original_and_object'); // Same original, two new requests at once: the lender lock admits exactly one. const raceFile = await receiptPdf(lender.name, { amount: '40.00' }); const racePrefill = await recognize(raceFile); const race = await Promise.all(['甲', '乙'].map((tag) => multipart(finance, '/company-loans/from-incoming-receipt', raceFile, { ...payload, requestKey: randomUUID(), context: racePrefill.context, originReference: `合成并发-${tag}`, originalPrincipal: '40.00', openingOutstandingPrincipal: '40.00' }))); assert.deepEqual(race.map((r) => r.statusCode).sort(), [200, 409]); assert.equal(race.find((r) => r.statusCode === 409).json().error.details.reasonCode, 'loan.incoming_receipt_duplicate'); // Old upload and new save racing for one original also admit exactly one. const crossFile = await receiptPdf(lender.name, { amount: '41.00' }); const crossPrefill = await recognize(crossFile); const crossLoan = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成旧入口竞争', originalPrincipal: '41.00', openingOutstandingPrincipal: '41.00', receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true }); const cross = await Promise.all([ multipart(finance, `/company-loans/${crossLoan.id}/evidences/incoming`, crossFile), multipart(finance, '/company-loans/from-incoming-receipt', crossFile, { ...payload, requestKey: randomUUID(), context: crossPrefill.context, originReference: '合成新入口竞争', originalPrincipal: '41.00', openingOutstandingPrincipal: '41.00' }), ]); assert.deepEqual(cross.map((r) => r.statusCode).sort(), [200, 409]); assert.equal(await prisma.loanEvidence.count({ where: { file_sha256: sha(crossFile), kind: 'incoming' } }), 1); checks.push('incoming_concurrent_saves_and_old_upload_race_admit_one_original'); console.log(JSON.stringify({ stage: 'loan_incoming_receipt_http', status: 'PASS', checks })); return { lender, file, loan }; };