const assert = require('node:assert/strict'); const { randomUUID } = require('node:crypto'); const { setTimeout: delay } = require('node:timers/promises'); const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs'); const { NOTIFICATION_TYPES, IM_SUPPORTED_NOTIFICATION_TYPES } = require('../../server/src/constants/notificationTypes'); module.exports = async function lenderAckFixture(h) { const { prisma, pool, app, call, finance, manager, ordinary, actor, verifiedLender, repay } = h; const checks = []; const root = '/loan-lender-acknowledgements'; async function upload(loan, kind = 'incoming') { const boundary = `synthetic-${randomUUID()}`; const buffer = await syntheticBankReceiptPdf({ reference: `ACK-${randomUUID()}`, amount: '100.00', direction: 'credit' }); const res = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${kind}`, headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), buffer, Buffer.from(`\r\n--${boundary}--\r\n`)]) }); assert.equal(res.statusCode, 200, `upload ${kind}`); return { evidence: res.json().data, buffer }; } async function loan(lender, extra = {}, withOriginal = true) { const value = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id, originReference: `SYN-ACK-${randomUUID()}`, originalPrincipal: '100.00', openingOutstandingPrincipal: '100.00', receivedDate: '2025-03-01', balanceAsOf: '2026-01-01', historicalEntry: true, ...extra }); if (withOriginal) value.original = (await upload(value)).evidence; return value; } async function setup(role = 'employee', count = 1) { const signer = await actor([role]); const lender = await verifiedLender(`合成声明出借人-${randomUUID().slice(0, 6)}`, signer.id, '4'); const loans = []; for (let i = 0; i < count; i++) loans.push(await loan(lender)); return { signer, lender, loans }; } const body = (s, selected = s.loans) => ({ lenderId: s.lender.id, expectedIdentityVersion: s.lender.identityVersion, loans: selected.map((l) => ({ id: l.id, version: l.version })), requestKey: randomUUID(), reason: '合成请本人核对借款事实和未还本金' }); const propose = (s) => call(finance, 'POST', root, body(s)); const decisionBody = (record, action = 'confirm') => ({ action, contentHash: record.contentHash, reason: action === 'confirm' ? '合成本人已核对并确认' : '合成金额有误退回核对', originalChecked: action === 'confirm' }); const decide = (s, record, action = 'confirm', status = 200, reason) => call(s.signer, 'POST', `${root}/${record.id}/decision`, decisionBody(record, action), status, reason); const confirmBalance = (l, status = 200) => call(finance, 'POST', `/company-loans/${l.id}/confirm`, { expectedVersion: l.version, accountingConfirmationRef: '合成代账明细账科目及期间引用', }, status, status === 409 ? 'loan.evidence_required' : undefined); const s = await setup('manager', 2); const input = body(s); const a = await call(finance, 'POST', root, input); assert.equal(a.status, 'pending'); assert.equal(a.loans.length, 2); assert.equal(a.canDecide, false); assert.equal((await call(finance, 'POST', root, input)).id, a.id); await call(finance, 'POST', root, { ...input, reason: '合成换参数重用请求键' }, 409, 'loan.duplicate_registration'); await call(finance, 'POST', root, body(s), 409, 'loan.lender_ack_exists'); const noticeType = NOTIFICATION_TYPES.loanLenderAcknowledgementRequested; const notices = await prisma.notification.findMany({ where: { target_type: 'loan_lender_acknowledgement', target_id: a.id } }); assert.equal(notices.length, 1); assert.equal(notices[0].recipient_employee_id, s.signer.id); assert.equal(notices[0].type, noticeType); assert.ok(!IM_SUPPORTED_NOTIFICATION_TYPES.includes(noticeType), 'lender statement notice stays in OA'); assert.equal(await prisma.notificationDelivery.count({ where: { notification_id: notices[0].id } }), 0); const inbox = await call(s.signer, 'GET', `/notifications?type=${noticeType}`); assert.equal(inbox.items.filter(item => item.targetId === a.id).length, 1); for (const who of [ordinary, finance, manager]) { assert.equal((await call(who, 'GET', `/notifications?type=${noticeType}`)).items.filter(item => item.targetId === a.id).length, 0); } const noticeCopy = notices[0].title + notices[0].body; for (const privateValue of [s.lender.bankAccount, s.lender.name, '100.00']) assert.ok(!noticeCopy.includes(privateValue)); checks.push('proposal_notice_only_for_actual_lender_no_duplicate_on_retry_or_external_delivery'); assert.ok(!JSON.stringify(a).includes(s.lender.bankAccount)); for (const item of a.loans) { assert.equal(item.originalPrincipal, '100.00'); assert.match(item.originals[0].sha256, /^[a-f0-9]{64}$/); } checks.push('same_lender_multi_loan_snapshot_request_binding_and_duplicate_refusal'); await call(null, 'GET', root, undefined, 401); await call(ordinary, 'POST', root, input, 403); assert.equal((await call(ordinary, 'GET', root)).items.length, 0); for (const who of [ordinary, manager]) { await call(who, 'GET', `${root}/${a.id}`, undefined, 404); await call(who, 'POST', `${root}/${a.id}/decision`, decisionBody(a), 404); } await call(finance, 'POST', `${root}/${a.id}/decision`, decisionBody(a), 403); await call(s.signer, 'GET', '/company-loans', undefined, 403); await call(s.signer, 'POST', `${root}/${a.id}/decision`, { ...decisionBody(a), originalChecked: false }, 400); await call(s.signer, 'POST', `${root}/${a.id}/decision`, { ...decisionBody(a), contentHash: '0'.repeat(64) }, 409, 'loan.lender_ack_changed'); const download = (who, l, evidence, extra) => app.inject({ method: 'POST', url: `/api/company-loans/${l.id}/evidences/${evidence.id}/download`, headers: who.headers, payload: { reason: '合成核对本人原借入凭据', lenderAcknowledgementId: a.id, ...extra } }); assert.equal((await download(s.signer, s.loans[0], s.loans[0].original)).statusCode, 200); assert.equal((await download(ordinary, s.loans[0], s.loans[0].original)).statusCode, 404); const accounting = await upload(s.loans[0], 'accounting_confirmation'); assert.equal((await download(s.signer, s.loans[0], accounting.evidence)).statusCode, 404); assert.equal((await download(s.signer, s.loans[0], s.loans[0].original, { paymentBatchId: randomUUID() })).statusCode, 400); checks.push('only_actual_lender_can_read_originals_and_decide_no_finance_or_representative_substitution'); await confirmBalance(s.loans[0], 409); // Accounting alone cannot replace agreement/ack. await decide(s, a); await decide(s, a); // Same decision retry is idempotent. await decide(s, a, 'return', 409, 'loan.lender_ack_decided'); await confirmBalance(s.loans[1], 409); // Valid ack cannot replace accounting. await upload(s.loans[1], 'accounting_confirmation'); for (const l of s.loans) { const value = await confirmBalance(l); assert.equal(value.confirmationStatus, 'confirmed'); assert.equal(value.lenderAcknowledgement.id, a.id); } assert.equal((await call(s.signer, 'GET', `${root}/${a.id}`)).status, 'confirmed'); const authority = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: s.lender.id, loanIds: s.loans.map((l) => l.id), basis: '合成已有公司代表权及指定借款范围依据', requestKey: randomUUID() }); await call(s.signer, 'POST', `/loan-related-authorizations/${authority.id}/actions`, { action: 'confirm', reason: '合成以公司代表身份单独确认授权' }); const batch = await repay(s.lender.id, s.loans[0].id, '10.00'); assert.equal(batch.loanRepayment.approvalMode, 'related_party'); await call(s.signer, 'POST', `/payment-batches/${batch.id}/review`, {}); await h.exportOrder(batch.id); checks.push('online_ack_replaces_only_agreement_and_reaches_separate_authority_repayment_review_and_export'); const ordinaryCase = await setup(); const ordinaryAck = await propose(ordinaryCase); assert.equal((await call(ordinaryCase.signer, 'GET', `${root}/${ordinaryAck.id}`)).canDecide, true); await decide(ordinaryCase, ordinaryAck, 'return'); assert.equal((await call(finance, 'GET', `${root}/${ordinaryAck.id}`)).status, 'returned'); const resent = await propose(ordinaryCase); await decide(ordinaryCase, resent); assert.notEqual(resent.id, ordinaryAck.id); checks.push('ordinary_employee_lender_can_return_and_confirm_new_request_without_manager_permission'); const partial = await loan(ordinaryCase.lender, { openingOutstandingPrincipal: '80.00' }); const partialScope = { ...ordinaryCase, loans: [partial] }; const partialAck = await propose(partialScope); await decide(partialScope, partialAck); await upload(partial, 'accounting_confirmation'); await confirmBalance(partial, 409); await upload(partial, 'adjustment'); await confirmBalance(partial); const paper = await loan(ordinaryCase.lender); await upload(paper, 'agreement'); await upload(paper, 'accounting_confirmation'); await confirmBalance(paper); checks.push('outside_repayment_evidence_still_required_and_paper_agreement_remains_valid'); const missing = await loan(ordinaryCase.lender, {}, false); await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing] }), 409, 'loan.lender_ack_scope_invalid'); await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing, s.loans[0]] }), 409, 'loan.lender_ack_scope_invalid'); await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing, missing] }), 409, 'loan.lender_ack_scope_invalid'); const ownLender = await verifiedLender('合成财务本人出借人', finance.id, '2'); const ownLoan = await loan(ownLender); await call(finance, 'POST', root, body({ lender: ownLender, loans: [ownLoan] }), 409, 'loan.lender_ack_scope_invalid'); checks.push('scope_requires_distinct_maker_verified_lender_same_company_unconfirmed_loans_and_originals'); await assert.rejects(prisma.loanLenderAcknowledgement.update({ where: { id: a.id }, data: { reason: '合成篡改声明' } })); await assert.rejects(prisma.loanLenderAcknowledgementLoan.update({ where: { acknowledgement_id_loan_id: { acknowledgement_id: a.id, loan_id: s.loans[0].id } }, data: { snapshot_json: {} } })); await assert.rejects(prisma.loanLenderAcknowledgementDecision.update({ where: { acknowledgement_id: a.id }, data: { reason: '合成篡改决定' } })); await assert.rejects(prisma.loanLenderAcknowledgementDecision.delete({ where: { acknowledgement_id: a.id } })); await assert.rejects(prisma.loanLenderAcknowledgementLoan.deleteMany({ where: { acknowledgement_id: a.id } })); checks.push('database_rejects_snapshot_decision_and_scope_history_rewrites_or_deletion'); for (const [column, changed] of [['original_principal', '110.00'], ['opening_outstanding_principal', '90.00'], ['received_date', '2025-03-02'], ['balance_as_of', '2026-01-02']]) { const l = await loan(ordinaryCase.lender); const itemScope = { ...ordinaryCase, loans: [l] }; const record = await propose(itemScope); await decide(itemScope, record); const before = (await pool.query(`SELECT ${column}::text AS value FROM company_loans WHERE id=$1`, [l.id])).rows[0].value; await pool.query(`UPDATE company_loans SET ${column}=$1 WHERE id=$2`, [changed, l.id]); await pool.query(`UPDATE company_loans SET ${column}=$1 WHERE id=$2`, [before, l.id]); assert.equal((await call(ordinaryCase.signer, 'GET', `${root}/${record.id}`)).status, 'invalid'); await upload(l, 'accounting_confirmation'); await confirmBalance(l, 409); await assert.rejects(prisma.loanLenderAcknowledgement.update({ where: { id: record.id }, data: { invalidated_at: null, invalid_reason: null } })); } checks.push('each_amount_and_date_change_permanently_invalidates_even_after_restoring_old_values'); const changedOriginal = await setup('employee', 2); const originalAck = await propose(changedOriginal); await decide(changedOriginal, originalAck); const addition = await upload(changedOriginal.loans[0]); await prisma.loanEvidence.update({ where: { id: addition.evidence.id }, data: { archived_at: new Date() } }); const oldRead = await call(changedOriginal.signer, 'GET', `${root}/${originalAck.id}`); assert.equal(oldRead.status, 'invalid'); assert.equal(oldRead.canReadOriginals, false); for (const l of changedOriginal.loans) { await upload(l, 'accounting_confirmation'); await confirmBalance(l, 409); } const identityCase = await setup(); const identityAck = await propose(identityCase); await decide(identityCase, identityAck); await prisma.loanLender.update({ where: { id: identityCase.lender.id }, data: { identity_version: { increment: 1 } } }); await prisma.loanLender.update({ where: { id: identityCase.lender.id }, data: { identity_version: identityCase.lender.identityVersion } }); assert.equal((await call(identityCase.signer, 'GET', `${root}/${identityAck.id}`)).status, 'invalid'); checks.push('original_or_identity_changes_invalidate_whole_group_and_cannot_reactivate'); const rollbackCase = await setup(); const rollbackBody = body(rollbackCase); async function auditFailure(action, run) { await pool.query(`CREATE FUNCTION synthetic_fail_lender_ack_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='${action}' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$`); await pool.query('CREATE TRIGGER synthetic_fail_lender_ack_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_lender_ack_audit()'); try { await run(); } finally { await pool.query('DROP TRIGGER synthetic_fail_lender_ack_audit ON audit_logs'); await pool.query('DROP FUNCTION synthetic_fail_lender_ack_audit()'); } } await auditFailure('LOAN_LENDER_ACK_PROPOSED', () => call(finance, 'POST', root, rollbackBody, 500)); assert.equal(await prisma.loanLenderAcknowledgement.count({ where: { request_key: rollbackBody.requestKey } }), 0); assert.equal(await prisma.notification.count({ where: { recipient_employee_id: rollbackCase.signer.id, type: noticeType } }), 0); const rollbackAck = await call(finance, 'POST', root, rollbackBody); await auditFailure('LOAN_LENDER_ACK_CONFIRMED', () => decide(rollbackCase, rollbackAck, 'confirm', 500)); assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: rollbackAck.id } }), 0); checks.push('proposal_and_decision_audit_failures_roll_back_completely'); const notificationFailure = await setup(); const notificationBody = body(notificationFailure); const auditsBefore = await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_ACK_PROPOSED' } }); await pool.query(`CREATE FUNCTION synthetic_fail_ack_notice() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.type='${noticeType}' THEN RAISE EXCEPTION 'synthetic notification failure'; END IF; RETURN NEW; END $$`); await pool.query('CREATE TRIGGER synthetic_fail_ack_notice BEFORE INSERT ON notifications FOR EACH ROW EXECUTE FUNCTION synthetic_fail_ack_notice()'); try { await call(finance, 'POST', root, notificationBody, 500); } finally { await pool.query('DROP TRIGGER synthetic_fail_ack_notice ON notifications'); await pool.query('DROP FUNCTION synthetic_fail_ack_notice()'); } assert.equal(await prisma.loanLenderAcknowledgement.count({ where: { request_key: notificationBody.requestKey } }), 0); assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_ACK_PROPOSED' } }), auditsBefore); assert.equal(await prisma.notification.count({ where: { recipient_employee_id: notificationFailure.signer.id, type: noticeType } }), 0); const afterNotificationFailure = await call(finance, 'POST', root, notificationBody); assert.equal(await prisma.notification.count({ where: { target_id: afterNotificationFailure.id, type: noticeType } }), 1); checks.push('notification_write_failure_rolls_back_statement_and_success_audit_then_same_request_can_retry'); const racing = await setup(); const raceInput = body(racing); const pair = await Promise.all([call(finance, 'POST', root, raceInput), call(finance, 'POST', root, raceInput)]); assert.equal(pair[0].id, pair[1].id); assert.equal(await prisma.notification.count({ where: { target_id: pair[0].id, type: noticeType } }), 1); const responses = await Promise.all(['confirm', 'return'].map((action) => app.inject({ method: 'POST', url: `/api${root}/${pair[0].id}/decision`, headers: racing.signer.headers, payload: decisionBody(pair[0], action) }))); assert.deepEqual(responses.map((r) => r.statusCode).sort(), [200, 409]); assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: pair[0].id } }), 1); checks.push('concurrent_duplicate_proposal_and_conflicting_decisions_have_one_result'); const sourceRace = await setup(); const sourceAck = await propose(sourceRace); const locker = await pool.connect(); try { await locker.query('BEGIN'); await locker.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [sourceRace.lender.id]); const pending = decide(sourceRace, sourceAck, 'confirm', 409, 'loan.lender_ack_changed'); let blocked = false; for (let i = 0; i < 80; i++) { const state = await pool.query("SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%loan_lenders%' LIMIT 1"); if (state.rowCount) { blocked = true; break; } await delay(20); } assert.ok(blocked, 'confirmation must wait for the source lock'); await locker.query('UPDATE company_loans SET opening_outstanding_principal=90 WHERE id=$1', [sourceRace.loans[0].id]); await locker.query('COMMIT'); await pending; assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: sourceAck.id } }), 0); } finally { await locker.query('ROLLBACK'); locker.release(); } checks.push('observed_source_lock_interleaving_rejects_stale_confirmation'); for (const change of ['identity', 'sibling_original']) { const balanceRace = await setup('employee', 2); const balanceAck = await propose(balanceRace); await decide(balanceRace, balanceAck); await upload(balanceRace.loans[0], 'accounting_confirmation'); const sourceWriter = await pool.connect(); let pending; try { await sourceWriter.query('BEGIN'); await sourceWriter.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [balanceRace.lender.id]); pending = app.inject({ method: 'POST', url: `/api/company-loans/${balanceRace.loans[0].id}/confirm`, headers: finance.headers, payload: { expectedVersion: balanceRace.loans[0].version, accountingConfirmationRef: '合成并发代账引用' } }); // Observe an actual waiter rather than relying on a scheduled delay. let blocked = false; for (let i = 0; i < 80; i++) { const state = await pool.query("SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%loan_lenders%' LIMIT 1"); if (state.rowCount) { blocked = true; break; } await delay(20); } assert.ok(blocked, `balance confirmation must wait for ${change} writer`); if (change === 'identity') await sourceWriter.query('UPDATE loan_lenders SET identity_version=identity_version+1 WHERE id=$1', [balanceRace.lender.id]); else await sourceWriter.query('UPDATE loan_evidences SET original_name=$1 WHERE id=$2', ['synthetic-changed.pdf', balanceRace.loans[1].original.id]); await sourceWriter.query('COMMIT'); const response = await pending; assert.equal(response.statusCode, 409); assert.equal(response.json().error.details.reasonCode, 'loan.evidence_required'); assert.equal((await prisma.companyLoan.findUniqueOrThrow({ where: { id: balanceRace.loans[0].id } })).confirmation_status, 'unconfirmed'); } finally { await sourceWriter.query('ROLLBACK'); sourceWriter.release(); if (pending) await pending; } } checks.push('balance_consumption_serializes_identity_and_group_original_changes_and_rechecks_after_wait'); const paperCurrent = await call(finance, 'GET', `/company-loans?lenderId=${ordinaryCase.lender.id}`); const paperLoan = paperCurrent.items.find(l => l.id === paper.id); assert.equal(paperLoan.lenderAcknowledgementRenewable, false); await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [paperLoan] }), 409, 'loan.lender_ack_scope_invalid'); for (const change of ['original', 'employee_refresh']) { const renewal = await setup('manager', 2); const previous = await propose(renewal); await decide(renewal, previous); for (let i = 0; i < renewal.loans.length; i++) { await upload(renewal.loans[i], 'accounting_confirmation'); renewal.loans[i] = await confirmBalance(renewal.loans[i]); } async function grant() { const authorization = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: renewal.lender.id, loanIds: renewal.loans.map(l => l.id), basis: '合成声明恢复后仍需独立办理公司代表授权', requestKey: randomUUID() }); await call(renewal.signer, 'POST', `/loan-related-authorizations/${authorization.id}/actions`, { action: 'confirm', reason: '合成公司代表身份确认' }); } await grant(); const before = await prisma.companyLoan.findUniqueOrThrow({ where: { id: renewal.loans[0].id } }); if (change === 'original') await upload(renewal.loans[1]); else { await prisma.employee.update({ where: { id: renewal.signer.id }, data: { name: renewal.lender.name, bank_institution_id: h.bank.id, bank_name: '合成建行员工收款资料' } }); const source = await call(finance, 'GET', `/loan-lenders/employee-source/${renewal.signer.id}?legalEntityId=${renewal.lender.legalEntityId}`); renewal.lender = await call(finance, 'POST', `/loan-lenders/${renewal.lender.id}/employee-refresh`, { expectedSourceVersion: source.sourceVersion, expectedIdentityVersion: renewal.lender.identityVersion, expectedAccountVersion: renewal.lender.accountVersion, detailsConfirmed: true, reason: '合成刷新员工收款资料' }); } assert.equal((await call(renewal.signer, 'GET', `${root}/${previous.id}`)).status, 'invalid'); const payBody = { requestKey: randomUUID(), lenderId: renewal.lender.id, reason: '合成验证续办还款', allocations: [{ loanId: renewal.loans[0].id, amount: '10.00' }] }; await call(finance, 'POST', '/loan-repayments', payBody, 409, 'loan.evidence_required'); const projected = await call(finance, 'GET', `/company-loans?lenderId=${renewal.lender.id}`); assert.ok(projected.items.every(l => l.lenderAcknowledgementRenewable)); // Renew one member of the invalid group; returning it does not trap renewal. const selected = { ...renewal, loans: [renewal.loans[0]] }; const back = await propose(selected); await decide(selected, back, 'return'); const freshAck = await propose(selected); await decide(selected, freshAck); assert.equal((await call(renewal.signer, 'GET', `${root}/${previous.id}`)).status, 'invalid'); assert.deepEqual(await prisma.companyLoan.findUniqueOrThrow({ where: { id: renewal.loans[0].id } }), before, 'renewal never edits the loan or its original balance confirmation'); if (change === 'employee_refresh') { const refusedPayment = await app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: payBody }); assert.equal(refusedPayment.statusCode, 409, 'lender statement does not renew company authority'); await grant(); } await call(finance, 'POST', '/loan-repayments', payBody); } checks.push('confirmed_balance_can_renew_invalid_statement_after_original_or_employee_refresh_without_rewriting_loan'); checks.push('renewal_supports_return_and_partial_group_but_does_not_replace_company_authority_or_open_paper_only_scope'); const browserScenarios = []; if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) browserScenarios.push({ mode, ...await setup('employee', 4) }); console.log(JSON.stringify({ stage: 'loan_lender_ack_http', status: 'PASS', checks })); return { browserScenarios, upload, loan, body, decide, propose, setup, checks }; };