文件
hyb-oa/scripts/qa/company-loan-lender-ack-fixture.cjs
T
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

290 行
24 KiB
JavaScript

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