修复在途还款被出借人维护卡死并按审批时资格登记付款后事实(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]>
这个提交包含在:
2026-10-06 13:13:30 +08:00
共同撰写人 Claude Opus 5.5
父节点 1989f666ac
当前提交 b639422211
共修改 15 个文件,包含 311 行新增和 38 行删除
+29 -7
查看文件
@@ -14,8 +14,12 @@ const REPAYMENT_INCLUDE = {
};
function assertLenderReady(lender) {
function assertLenderIdentityVerified(lender) {
if (lender?.identity_status !== "verified" || !lender.identity_confirmed_at || !lender.identity_confirmed_by) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
}
function assertLenderReady(lender) {
assertLenderIdentityVerified(lender);
if (lender.archived_at || !lender.legal_entity?.enabled || lender.legal_entity.archived_at ||
!lender.bank_institution?.enabled || lender.bank_institution.archived_at || lender.bank_institution.code !== "CCB") { throw buildBusinessValidationError("loan.source_unavailable", {}, 409); };
}
@@ -52,14 +56,31 @@ async function readLoanRepayment(tx, batch) {
return repayment;
}
async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, recheckApproval = false } = {}) {
// Approval-time eligibility for facts that already happened at the bank.
// The review route ran activeReviewer() against current roles when it set
// reviewed_by/reviewed_at, so a later departure or role change of that reviewer
// (or of the lender's linked employee) must not strand a paid repayment.
// Independence is still checked against the frozen participants.
function assertApprovedIndependently(batch, excludedIds) {
if (!batch.reviewed_by || !batch.reviewed_at || excludedIds.includes(batch.reviewed_by)) {
throw buildBusinessValidationError("loan.independent_review_required", {}, 403);
}
}
// postPayment=true is for receipt OCR, manual receipt review and settlement:
// identity/account versions and the frozen payee snapshot must still match
// (maintenance is blocked while reserved, see assertLenderNotInFlight), but
// current employment, roles and master-data enablement are not re-judged.
// Pre-payment paths (creation, review, payment-order export) keep current checks.
async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, recheckApproval = false, postPayment = false } = {}) {
let repayment = await readLoanRepayment(tx, batch);
if (lock) {
await lockLender(tx, repayment.lender_id);
repayment = await readLoanRepayment(tx, batch);
}
const lender = repayment.lender;
assertLenderReady(lender);
if (postPayment) assertLenderIdentityVerified(lender);
else assertLenderReady(lender);
if (lender.identity_version !== repayment.lender_identity_version || lender.account_version !== repayment.lender_account_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
if (repayment.legal_entity_id !== batch.legal_entity_id || repayment.currency_code !== "CNY" ||
!new Prisma.Decimal(repayment.amount).eq(batch.items[0].amount) || batch.items[0].currency_code !== "CNY" ||
@@ -68,7 +89,8 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
batch.items[0].snapshot_json?.payee?.accountNameEncrypted !== lender.account_name_encrypted) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
const excluded = [batch.created_by, repayment.submitted_by, lender.employee_id].filter(Boolean);
if (reviewerId) await activeReviewer(tx, reviewerId, excluded, { lock });
if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock });
if (postPayment) assertApprovedIndependently(batch, excluded);
else if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock });
if (lock) {
const ids = repayment.allocations.map((a) => a.loan_id).sort();
if (!ids.length) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); };
@@ -81,7 +103,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
assertEvidenceComplete(loan);
if (new Prisma.Decimal(loanBalances(loan).available).lt(0)) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); };
}
if (lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
if (!postPayment && lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
return repayment;
}
@@ -148,7 +170,7 @@ function repaymentMatchesInput(repayment, input) {
}
async function settleLoanRepayment(tx, batch, request) {
const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true });
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
if (repayment.bank_result_state === "confirmed_failed") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
@@ -175,7 +197,7 @@ async function cancelLoanRepayment(tx, batch, request, reason) {
}
async function loanReceiptParticipants(tx, batch) {
const repayment = await validateLoanPayment(tx, batch);
const repayment = await validateLoanPayment(tx, batch, { postPayment: true });
return [{ ownerId: repayment.lender.employee_id, externalOwnerVerified: repayment.lender.employee_id === null,
submitterId: repayment.submitted_by }];
}
+22 -2
查看文件
@@ -57,7 +57,7 @@ function encryptLenderAccount(config, input) {
}
}
function mapLender(config, lender) {
function mapLender(config, lender, { inFlight = false } = {}) {
let bankAccountMasked = null; let accountUnavailable = false;
try { bankAccountMasked = maskBankAccount(readLenderAccount(config, lender).bankAccount); }
catch { accountUnavailable = true; }
@@ -66,7 +66,7 @@ function mapLender(config, lender) {
legalEntityName: lender.legal_entity?.name ?? null, employeeId: lender.employee_id,
identityStatus: lender.identity_status, identityVersion: lender.identity_version,
accountVersion: lender.account_version, bankInstitutionId: lender.bank_institution_id,
bankName: lender.bank_name, bankAccountMasked, accountUnavailable,
bankName: lender.bank_name, bankAccountMasked, accountUnavailable, hasInFlightRepayment: inFlight,
};
}
@@ -105,6 +105,25 @@ async function lockLender(tx, id) {
return tx.loanLender.findUniqueOrThrow({ where: { id }, include: LENDER_INCLUDE });
}
// Any reserved allocation means a repayment froze this lender's identity and
// account versions. Creation locks the lender before reserving, so checking
// under the same lender lock serializes with it; later batch steps never add
// reservations. Changing the lender then would strand an already-paid request.
async function assertLenderNotInFlight(tx, lenderId) {
const reserved = await tx.loanRepaymentAllocation.count({ where: {
reservation_state: "reserved", repayment: { lender_id: lenderId },
} });
if (reserved > 0) throw buildBusinessValidationError("loan.lender_in_flight", {}, 409);
}
async function inFlightLenderIds(db, lenderIds) {
if (!lenderIds.length) return new Set();
const rows = await db.loanRepayment.findMany({ where: {
lender_id: { in: lenderIds }, allocations: { some: { reservation_state: "reserved" } },
}, select: { lender_id: true }, distinct: ["lender_id"] });
return new Set(rows.map((row) => row.lender_id));
}
function assertEvidenceComplete(loan) {
const kinds = new Set((loan.evidences ?? []).filter((e) => !e.archived_at && e.governance_status === "approved").map((e) => e.kind));
const required = ["agreement", "incoming", "accounting_confirmation"];
@@ -146,4 +165,5 @@ module.exports = {
LENDER_INCLUDE, LOAN_INCLUDE, ensureLoanManager, loanNotFound, auditLoan, loanBalances,
readLenderAccount, encryptLenderAccount, mapLender, mapLoan, mapEvidence, lockLoan, lockLender,
assertEvidenceComplete, assertCurrentVersion, validateLenderReferences, createIdempotently,
assertLenderNotInFlight, inFlightLenderIds,
};