const { assertEvidenceComplete } = require('../../../src/services/companyLoans'); const { snapshot, validAcknowledgement, acknowledgementRenewable } = require('../../../src/services/loanLenderAcknowledgementSnapshot'); function fixture() { const lender = { id: 'lender', legal_entity_id: 'company', employee_id: 'signer', name: '合成出借人', identity_status: 'verified', identity_version: 1, account_version: 1, archived_at: null }; const loan = { id: 'loan', loan_no: 'JK-SYN', origin_reference: 'SYN-IN', lender_id: lender.id, legal_entity_id: 'company', currency_code: 'CNY', original_principal: '100', opening_outstanding_principal: '100', historical_entry: true, received_date: new Date('2025-03-01'), balance_as_of: new Date('2026-01-01'), lender, evidences: [{ id: 'original', kind: 'incoming', governance_status: 'approved', file_sha256: 'a'.repeat(64), original_name: 'synthetic.pdf', mime_type: 'application/pdf', file_size: 123 }, { id: 'accounting', kind: 'accounting_confirmation', governance_status: 'approved' }] }; const record = { id: 'ack', lender_id: lender.id, legal_entity_id: 'company', employee_id: 'signer', lender_name: lender.name, identity_version: 1, account_version: 1, proposed_by: 'finance', loan_ids: [loan.id], invalidated_at: null, decision: { action: 'confirm', actor_id: 'signer', original_checked: true } }; loan.lender_acknowledgements = [{ acknowledgement: record, snapshot_json: snapshot(loan) }]; return { loan, record, lender }; } function refused(loan) { expect(() => assertEvidenceComplete(loan)).toThrow(expect.objectContaining({ details: { reasonCode: 'loan.evidence_required' } })); } describe('lender statement as one specific evidence alternative', () => { it('accepts a personally confirmed statement with originals and accounting, without paper agreement', () => { const { loan } = fixture(); expect(() => assertEvidenceComplete(loan)).not.toThrow(); }); it.each(['incoming', 'accounting_confirmation'])('never replaces %s evidence', (kind) => { const { loan } = fixture(); loan.evidences = loan.evidences.filter(e => e.kind !== kind); refused(loan); }); it.each(['archived_at', 'governance_status'])('rejects unusable accounting evidence: %s', (field) => { const { loan } = fixture(); loan.evidences[1][field] = field === 'archived_at' ? new Date() : 'pending'; refused(loan); }); it('still requires the separate basis for historical repayments', () => { const { loan } = fixture(); loan.opening_outstanding_principal = '80'; loan.lender_acknowledgements[0].snapshot_json = snapshot(loan); refused(loan); loan.evidences.push({ kind: 'adjustment', governance_status: 'approved' }); expect(() => assertEvidenceComplete(loan)).not.toThrow(); }); it('keeps the existing paper path when an online statement is absent or invalid', () => { const { loan, record } = fixture(); record.invalidated_at = new Date(); loan.evidences.push({ kind: 'agreement', governance_status: 'approved' }); expect(() => assertEvidenceComplete(loan)).not.toThrow(); delete loan.lender_acknowledgements; expect(() => assertEvidenceComplete(loan)).not.toThrow(); }); it.each([ ['pending', ({ record }) => { record.decision = null; }], ['returned', ({ record }) => { record.decision.action = 'return'; }], ['already invalidated', ({ record }) => { record.invalidated_at = new Date(); }], ['unchecked original', ({ record }) => { record.decision.original_checked = false; }], ['another signer', ({ record }) => { record.decision.actor_id = 'someone-else'; }], ['maker signs own request', ({ record }) => { record.proposed_by = 'signer'; }], ['another loan', ({ record }) => { record.loan_ids = ['other-loan']; }], ['unverified identity', ({ lender }) => { lender.identity_status = 'unverified'; }], ['different association', ({ lender }) => { lender.employee_id = 'other-employee'; }], ['identity version changed', ({ lender }) => { lender.identity_version++; }], ['account version changed', ({ lender }) => { lender.account_version++; }], ['lender archived', ({ lender }) => { lender.archived_at = new Date(); }], ['lender name changed', ({ lender }) => { lender.name = '另一合成人'; }], ['company changed', ({ lender }) => { lender.legal_entity_id = 'other-company'; }], ['amount changed', ({ loan }) => { loan.original_principal = '101'; }], ['balance date changed', ({ loan }) => { loan.balance_as_of = new Date('2026-01-02'); }], ['original changed', ({ loan }) => { loan.evidences[0].file_sha256 = 'b'.repeat(64); }], ])('does not consume a statement with %s', (_, change) => { const f = fixture(); change(f); expect(validAcknowledgement(f.loan)).toBeNull(); refused(f.loan); }); it('balance confirmation, later repayments and additional accounting evidence leave the frozen declaration valid', () => { const { loan, record } = fixture(); loan.version = 9; loan.confirmation_status = 'confirmed'; loan.allocations = [{ amount: '30.00', reservation_state: 'settled' }]; loan.evidences.push({ kind: 'accounting_confirmation', governance_status: 'approved', id: 'extra' }); // PostgreSQL JSONB may return keys in another order. loan.lender_acknowledgements[0].snapshot_json = Object.fromEntries(Object.entries(snapshot(loan)).reverse()); expect(validAcknowledgement(loan)).toBe(record); expect(() => assertEvidenceComplete(loan)).not.toThrow(); }); it.each([ ['no online history', null, false], ['current declaration', null, true], ['invalidated declaration', new Date('2026-01-03'), true], ])('confirmed-balance renewal scope: %s', (_, invalidatedAt, withRecord) => { const { loan, record } = fixture(); loan.confirmation_status = 'confirmed'; record.invalidated_at = invalidatedAt; if (!withRecord) loan.lender_acknowledgements = []; expect(acknowledgementRenewable(loan)).toBe(Boolean(withRecord && invalidatedAt)); }); });