修复在途还款被出借人维护卡死并按审批时资格登记付款后事实(09-17-company-loan-principal-repayment)
- A:出借人存在 reserved 额度占用时,身份关联与收款资料维护在出借人锁内 返回 409 loan.lender_in_flight;页面禁用并提示,修正账户弹窗误导文案 - B:回单 OCR 认领、人工回单参与人、核销按审批时资格认定,不再因原审批人 或出借员工事后离职/撤权、主数据停用而卡死;保留身份核实、版本与付款快照 一致、审批独立性及回单匹配校验;付款前环节仍校验当前资格 - 测试:隔离 PostgreSQL 合成全流程,含确定性并发交错、付款后独立性反例、 离职后经 OCR/人工复核核销、PC/手机按钮禁用;变异测试均变红 - 恢复手册:资金类数据恢复后先暂停付款,对照银行流水补齐并核对占用再开放 - design 按环节修订资格规则 注意:C(同金额回单传错批次错误核销)已复现但尚未修复,借款功能不具备 真实还款验收条件;未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -297,32 +297,204 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
payload: { reason: '合成还款审批材料', paymentBatchId: manual.id } });
|
||||
assert.equal(reviewerDownload.statusCode, 200);
|
||||
await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403);
|
||||
const { item: stale } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] });
|
||||
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
|
||||
expectedVersion: verified.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成收款人关联经理',
|
||||
});
|
||||
await call(manager, 'POST', `/payment-batches/${stale.id}/review`, {}, 403);
|
||||
// Fresh lenders below: the first lender keeps unknown-state reservations from
|
||||
// the negative receipt loop above, which by design block its maintenance.
|
||||
async function verifiedLender(name, employeeId, digit) {
|
||||
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name,
|
||||
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: digit.repeat(16), accountName: name });
|
||||
const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, {
|
||||
expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' });
|
||||
return { ...confirmedLender, bankAccount: digit.repeat(16) };
|
||||
}
|
||||
async function confirmedLoan(lenderId, principal, ref) {
|
||||
const created = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId, originReference: ref,
|
||||
originalPrincipal: principal, openingOutstandingPrincipal: principal, receivedDate: '2025-03-01', balanceAsOf: '2026-01-01', historicalEntry: true });
|
||||
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, created.id);
|
||||
await call(finance, 'POST', `/company-loans/${created.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' });
|
||||
return created;
|
||||
}
|
||||
const repay = async (lenderId, loanId, amount) => (await call(finance, 'POST', '/loan-repayments', {
|
||||
requestKey: randomUUID(), lenderId, reason: '合成还本', allocations: [{ loanId, amount }] })).item;
|
||||
async function exportOrder(batchId, status = 200, reason) {
|
||||
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/payment-order`, headers: finance.headers, payload: {} });
|
||||
assert.equal(response.statusCode, status, `export ${response.statusCode} ${status === 200 ? '' : response.body.slice(0, 200)}`);
|
||||
if (reason) assert.equal(response.json().details?.reasonCode, reason);
|
||||
}
|
||||
const lenderState = async (id) => (await call(finance, 'GET', '/loan-lenders')).items.find((item) => item.id === id);
|
||||
const independent = await actor(['ceo']);
|
||||
await call(independent, 'POST', `/payment-batches/${stale.id}/review`, {}, 409, 'loan.version_changed');
|
||||
await call(finance, 'POST', `/payment-batches/${stale.id}/cancel`, { reason: '合成未发起身份更正' });
|
||||
const { item: ownPayee } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] });
|
||||
|
||||
// Lender linked to a manager: that manager cannot approve, an independent CEO can.
|
||||
const linked = await verifiedLender('合成出借人乙', manager.id, '4');
|
||||
const linkedLoan = await confirmedLoan(linked.id, '500.00', '合成乙借入');
|
||||
const ownPayee = await repay(linked.id, linkedLoan.id, '5.00');
|
||||
await call(manager, 'POST', `/payment-batches/${ownPayee.id}/review`, {}, 403);
|
||||
await call(independent, 'POST', `/payment-batches/${ownPayee.id}/review`, {});
|
||||
await call(finance, 'POST', `/payment-batches/${ownPayee.id}/cancel`, { reason: '合成验证结束' });
|
||||
checks.push('scoped_reviewer_materials_payee_self_review_and_identity_version_guards');
|
||||
const { item: oldAccount } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] });
|
||||
const changedAccount = await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, {
|
||||
expectedVersion: lender.accountVersion, bankInstitutionId: bank.id, bankName: '合成变更开户行',
|
||||
bankAccount: '3'.repeat(16), accountName: '合成出借人',
|
||||
});
|
||||
checks.push('scoped_reviewer_materials_and_payee_self_review');
|
||||
|
||||
// B1: any reserved allocation freezes lender identity/account maintenance,
|
||||
// before and after payment-order export, until the repayment settles or is cancelled.
|
||||
const identityChange = (who, version, status, reason) => call(who, 'POST', `/loan-lenders/${linked.id}/identity-confirmation`, {
|
||||
expectedVersion: version, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成在途改身份' }, status, reason);
|
||||
const accountChange = (version, status, reason) => call(finance, 'PATCH', `/loan-lenders/${linked.id}/account`, {
|
||||
expectedVersion: version, bankInstitutionId: bank.id, bankName: '合成变更开户行', bankAccount: '3'.repeat(16), accountName: '合成出借人乙' }, status, reason);
|
||||
assert.equal((await lenderState(linked.id)).hasInFlightRepayment, false);
|
||||
const inFlight = await repay(linked.id, linkedLoan.id, '5.00');
|
||||
assert.equal((await lenderState(linked.id)).hasInFlightRepayment, true);
|
||||
await identityChange(finance, (await lenderState(linked.id)).identityVersion, 409, 'loan.lender_in_flight');
|
||||
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight');
|
||||
await call(independent, 'POST', `/payment-batches/${inFlight.id}/review`, {});
|
||||
await exportOrder(inFlight.id);
|
||||
await identityChange(finance, (await lenderState(linked.id)).identityVersion, 409, 'loan.lender_in_flight');
|
||||
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight');
|
||||
const inFlightReceipt = await receipt(inFlight.id, '500', 'SYNTHETIC-IN-FLIGHT', { payeeAccount: linked.bankAccount });
|
||||
assert.equal(inFlightReceipt.result.status, 'matched');
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${inFlight.id}/execute`, {})).item.status, 'executed');
|
||||
assert.equal((await lenderState(linked.id)).hasInFlightRepayment, false);
|
||||
// A never-sent request can be cancelled to release the freeze.
|
||||
const unsent = await repay(linked.id, linkedLoan.id, '5.00');
|
||||
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.lender_in_flight');
|
||||
await call(finance, 'POST', `/payment-batches/${unsent.id}/cancel`, { reason: '合成未发送取消' });
|
||||
checks.push('reserved_allocation_blocks_lender_maintenance_before_and_after_export');
|
||||
|
||||
// Account change after release revokes identity and blocks new requests until re-verified.
|
||||
const changedAccount = await accountChange((await lenderState(linked.id)).accountVersion);
|
||||
assert.equal(changedAccount.identityStatus, 'unverified');
|
||||
await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.identity_required');
|
||||
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
|
||||
expectedVersion: changedAccount.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成账户变更后重新核实',
|
||||
});
|
||||
await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.version_changed');
|
||||
await call(finance, 'POST', `/payment-batches/${oldAccount.id}/cancel`, { reason: '合成旧账户未发起取消' });
|
||||
checks.push('account_changes_revoke_identity_and_invalidate_old_unpaid_snapshot');
|
||||
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: linked.id, reason: '合成未核实',
|
||||
allocations: [{ loanId: linkedLoan.id, amount: '1.00' }] }, 409, 'loan.identity_required');
|
||||
await identityChange(finance, changedAccount.identityVersion);
|
||||
checks.push('account_changes_revoke_identity_until_reverified');
|
||||
|
||||
// Maintenance racing creation, with deterministic interleaving: a separate
|
||||
// connection holds the lender row lock, both requests queue behind it in a
|
||||
// chosen order, then the lock is released. No reservation may keep stale versions.
|
||||
const { Pool: LockPool } = req('pg');
|
||||
const lockPool = new LockPool({ ...pool.options, max: 2 });
|
||||
const lockWaiters = async () => Number((await lockPool.query(
|
||||
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n);
|
||||
async function untilWaiting(count) {
|
||||
for (let i = 0; i < 200; i++) { if (await lockWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); }
|
||||
throw new Error('request did not queue behind the lender lock');
|
||||
}
|
||||
try {
|
||||
for (const first of ['create', 'maintain']) {
|
||||
const current = await lenderState(linked.id);
|
||||
const holder = await lockPool.connect();
|
||||
let created; let maintained;
|
||||
try {
|
||||
await holder.query('BEGIN');
|
||||
await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [linked.id]);
|
||||
const start = { create: () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: {
|
||||
requestKey: randomUUID(), lenderId: linked.id, reason: '合成并发', allocations: [{ loanId: linkedLoan.id, amount: '1.00' }] } }),
|
||||
maintain: () => app.inject({ method: 'POST', url: `/api/loan-lenders/${linked.id}/identity-confirmation`, headers: finance.headers, payload: {
|
||||
expectedVersion: current.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成并发改身份' } }) };
|
||||
const second = first === 'create' ? 'maintain' : 'create';
|
||||
const firstResponse = start[first](); await untilWaiting(1);
|
||||
const secondResponse = start[second](); await untilWaiting(2);
|
||||
await holder.query('COMMIT');
|
||||
[created, maintained] = first === 'create' ? [await firstResponse, await secondResponse] : [await secondResponse, await firstResponse];
|
||||
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
|
||||
if (first === 'create') {
|
||||
assert.equal(created.statusCode, 200);
|
||||
assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().details?.reasonCode, 'loan.lender_in_flight');
|
||||
} else {
|
||||
assert.equal(maintained.statusCode, 200, 'maintenance first succeeds');
|
||||
assert.equal(created.statusCode, 200, 'creation after maintenance uses the new versions');
|
||||
}
|
||||
const after = await prisma.loanLender.findUniqueOrThrow({ where: { id: linked.id } });
|
||||
const stale = await prisma.loanRepayment.count({ where: { lender_id: linked.id, allocations: { some: { reservation_state: 'reserved' } },
|
||||
OR: [{ lender_identity_version: { not: after.identity_version } }, { lender_account_version: { not: after.account_version } }] } });
|
||||
assert.equal(stale, 0, `no reserved repayment may hold stale lender versions (${first} first)`);
|
||||
await call(finance, 'POST', `/payment-batches/${created.json().data.item.id}/cancel`, { reason: '合成并发结束' });
|
||||
}
|
||||
} finally { await lockPool.end(); }
|
||||
checks.push('lender_maintenance_and_creation_deterministic_interleaving_never_leaves_stale_reservation');
|
||||
|
||||
// B: after the bank paid, approval-time eligibility decides. A departed
|
||||
// reviewer or lender employee must not strand settlement via OCR or manual review.
|
||||
const lenderEmployee = await actor(['employee']);
|
||||
const staffLender = await verifiedLender('合成出借人丙', lenderEmployee.id, '5');
|
||||
const staffLoan = await confirmedLoan(staffLender.id, '500.00', '合成丙借入');
|
||||
const setStatus = (who, status) => prisma.employee.update({ where: { id: who.id }, data: { status } });
|
||||
const outstanding = async () => (await call(finance, 'GET', `/company-loans/${staffLoan.id}`)).outstanding;
|
||||
const reviewerA = await actor(['manager']);
|
||||
const viaOcr = await repay(staffLender.id, staffLoan.id, '10.00');
|
||||
await call(reviewerA, 'POST', `/payment-batches/${viaOcr.id}/review`, {});
|
||||
await exportOrder(viaOcr.id);
|
||||
await setStatus(reviewerA, 'archived'); await setStatus(lenderEmployee, 'archived');
|
||||
const ocrResult = await receipt(viaOcr.id, '1000', 'SYNTHETIC-POST-PAY-OCR', { payeeAccount: staffLender.bankAccount });
|
||||
assert.equal(ocrResult.result.status, 'matched', 'OCR must not fall back to legacy after departures');
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${viaOcr.id}/execute`, {})).item.status, 'executed');
|
||||
assert.equal(await outstanding(), '490.00');
|
||||
await setStatus(lenderEmployee, 'active');
|
||||
const reviewerB = await actor(['manager']);
|
||||
const viaManual = await repay(staffLender.id, staffLoan.id, '20.00');
|
||||
await call(reviewerB, 'POST', `/payment-batches/${viaManual.id}/review`, {});
|
||||
await exportOrder(viaManual.id);
|
||||
await setStatus(reviewerB, 'archived'); await setStatus(lenderEmployee, 'archived');
|
||||
const manualUnclear = await receipt(viaManual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
|
||||
assert.equal(manualUnclear.result.status, 'needs_review');
|
||||
const manualAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: manualUnclear.attachment.id } });
|
||||
const manualPath = `/payment-batches/${viaManual.id}/receipt-attachments/${manualUnclear.attachment.id}/manual-verification`;
|
||||
const manualProposal = await call(finance, 'POST', manualPath, { expectedVersion: manualAttachment.receipt_ocr_version,
|
||||
reason: '合成付款后人工核对', pages: [{ pageNumber: 1, itemId: viaManual.items[0].id, fields: Object.entries({
|
||||
小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: staffLender.bankAccount, 凭证号: 'SYNTHETIC-POST-PAY-MANUAL', 单据状态: '交易成功',
|
||||
}).map(([Name, Value]) => ({ Name, Value })) }] });
|
||||
const manualSaved = manualProposal.item ?? manualProposal;
|
||||
await call(manager, 'POST', `${manualPath}/${manualSaved.id}/review`, { expectedVersion: manualSaved.version, decision: 'approve', reason: '合成当前独立复核' });
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${viaManual.id}/execute`, {})).item.status, 'executed');
|
||||
assert.equal(await outstanding(), '470.00');
|
||||
await setStatus(lenderEmployee, 'active');
|
||||
checks.push('departed_reviewer_or_lender_employee_does_not_strand_paid_repayment_via_ocr_or_manual');
|
||||
// Approval-time independence is still enforced after payment: a recorded
|
||||
// approver equal to the batch creator/submitter or lender employee is refused.
|
||||
for (const [label, approverId] of [['creator', finance.id], ['lender_employee', lenderEmployee.id]]) {
|
||||
const forged = await repay(staffLender.id, staffLoan.id, '2.00');
|
||||
await call(manager, 'POST', `/payment-batches/${forged.id}/review`, {});
|
||||
await exportOrder(forged.id);
|
||||
const forgedReceipt = await receipt(forged.id, '200', `SYNTHETIC-FORGED-APPROVER-${label}`, { payeeAccount: staffLender.bankAccount });
|
||||
assert.equal(forgedReceipt.result.status, 'matched');
|
||||
await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: approverId } });
|
||||
await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {}, 403, 'loan.independent_review_required');
|
||||
await prisma.paymentBatch.update({ where: { id: forged.id }, data: { reviewed_by: manager.id } });
|
||||
assert.equal((await call(finance, 'POST', `/payment-batches/${forged.id}/execute`, {})).item.status, 'executed');
|
||||
}
|
||||
checks.push('post_payment_still_rejects_non_independent_recorded_approver');
|
||||
// Pre-payment export still requires the approver to be currently eligible.
|
||||
const reviewerC = await actor(['manager']);
|
||||
const prePayment = await repay(staffLender.id, staffLoan.id, '1.00');
|
||||
await call(reviewerC, 'POST', `/payment-batches/${prePayment.id}/review`, {});
|
||||
await setStatus(reviewerC, 'archived');
|
||||
await exportOrder(prePayment.id, 403, 'loan.independent_review_required');
|
||||
await setStatus(reviewerC, 'active');
|
||||
await call(finance, 'POST', `/payment-batches/${prePayment.id}/cancel`, { reason: '合成付款前资格失效' });
|
||||
checks.push('pre_payment_export_keeps_current_approver_eligibility');
|
||||
|
||||
// C (reproduction only, outcome recorded, not yet a guarded behaviour):
|
||||
// can a genuine receipt of one repayment settle a different same-amount batch?
|
||||
const probeLender = await verifiedLender('合成出借人丁', null, '6');
|
||||
const probeLoan = await confirmedLoan(probeLender.id, '500.00', '合成丁借入');
|
||||
const reproductions = [];
|
||||
async function probeExecute(batchId) {
|
||||
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} });
|
||||
return { status: response.statusCode, reasonCode: response.json().details?.reasonCode ?? null };
|
||||
}
|
||||
const c1p = await repay(probeLender.id, probeLoan.id, '7.00');
|
||||
const c1q = await repay(probeLender.id, probeLoan.id, '7.00');
|
||||
for (const item of [c1p, c1q]) { await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); }
|
||||
const c1 = await receipt(c1q.id, '700', 'SYNTHETIC-C1-PAYMENT-OF-P', { payeeAccount: probeLender.bankAccount });
|
||||
reproductions.push({ scenario: 'two_in_flight_same_amount_receipt_of_p_uploaded_to_q', match: c1.result.status, execute: await probeExecute(c1q.id) });
|
||||
const c2old = await repay(probeLender.id, probeLoan.id, '6.00');
|
||||
await call(manager, 'POST', `/payment-batches/${c2old.id}/review`, {});
|
||||
const c2fail = await receipt(c2old.id, '600', 'SYNTHETIC-C2-OLD-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
|
||||
await call(manager, 'POST', `/payment-batches/${c2old.id}/bank-failure-review`, { attachmentId: c2fail.attachment.id });
|
||||
await call(finance, 'POST', `/payment-batches/${c2old.id}/cancel`, { reason: '合成旧批次确认失败' });
|
||||
const c2new = await repay(probeLender.id, probeLoan.id, '6.00');
|
||||
await call(manager, 'POST', `/payment-batches/${c2new.id}/review`, {}); await exportOrder(c2new.id);
|
||||
const c2 = await receipt(c2new.id, '600', 'SYNTHETIC-C2-OLD-LATE-SUCCESS', { payeeAccount: probeLender.bankAccount });
|
||||
reproductions.push({ scenario: 'old_failed_cancelled_then_late_success_receipt_uploaded_to_new_same_amount', match: c2.result.status, execute: await probeExecute(c2new.id) });
|
||||
console.log(JSON.stringify({ stage: 'loan_receipt_attribution_reproduction', reproductions }));
|
||||
checks.push('receipt_batch_attribution_reproduction_recorded');
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
|
||||
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma });
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户