文件
hyb-oa/server/tests/unit/services/loanLenderAcknowledgement.test.js
wangsli和Claude Opus 5.5 4a5cd8f2b3
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
借款出借人线上确认借款并以站内通知直达(09-17-company-loan-principal-repayment)
- 新增出借人确认声明:财务发起,出借人本人查看原件后确认或退回;快照与决定不可改删,金额、日期、原件或身份变化后整组失效
- 余额确认接受纸质协议或有效线上声明二选一,代账余额核对材料仍必需;声明与公司代表授权分离
- 普通员工无常驻入口,财务发起后仅通知对应出借人并直达确认页
- 新增迁移 20261010010000_loan_lender_acknowledgements(仅新增三表)
- 测试:单测、隔离 HTTP/浏览器、完整借款回归;独立只读复核无阻断;同步发布记录与操作手册

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 15:22:12 +08:00

82 行
5.9 KiB
JavaScript

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));
});
});