387 行
30 KiB
JavaScript
387 行
30 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { randomUUID } = require('node:crypto');
|
|
|
|
// Exercise the real return consumer in both approval modes. A late-success
|
|
// audit rollback does not prove that return claims and freeze closure roll back.
|
|
async function verifyReturnGuards({ prisma, pool, call, finance, batchId, lenderId, loanId, parentId, voidedEvidenceId, fields, upload, review, apply, prefix }) {
|
|
const base = `/payment-batches/${batchId}`;
|
|
const checks = [];
|
|
const remaining = async () => {
|
|
const settlements = await prisma.loanSupplementalSettlement.findMany({ where: { event_id: parentId } });
|
|
const returns = await prisma.loanBankResultEvent.findMany({ where: { parent_id: parentId, kind: 'return', status: 'booked' } });
|
|
return settlements.reduce((n, r) => n + Number(r.receivable_amount), 0) - returns.reduce((n, r) => n + Number(r.amount), 0);
|
|
};
|
|
async function snapshot() {
|
|
const loan = await call(finance, 'GET', `/company-loans/${loanId}`);
|
|
const batch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batchId } });
|
|
const repayment = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loan_repayment_id }, include: { allocations: true } });
|
|
const freezes = await prisma.loanFreezeReason.findMany({ where: { lender_id: lenderId }, orderBy: { id: 'asc' } });
|
|
return {
|
|
outstandingReturn: (await remaining()).toFixed(2), outstanding: loan.outstanding, reserved: loan.reserved,
|
|
batchCount: await prisma.paymentBatch.count(), batchStatus: batch.status, bankState: repayment.bank_result_state,
|
|
allocations: repayment.allocations.map(r => [r.id, r.amount.toFixed(2), r.reservation_state]).sort(),
|
|
claims: await prisma.loanBankEventClaim.count({ where: { event: { payment_batch_id: batchId } } }),
|
|
freezes: freezes.map(r => [r.id, r.kind, r.status]),
|
|
supplementalCount: await prisma.loanSupplementalSettlement.count({ where: { event_id: parentId } }),
|
|
};
|
|
}
|
|
const body = async (amount, overrides = {}) => ({ kind: 'return', parentId, evidenceId: await upload('incoming_return'), requestKey: randomUUID(),
|
|
fields: fields(amount, true), attributionNote: '合成退款原件唯一定位原批次', exclusionNote: '合成逐笔排除其余付款及退款交易', ...overrides });
|
|
const propose = (input, status = 200) => call(finance, 'POST', `${base}/loan-bank-results`, input, status,
|
|
status === 409 ? 'loan.bank_result_reconcile_required' : undefined);
|
|
const initial = await snapshot();
|
|
assert.equal(initial.outstandingReturn, '40.00');
|
|
const wrongKind = await upload('late_success');
|
|
assert.ok((await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: voidedEvidenceId } })).voided_at);
|
|
for (const [name, input] of [
|
|
['wrong_direction', await body('15.00', { fields: fields('15.00', false) })],
|
|
['wrong_evidence_kind', await body('15.00', { evidenceId: wrongKind })],
|
|
['voided_evidence', await body('15.00', { evidenceId: voidedEvidenceId })],
|
|
['over_remaining', await body('40.01')],
|
|
]) {
|
|
const eventCount = await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } });
|
|
await propose(input, 409);
|
|
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } }), eventCount);
|
|
assert.deepEqual(await snapshot(), initial, `${name} must not change money, claims, freezes or original facts`);
|
|
checks.push(`${prefix}_return_${name}_refused_without_money_change`);
|
|
}
|
|
const input = await body('15.00');
|
|
const partial = await propose(input);
|
|
const eventCount = await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } });
|
|
assert.equal((await propose(input)).id, partial.id);
|
|
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } }), eventCount);
|
|
await propose({ ...input, fields: fields('14.00', true) }, 409);
|
|
await review(partial); await apply(partial);
|
|
const afterPartial = await snapshot(); assert.equal(afterPartial.outstandingReturn, '25.00');
|
|
assert.ok(afterPartial.freezes.some(r => r[1] === 'receivable' && r[2] === 'active'));
|
|
await apply(partial); assert.deepEqual(await snapshot(), afterPartial, 'booked replay does not count a return twice');
|
|
checks.push(`${prefix}_return_request_key_and_booked_replay_idempotent`);
|
|
await propose(await body('25.01'), 409);
|
|
assert.deepEqual(await snapshot(), afterPartial, 'remaining limit is recomputed after partial return');
|
|
checks.push(`${prefix}_return_partial_recomputes_remaining_limit`);
|
|
|
|
// Re-uploading the same bank transaction may create auditable proposal
|
|
// history, but it must never gain approval or a second claim.
|
|
const duplicate = await propose({ ...input, evidenceId: await upload('incoming_return'), requestKey: randomUUID() });
|
|
await review(duplicate, 409); assert.deepEqual(await snapshot(), afterPartial);
|
|
checks.push(`${prefix}_return_same_bank_transaction_cannot_be_booked_twice`);
|
|
const rejected = await propose(await body('1.00'));
|
|
await review(rejected, 200, { decision: 'reject' });
|
|
await apply(rejected, 409); assert.deepEqual(await snapshot(), afterPartial);
|
|
checks.push(`${prefix}_return_rejected_review_cannot_apply`);
|
|
|
|
// Keep a separate unresolved late-success cause while fully returning this
|
|
// receivable. Closing one cause must not unfreeze the whole lender.
|
|
const other = await propose({ ...await body('80.00'), kind: 'late_success', parentId: undefined,
|
|
evidenceId: wrongKind, fields: fields('80.00', false) });
|
|
const full = await propose(await body('25.00')); await review(full);
|
|
const beforeRollback = await snapshot();
|
|
await pool.query(`CREATE FUNCTION synthetic_return_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
|
IF NEW.action='LOAN_RETURN_BOOKED' THEN RAISE EXCEPTION 'synthetic return audit failure'; END IF; RETURN NEW; END $$`);
|
|
await pool.query('CREATE TRIGGER synthetic_return_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_return_audit_failure()');
|
|
try { await apply(full, 500); }
|
|
finally {
|
|
await pool.query('DROP TRIGGER synthetic_return_audit_failure ON audit_logs');
|
|
await pool.query('DROP FUNCTION synthetic_return_audit_failure()');
|
|
}
|
|
assert.deepEqual(await snapshot(), beforeRollback, 'return audit failure rolls back claims, amount and freeze closure');
|
|
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: full.id } })).status, 'approved');
|
|
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: full.id } }), 0);
|
|
checks.push(`${prefix}_return_audit_failure_rolls_back_claim_amount_and_freeze`);
|
|
await apply(full);
|
|
const afterFull = await snapshot(); assert.equal(afterFull.outstandingReturn, '0.00');
|
|
assert.ok(!afterFull.freezes.some(r => r[1] === 'receivable' && r[2] === 'active'));
|
|
assert.ok(afterFull.freezes.some(r => r[1] === 'late_success' && r[2] === 'active'));
|
|
assert.equal(afterFull.outstanding, initial.outstanding); assert.equal(afterFull.reserved, initial.reserved);
|
|
assert.equal(afterFull.batchCount, initial.batchCount); assert.equal(afterFull.batchStatus, initial.batchStatus);
|
|
assert.equal(afterFull.bankState, initial.bankState); assert.deepEqual(afterFull.allocations, initial.allocations);
|
|
assert.equal(afterFull.supplementalCount, initial.supplementalCount);
|
|
await apply(full); assert.deepEqual(await snapshot(), afterFull);
|
|
await review(other, 200, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: await upload('bank_terminal') });
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: lenderId, status: 'active' } }), 0);
|
|
checks.push(`${prefix}_return_full_closes_only_receivable_and_preserves_original_facts`);
|
|
return checks;
|
|
}
|
|
|
|
module.exports = async function bankResultsFixture(h) {
|
|
const { prisma, pool, call, app, req, finance, manager, independent, verifiedLender, confirmedLoan, repay,
|
|
exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid } = h;
|
|
const { PDFDocument } = req('pdf-lib');
|
|
const config = h.config;
|
|
const { decryptStringOrThrow } = req('./src/services/crypto');
|
|
async function upload(batchId, kind) {
|
|
const doc = await PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]);
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
const res = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-terminal-evidences?kind=${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.from(await doc.save()), Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
|
assert.equal(res.statusCode, 200); return res.json().data.id;
|
|
}
|
|
async function setup(cancelled = true, prepareVoidedReturn = false) {
|
|
const lender = await verifiedLender(`合成晚到${randomUUID().slice(0, 6)}`, null, '5');
|
|
const loan = await confirmedLoan(lender.id, '100.00', '合成晚到借款');
|
|
const batch = await repay(lender.id, loan.id, '80.00');
|
|
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id);
|
|
const exp = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: batch.id } });
|
|
const sub = await register(batch.id, `LATE-${randomUUID()}`, exp.id); await attest(batch.id);
|
|
const ev = await uploadEvidence(batch.id);
|
|
const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: ev });
|
|
await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id);
|
|
let voidedEvidenceId;
|
|
if (prepareVoidedReturn) {
|
|
voidedEvidenceId = await upload(batch.id, 'incoming_return');
|
|
await call(finance, 'POST', `/payment-batches/${batch.id}/loan-terminal-evidences/${voidedEvidenceId}/void`, { reason: '合成取消前作废未消费退款材料' });
|
|
}
|
|
if (cancelled) await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '合成原终态取消' });
|
|
const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id } });
|
|
return { lender, loan, batch, voidedEvidenceId, payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted) };
|
|
}
|
|
function fields(s, amount = '80.00', incoming = false, ref = `BANK-${randomUUID()}`) {
|
|
return [{ Name: '付款账号', Value: incoming ? s.lender.bankAccount : s.payer }, { Name: '收款账号', Value: incoming ? s.payer : s.lender.bankAccount },
|
|
{ Name: '金额', Value: amount }, { Name: '币种', Value: 'CNY' }, { Name: '交易状态', Value: '交易成功' }, { Name: '流水号', Value: ref }];
|
|
}
|
|
async function propose(s, kind = 'late_success', parentId, amount, ref, status = 200) {
|
|
return call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results`, { kind, ...(parentId ? { parentId } : {}),
|
|
evidenceId: await upload(s.batch.id, kind === 'return' ? 'incoming_return' : 'late_success'), requestKey: randomUUID(),
|
|
fields: fields(s, amount, kind === 'return', ref), attributionNote: '合成银行附言唯一定位原批次的付款事实', exclusionNote: '合成银行查询材料逐一排除其他相同金额候选' }, status);
|
|
}
|
|
const review = (s, e, who = independent, body = {}, status = 200) => call(who, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results/${e.id}/review`, { decision: 'approve', reason: '合成独立核对流水归属及银行证据', ...body }, status);
|
|
const apply = (s, e, status = 200) => call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results/${e.id}/apply`, {}, status);
|
|
const balances = (s) => call(finance, 'GET', `/company-loans/${s.loan.id}`);
|
|
const checks = [];
|
|
for (const cancelled of [true, false]) {
|
|
const s = await setup(cancelled); const count = await prisma.paymentBatch.count();
|
|
const e = await propose(s);
|
|
if (!cancelled) await call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`, { reason: '合成不得用旧结论释放' }, 409, 'loan.bank_result_reconcile_required');
|
|
await review(s, e, finance, {}, 403); await review(s, e);
|
|
await apply(s, e); await apply(s, e);
|
|
assert.equal((await balances(s)).outstanding, '20.00'); assert.equal((await balances(s)).reserved, '0.00');
|
|
assert.equal(await prisma.paymentBatch.count(), count);
|
|
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.batch.loanRepaymentId } })).bank_result_state, 'confirmed_not_paid');
|
|
const record = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: e.id } });
|
|
assert.equal(record.bank_amount.toFixed(2), '80.00'); assert.equal(record.settled_amount.toFixed(2), '80.00');
|
|
await assert.rejects(prisma.loanSupplementalSettlement.update({ where: { id: record.id }, data: { settled_amount: '1.00' } }));
|
|
}
|
|
checks.push('s6_late_success_cancelled_or_still_reserved_books_once_without_new_payment_batch');
|
|
{
|
|
const s = await setup(); const conflict = await repay(s.lender.id, s.loan.id, '70.00');
|
|
const e = await propose(s); await review(s, e); assert.equal((await apply(s, e)).conflict, true);
|
|
assert.equal((await balances(s)).outstanding, '100.00');
|
|
await call(manager, 'POST', `/payment-batches/${conflict.id}/review`, {}, 409, 'loan.lender_frozen');
|
|
await call(finance, 'POST', `/payment-batches/${conflict.id}/cancel`, { reason: '合成清除未付款占用' });
|
|
await apply(s, e); assert.equal((await balances(s)).outstanding, '20.00');
|
|
}
|
|
checks.push('s6_reserved_conflict_is_not_overpayment_and_blocks_until_resolved');
|
|
{
|
|
const s = await setup(true, true);
|
|
// Another real payment uses the existing HTTP/OCR settlement path.
|
|
const paid = await repay(s.lender.id, s.loan.id, '60.00');
|
|
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
|
|
await h.receipt(paid.id, '6000', `ALREADY-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
|
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
|
|
const e = await propose(s); await review(s, e); await apply(s, e);
|
|
const row = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: e.id } });
|
|
assert.equal(row.settled_amount.toFixed(2), '40.00'); assert.equal(row.receivable_amount.toFixed(2), '40.00');
|
|
assert.equal((await balances(s)).outstanding, '0.00');
|
|
await propose(s, 'return', e.id, '0.00', undefined, 409);
|
|
checks.push(...await verifyReturnGuards({ prisma, pool, call, finance, batchId: s.batch.id, lenderId: s.lender.id, loanId: s.loan.id, parentId: e.id, voidedEvidenceId: s.voidedEvidenceId,
|
|
fields: (amount, incoming) => fields(s, amount, incoming), upload: kind => upload(s.batch.id, kind),
|
|
review: (event, status = 200, body = {}) => review(s, event, independent, body, status), apply: (event, status = 200) => apply(s, event, status), prefix: 'independent' }));
|
|
assert.equal((await balances(s)).outstanding, '0.00');
|
|
}
|
|
checks.push('s6_s7_overpayment_is_difference_partial_return_keeps_frozen_full_return_closes');
|
|
{
|
|
const s = await setup(); const e = await propose(s);
|
|
await review(s, e, independent, { decision: 'reject' }, 409);
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
|
const correctionEvidenceId = await upload(s.batch.id, 'bank_terminal');
|
|
await review(s, e, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId });
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
|
|
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event_id: e.id } }), 0);
|
|
assert.equal((await balances(s)).outstanding, '100.00');
|
|
}
|
|
checks.push('s5_false_alarm_requires_evidenced_independent_dismissal_uncertainty_stays_frozen');
|
|
console.log(JSON.stringify({ stage: "s6_s7", status: "PASS", checks }));
|
|
{
|
|
const s = await setup();
|
|
const paid = await repay(s.lender.id, s.loan.id, '100.00');
|
|
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
|
|
const paidRef = `FULL-${randomUUID()}`;
|
|
await h.receipt(paid.id, '10000', paidRef, { payeeAccount: s.lender.bankAccount });
|
|
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
|
|
const event = await propose(s); await review(s, event); await apply(s, event);
|
|
const row = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: event.id } });
|
|
assert.equal(row.settled_amount.toFixed(2), '0.00'); assert.equal(row.receivable_amount.toFixed(2), '80.00');
|
|
assert.equal(row.bank_amount.toFixed(2), '80.00');
|
|
// Same transaction as an ordinary executed payment must be rejected.
|
|
const reused = await propose(s, 'late_success', undefined, '80.00', paidRef);
|
|
await review(s, reused, independent, {}, 409);
|
|
// The review-only correction exit does not erase the existing receivable cause.
|
|
const correctionEvidenceId = await upload(s.batch.id, 'bank_terminal');
|
|
await review(s, reused, independent, { decision: 'reject', correctionKind: 'duplicate', correctionEvidenceId });
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
|
}
|
|
checks.push('s6_full_excess_retains_bank_amount_and_shared_transaction_identity_blocks_reuse');
|
|
{
|
|
const s = await setup(); const event = await propose(s); await review(s, event);
|
|
// Late factual payment must use original account snapshots even after legal
|
|
// master-data changes, and a departed original approver cannot strand it.
|
|
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
|
|
try { await apply(s, event); } finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
|
|
assert.equal((await balances(s)).outstanding, '20.00');
|
|
}
|
|
checks.push('s6_original_approver_departure_does_not_block_bank_fact_booking');
|
|
{
|
|
const s = await setup(); const event = await propose(s); await review(s, event);
|
|
await pool.query(`CREATE FUNCTION synthetic_bank_result_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
|
IF NEW.action='LOAN_LATE_SUCCESS_BOOKED' THEN RAISE EXCEPTION 'synthetic late audit failure'; END IF; RETURN NEW; END $$`);
|
|
await pool.query('CREATE TRIGGER synthetic_bank_result_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_bank_result_audit_failure()');
|
|
try { await apply(s, event, 500); }
|
|
finally { await pool.query('DROP TRIGGER synthetic_bank_result_audit_failure ON audit_logs'); await pool.query('DROP FUNCTION synthetic_bank_result_audit_failure()'); }
|
|
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event_id: event.id } }), 0);
|
|
assert.equal((await balances(s)).outstanding, '100.00');
|
|
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: event.id } })).status, 'approved');
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
|
await apply(s, event);
|
|
}
|
|
checks.push('s6_bookkeeping_audit_failure_rolls_back_amounts_status_and_freezes');
|
|
const { Pool } = req('pg'); const lockPool = new Pool({ ...pool.options, max: 3 });
|
|
async function waiters(n) {
|
|
for (let i = 0; i < 300; i++) {
|
|
const result = await lockPool.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type='Lock' AND datname=current_database()");
|
|
if (result.rows[0].n >= n) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
throw new Error('bank result requests did not queue on lender');
|
|
}
|
|
try {
|
|
for (const freezeFirst of [false, true]) {
|
|
const s = await setup(); const next = await repay(s.lender.id, s.loan.id, '10.00');
|
|
await call(manager, 'POST', `/payment-batches/${next.id}/review`, {});
|
|
const evidenceId = await upload(s.batch.id, 'late_success');
|
|
const holder = await lockPool.connect();
|
|
const freeze = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-results`, headers: finance.headers,
|
|
payload: { kind: 'late_success', evidenceId, requestKey: randomUUID(), fields: fields(s), attributionNote: '合成确定性交错银行归属说明', exclusionNote: '合成确定性交错排除其他付款批次' } });
|
|
const exporting = () => app.inject({ method: 'POST', url: `/api/payment-batches/${next.id}/payment-order`, headers: finance.headers, payload: {} });
|
|
let first, second;
|
|
try {
|
|
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
|
|
first = freezeFirst ? freeze() : exporting(); await waiters(1);
|
|
second = freezeFirst ? exporting() : freeze(); await waiters(2);
|
|
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
|
|
} finally { await holder.query('ROLLBACK'); holder.release(); }
|
|
assert.equal(first.statusCode, 200); assert.equal(second.statusCode, freezeFirst ? 409 : 200);
|
|
assert.equal(await prisma.paymentOrderExport.count({ where: { payment_batch_id: next.id } }), freezeFirst ? 0 : 1);
|
|
}
|
|
for (const bookFirst of [false, true]) {
|
|
const s = await setup(); const event = await propose(s); await review(s, event);
|
|
const holder = await lockPool.connect();
|
|
const book = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-results/${event.id}/apply`, headers: finance.headers, payload: {} });
|
|
const request = () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers,
|
|
payload: { lenderId: s.lender.id, reason: '合成并发超额申请', requestKey: randomUUID(), allocations: [{ loanId: s.loan.id, amount: '30.00' }] } });
|
|
let first, second;
|
|
try {
|
|
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
|
|
first = bookFirst ? book() : request(); await waiters(1);
|
|
second = bookFirst ? request() : book(); await waiters(2);
|
|
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
|
|
} finally { await holder.query('ROLLBACK'); holder.release(); }
|
|
assert.equal(first.statusCode, bookFirst ? 200 : 409); assert.equal(second.statusCode, bookFirst ? 409 : 200);
|
|
assert.equal((await balances(s)).outstanding, '20.00'); assert.equal((await balances(s)).reserved, '0.00');
|
|
}
|
|
} finally { await lockPool.end(); }
|
|
checks.push('s5_s6_freeze_vs_export_and_supplement_vs_new_request_deterministic_both_orders');
|
|
{
|
|
const s = await setup(false); const ref = `REPLACE-${randomUUID()}`;
|
|
const old = await propose(s, 'late_success', undefined, '80.00', ref);
|
|
const oldRow = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } });
|
|
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${oldRow.evidence_id}/void`, { reason: '合成待审材料换版' });
|
|
await review(s, old, independent, {}, 409);
|
|
const fresh = await propose(s, 'late_success', undefined, '80.00', ref);
|
|
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } })).status, 'superseded');
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
|
await review(s, fresh);
|
|
const current = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: fresh.id } });
|
|
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${current.evidence_id}/void`, { reason: '合成已审事实不可作废' }, 409, 'loan.bank_result_reconcile_required');
|
|
await apply(s, fresh);
|
|
}
|
|
checks.push('s6_pending_evidence_replaced_without_unfreeze_reviewed_bank_fact_evidence_retained');
|
|
{
|
|
const s = await setup(); const otherLoan = await confirmedLoan(s.lender.id, '100.00', '合成多借款补记第二笔');
|
|
const combined = (await call(finance, 'POST', '/loan-repayments', { lenderId: s.lender.id, reason: '合成跨原借款逐笔补记', requestKey: randomUUID(),
|
|
allocations: [{ loanId: s.loan.id, amount: '20.00' }, { loanId: otherLoan.id, amount: '40.00' }] })).item;
|
|
await call(manager, 'POST', `/payment-batches/${combined.id}/review`, {}); await exportOrder(combined.id);
|
|
const exp = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: combined.id } });
|
|
const submission = await register(combined.id, `COMBINED-${randomUUID()}`, exp.id); await attest(combined.id);
|
|
const evidenceId = await uploadEvidence(combined.id);
|
|
const fact = await proposeFact(combined.id, { submissionId: submission.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
|
|
await reviewFact(combined.id, fact.id); await confirmNotPaid(combined.id);
|
|
await call(finance, 'POST', `/payment-batches/${combined.id}/cancel`, { reason: '合成多借款原结论取消' });
|
|
for (const [loanId, amount] of [[s.loan.id, '90.00'], [otherLoan.id, '70.00']]) {
|
|
const paid = await repay(s.lender.id, loanId, amount);
|
|
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
|
|
await h.receipt(paid.id, String(Number(amount) * 100), `MULTI-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
|
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
|
|
}
|
|
const combinedSource = { ...s, batch: combined }; const event = await propose(combinedSource, 'late_success', undefined, '60.00');
|
|
await review(combinedSource, event); await apply(combinedSource, event);
|
|
const rows = await prisma.loanSupplementalSettlement.findMany({ where: { event_id: event.id } });
|
|
assert.equal(rows.length, 2);
|
|
const a = rows.find((row) => row.loan_id === s.loan.id); const b = rows.find((row) => row.loan_id === otherLoan.id);
|
|
assert.deepEqual([a.settled_amount.toFixed(2), a.receivable_amount.toFixed(2)], ['10.00', '10.00']);
|
|
assert.deepEqual([b.settled_amount.toFixed(2), b.receivable_amount.toFixed(2)], ['30.00', '10.00']);
|
|
assert.equal((await balances(s)).outstanding, '0.00');
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${otherLoan.id}`)).outstanding, '0.00');
|
|
}
|
|
checks.push('s6_multiple_original_loans_settle_each_difference_without_cross_loan_offset');
|
|
{
|
|
const s = await setup(); const competitor = await repay(s.lender.id, s.loan.id, '80.00');
|
|
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
|
|
const ref = `STALE-APPROVED-${randomUUID()}`;
|
|
const old = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, old);
|
|
const submitted = await register(competitor.id, `NEW-CANDIDATE-${randomUUID()}`, null);
|
|
await apply(s, old, 409);
|
|
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: old.id } }), 0, 'review does not consume a transaction');
|
|
await attest(competitor.id); const evidenceId = await uploadEvidence(competitor.id);
|
|
const fact = await proposeFact(competitor.id, { submissionId: submitted.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
|
|
await reviewFact(competitor.id, fact.id); await confirmNotPaid(competitor.id);
|
|
await call(finance, 'POST', `/payment-batches/${competitor.id}/cancel`, { reason: '合成其他候选已确认终止' });
|
|
const fresh = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, fresh); await apply(s, fresh);
|
|
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } })).status, 'superseded');
|
|
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: fresh.id } }), 1);
|
|
assert.equal((await balances(s)).outstanding, '20.00');
|
|
}
|
|
checks.push('s6_approved_attribution_stales_on_new_candidate_reregistration_rechecks_then_claims_once');
|
|
for (const dismiss of [false, true]) {
|
|
const s = await setup(); const conflict = await repay(s.lender.id, s.loan.id, '70.00');
|
|
const ref = `REPLACE-CONFLICT-${randomUUID()}`;
|
|
const old = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, old);
|
|
assert.equal((await apply(s, old)).conflict, true);
|
|
const fresh = await propose(s, 'late_success', undefined, '80.00', ref);
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { source_key: old.id, status: 'active' } }), 0);
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { source_key: fresh.id, status: 'active' } }), 2);
|
|
if (dismiss) {
|
|
await review(s, fresh, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: await upload(s.batch.id, 'bank_terminal') });
|
|
assert.equal((await balances(s)).outstanding, '100.00');
|
|
} else {
|
|
await review(s, fresh);
|
|
await call(finance, 'POST', `/payment-batches/${conflict.id}/cancel`, { reason: '合成解除其他申请占用' });
|
|
await apply(s, fresh); assert.equal((await balances(s)).outstanding, '20.00');
|
|
}
|
|
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
|
|
}
|
|
checks.push('s6_replacement_transfers_conflict_freeze_and_booking_or_evidenced_dismissal_closes_it');
|
|
{
|
|
const s = await setup(false); const event = await propose(s);
|
|
const correction = await upload(s.batch.id, 'bank_terminal');
|
|
await review(s, event, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: correction });
|
|
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${correction}/void`, { reason: '合成不得撤销已消费的误报更正依据' }, 409, 'loan.bank_result_reconcile_required');
|
|
await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: correction }, data: { voided_at: new Date(), voided_by: finance.id, void_reason: '合成数据库旁路拒绝' } }));
|
|
assert.equal((await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: correction } })).voided_at, null);
|
|
assert.equal((await balances(s)).reserved, '80.00');
|
|
}
|
|
checks.push('s5_consumed_false_alarm_correction_cannot_be_voided_in_service_or_database');
|
|
return checks;
|
|
};
|
|
module.exports.verifyReturnGuards = verifyReturnGuards;
|