文件
hyb-oa/scripts/qa/company-loan-related-fixture.cjs
T
wangsli和Claude Opus 5.5 45737ccb9a
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号
- 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE
- 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效
- 电脑/手机共用对话框,写请求在卸载或身份切换时取消
- 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 21:03:07 +08:00

582 行
48 KiB
JavaScript

const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
// Two real authenticated employees perform every related-party action. Other
// actors are used only for refusal tests; all documents and accounts are synthetic.
module.exports = async function relatedFixture(h) {
const { prisma, pool, app, req, call, finance, manager, ordinary, independent, config,
verifiedLender, confirmedLoan, repay, exportOrder, receipt } = h;
const checks = [];
const base = b => `/payment-batches/${b.id}`;
const approve = (b, who = manager, status = 200) => call(who, 'POST', `${base(b)}/review`, {}, status);
const cancel = (b, extra = {}, status = 200, reason) => call(finance, 'POST', `${base(b)}/cancel`, { reason: '合成有依据释放占用', ...extra }, status, reason);
const balance = s => call(finance, 'GET', `/company-loans/${s.loan.id}`);
const action = (a, name, who = finance, status = 200) => call(who, 'POST', `/loan-related-authorizations/${a.id}/actions`, { action: name, reason: '合成公司授权范围确认或停止' }, status);
async function authorization(lender, loans, confirm = true) {
const body = { lenderId: lender.id, loanIds: loans.map(l => l.id), basis: '合成已确认的公司代表权及指定借款范围', requestKey: randomUUID() };
const a = await call(finance, 'POST', '/loan-related-authorizations', body);
assert.equal((await call(finance, 'POST', '/loan-related-authorizations', body)).id, a.id);
if (confirm) await action(a, 'confirm', manager);
return a;
}
async function setup(principal = '500.00', confirm = true) {
const lender = await verifiedLender(`合成关联方${randomUUID().slice(0, 6)}`, manager.id, '6');
const loan = await confirmedLoan(lender.id, principal, `合成关联借入${randomUUID().slice(0, 6)}`);
const auth = confirm === null ? null : await authorization(lender, [loan], confirm);
return { lender, loan, auth };
}
async function batch(s, amount = '10.00', exported = true) {
const b = await repay(s.lender.id, s.loan.id, amount);
assert.equal(b.loanRepayment.approvalMode, 'related_party');
await approve(b); if (exported) await exportOrder(b.id);
return b;
}
const fields = (s, amount, ref, incoming = false) => [
{ Name: '付款账号', Value: incoming ? s.lender.bankAccount : '8'.repeat(16) },
{ Name: '收款账号', Value: incoming ? '8'.repeat(16) : s.lender.bankAccount },
{ Name: '金额', Value: amount }, { Name: '币种', Value: 'CNY' },
{ Name: '交易状态', Value: '交易成功' }, { Name: '流水号', Value: ref },
];
const pdf = async () => { const doc = await req('pdf-lib').PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]); return Buffer.from(await doc.save()); };
async function evidence(b, kind = 'bank_terminal', who = finance, status = 200) {
const boundary = `synthetic-${randomUUID()}`;
const res = await app.inject({ method: 'POST', url: `/api${base(b)}/loan-terminal-evidences?kind=${kind}`,
headers: { ...who.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`), await pdf(), Buffer.from(`\r\n--${boundary}--\r\n`)]) });
assert.equal(res.statusCode, status, `related evidence: ${res.statusCode}`);
return res.json().data?.id;
}
async function submission(b, ref = `RP-${randomUUID()}`, exported = null) {
const exp = exported ?? await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: b.id }, orderBy: { sequence: 'desc' } });
return call(finance, 'POST', `${base(b)}/loan-bank-submissions`, { channel: exp ? 'online_banking_import' : 'manual_entry',
...(exp ? { exportId: exp.id } : {}), bankInstructionRef: ref, submittedAt: new Date().toISOString(), note: '合成实际向银行提交的指令' });
}
async function attest(b) {
const p = await call(finance, 'POST', `${base(b)}/loan-submission-attestation`, { note: '合成逐笔核对全部实际银行提交' });
await call(manager, 'POST', `${base(b)}/loan-submission-attestation/${p.id}/review`, { decision: 'approve', reason: '合成公司代表确认清单完整' });
return p;
}
async function fact(b, sub, input = {}) {
return call(finance, 'POST', `${base(b)}/loan-terminal-facts`, { submissionId: sub.id, kind: 'termination_evidence',
evidenceId: await evidence(b), terminalStatus: 'failed', note: '合成银行明确该指令失败且不会再执行', ...input });
}
const review = (b, p, type = 'loan-terminal-facts', status = 200, who = manager, more = {}) => call(who, 'POST', `${base(b)}/${type}/${p.id}/review`, { decision: 'approve', reason: '合成公司代表按原件确认银行事实', ...more }, status);
const notPaid = (b, status = 200, reason) => call(manager, 'POST', `${base(b)}/loan-not-paid/review`, {}, status, reason);
async function terminate(s, b, release = false) {
const sub = await submission(b); await attest(b); const f = await fact(b, sub); await review(b, f); await notPaid(b);
if (release) await cancel(b);
return { sub, f, evidenceId: (await prisma.loanBankTerminalFact.findUniqueOrThrow({ where: { id: f.id } })).evidence_id };
}
async function attribution(b, extra = {}) {
return call(finance, 'POST', `${base(b)}/loan-receipt-attribution`, { basisKind: 'bank_written_confirmation',
basisNote: '合成银行查询唯一定位此交易对应本批次', exclusionNote: '合成逐笔排除其余同金额批次的付款指令', ...extra });
}
async function bankEvent(s, b, kind = 'late_success', amount = '80.00', parentId, ref = `RP-EVENT-${randomUUID()}`) {
return call(finance, 'POST', `${base(b)}/loan-bank-results`, { kind, ...(parentId ? { parentId } : {}),
evidenceId: await evidence(b, kind === 'return' ? 'incoming_return' : 'late_success'), requestKey: randomUUID(),
fields: fields(s, amount, ref, kind === 'return'), attributionNote: '合成原件明确对应原批次银行付款', exclusionNote: '合成逐笔排除其他所有相同金额候选' });
}
const apply = (b, e, status = 200) => call(finance, 'POST', `${base(b)}/loan-bank-results/${e.id}/apply`, {}, status);
// Deliberately corrupt only this isolated fixture's decision metadata to
// verify the final consumer independently of the DB write guard. Always
// restore both the original context and trigger before leaving the scenario.
async function damagedDecision(table, id, check) {
assert.ok(['payment_batches', 'payment_receipt_manual_proposals'].includes(table));
const original = (await pool.query(`SELECT loan_approval_context FROM ${table} WHERE id=$1`, [id])).rows[0].loan_approval_context;
async function put(context) {
const tx = await pool.connect();
try {
await tx.query('BEGIN'); await tx.query(`ALTER TABLE ${table} DISABLE TRIGGER loan_decision_context`);
await tx.query(`UPDATE ${table} SET loan_approval_context=$1 WHERE id=$2`, [JSON.stringify(context), id]);
await tx.query('SET CONSTRAINTS ALL IMMEDIATE');
await tx.query(`ALTER TABLE ${table} ENABLE TRIGGER loan_decision_context`); await tx.query('COMMIT');
} catch (e) { await tx.query('ROLLBACK'); throw e; } finally { tx.release(); }
}
await put({ ...original, authorizationEventId: 'damaged-synthetic-event' });
try { await check(); } finally { await put(original); }
}
async function legacyVerification(attachmentId) {
const last = await prisma.paymentReceiptVerification.findFirstOrThrow({ where: { attachment_id: attachmentId },
include: { matches: true }, orderBy: { created_at: 'desc' } });
const crypto = req('./src/services/crypto');
const old = JSON.parse(crypto.decryptStringOrThrow(config, last.normalized_encrypted));
delete old.documentPages; for (const page of old.pages) delete page.document;
// Seed an old-policy record, preserving every existing immutable record.
return prisma.paymentReceiptVerification.create({ data: { attachment_id: last.attachment_id, source_sha256: last.source_sha256,
source_version: last.source_version, status: last.status, pages_expected: last.pages_expected, pages_processed: last.pages_processed,
normalized_encrypted: crypto.encryptStringOrThrow(config, JSON.stringify(old)),
...(last.matches.length ? { matches: { create: last.matches.map(({ page_number, item_id, fingerprint, key_id }) =>
({ page_number, item_id, fingerprint, key_id })) } } : {}) } });
}
const originalWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 0;
const ui = [];
try {
if (process.env.RUN_ISOLATED_LOAN_RELATED_UI_ONLY !== '1') {
{
const s = await setup('100.00', null);
const old = await repay(s.lender.id, s.loan.id, '1.00');
assert.equal(old.loanRepayment.approvalMode, 'independent');
const a = await authorization(s.lender, [s.loan], false);
await action(a, 'confirm', finance, 403); await action(a, 'confirm', ordinary, 403);
await approve(old, manager, 403);
await action(a, 'confirm', manager);
await approve(old, manager, 403); await approve(old, independent); await cancel(old);
const outside = await confirmedLoan(s.lender.id, '10.00', `RP-OUT-${randomUUID()}`);
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成范围外申请', allocations: [{ loanId: outside.id, amount: '1.00' }] }, 409, 'loan.related_scope_invalid');
const other = await setup();
await call(finance, 'POST', '/loan-related-authorizations', { lenderId: s.lender.id, loanIds: [other.loan.id], basis: '合成不得跨出借人混入范围', requestKey: randomUUID() }, 409);
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成客户端伪造模式', approvalMode: 'related_party', allocations: [{ loanId: s.loan.id, amount: '1.00' }] }, 400);
const b = await batch(s, '2.00', false);
await call(ordinary, 'GET', `/loan-related-authorizations/${a.id}`, undefined, 403);
await call(independent, 'GET', `/loan-related-authorizations/${a.id}`, undefined, 403);
await call(manager, 'GET', '/company-loans', undefined, 403);
await call(ordinary, 'GET', base(b), undefined, 403);
const scoped = await call(manager, 'GET', `/loan-related-authorizations/${a.id}`);
assert.deepEqual(scoped.loans.map(l => l.id), [s.loan.id]);
assert.ok(!JSON.stringify(scoped).includes(s.lender.bankAccount));
await call(manager, 'POST', `/company-loans/${outside.id}/evidences/${outside.evidences?.[0]?.id ?? randomUUID()}/download`, { reason: '合成范围外材料', authorizationId: a.id }, 404);
await assert.rejects(pool.query("UPDATE loan_repayments SET approval_mode='related_party',authorization_id=$1,approval_context=$2 WHERE id=$3", [a.id, JSON.stringify(b.loanRepayment), old.loanRepaymentId]), e => e.code === '23514');
await assert.rejects(pool.query('UPDATE loan_related_authorizations SET basis=$1 WHERE id=$2', ['合成禁止改写授权', a.id]), e => e.code === '23514');
await assert.rejects(pool.query('DELETE FROM loan_related_authorization_events WHERE authorization_id=$1', [a.id]), e => e.code === '23514');
await assert.rejects(pool.query('INSERT INTO loan_related_authorization_loans(authorization_id,loan_id) VALUES($1,$2)', [a.id, outside.id]), e => e.code === '23514');
const stored = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } });
assert.equal(stored.loan_approval_mode, 'related_party'); assert.equal(stored.loan_authorization_id, a.id);
await assert.rejects(pool.query("UPDATE loan_repayments SET approval_context=jsonb_set(approval_context,'{authorizationVersion}','99') WHERE id=$1", [b.loanRepaymentId]), e => e.code === '23514');
await assert.rejects(pool.query('UPDATE payment_batches SET reviewed_by=$1 WHERE id=$2', [finance.id, b.id]), e => e.code === '23514');
await assert.rejects(pool.query("UPDATE payment_batches SET loan_approval_context=jsonb_set(loan_approval_context,'{purpose}','\"reconcile\"') WHERE id=$1", [b.id]), e => e.code === '23514');
await cancel(b);
}
checks.push('rp_scope_activation_exact_identity_old_mode_immutable_and_scoped_reads');
{
const s = await setup('100.00', false);
await pool.query(`CREATE FUNCTION synthetic_related_authority_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF NEW.action='LOAN_RELATED_AUTHORIZATION_DECIDED' THEN RAISE EXCEPTION 'synthetic authority audit failure'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_related_authority_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_related_authority_audit_failure()');
try { await action(s.auth, 'confirm', manager, 500); }
finally {
await pool.query('DROP TRIGGER synthetic_related_authority_audit_failure ON audit_logs');
await pool.query('DROP FUNCTION synthetic_related_authority_audit_failure()');
}
assert.equal(await prisma.loanRelatedAuthorizationEvent.count({ where: { authorization_id: s.auth.id } }), 0);
await action(s.auth, 'confirm', manager); const b = await batch(s, '1.00', false); await cancel(b);
}
checks.push('rp_authority_confirmation_audit_failure_rolls_back_activation');
{
const s = await setup(); const oldBatch = await batch(s, '2.00', false);
const renewed = await authorization(s.lender, [s.loan]);
await action(s.auth, 'stop_payment', finance, 409); await action(s.auth, 'stop_reconcile', manager, 409);
assert.equal((await call(manager, 'GET', `/loan-related-authorizations/${renewed.id}`)).paymentEnabled, true);
await assert.rejects(pool.query("INSERT INTO loan_related_authorization_events(id,authorization_id,sequence,action,payment_enabled,reconcile_enabled,actor_id,reason) VALUES($1,$2,2,'stop_payment',false,true,$3,'合成旧授权不得假装停止当前范围')", [randomUUID(), s.auth.id, finance.id]), e => e.code === '23514');
const currentBatch = await batch(s, '3.00', false); const pending = await repay(s.lender.id, s.loan.id, '1.00');
const context = (await prisma.paymentBatch.findUniqueOrThrow({ where: { id: currentBatch.id } })).loan_approval_context;
const forged = { ...context, targetId: pending.id, batchId: pending.id, repaymentId: pending.loanRepaymentId, approverId: finance.id };
await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed',reviewed_by=$1,reviewed_at=now(),loan_approval_mode='related_party',loan_authorization_id=$2,loan_approval_context=$3 WHERE id=$4", [manager.id, renewed.id, JSON.stringify(forged), pending.id]), e => e.code === '23514');
assert.equal((await prisma.paymentBatch.findUniqueOrThrow({ where: { id: oldBatch.id } })).loan_authorization_id, s.auth.id);
await cancel(pending); await cancel(currentBatch); await cancel(oldBatch);
}
checks.push('rp_renewed_scope_rejects_stale_stop_and_forged_first_decision_actor');
{
const s = await setup(); const b = await repay(s.lender.id, s.loan.id, '9.00');
const endpoint = `${base(b)}/review`;
await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed',reviewed_by=$1,reviewed_at=now() WHERE id=$2", [manager.id, b.id]), e => e.code === '23514');
await call(finance, 'POST', endpoint, { decision: 'reject', comment: '合成经办人不得代替公司代表退回' }, 403);
await call(manager, 'POST', endpoint, { decision: 'reject' }, 400);
const before = await balance(s);
await call(manager, 'POST', endpoint, { decision: 'reject', comment: '合成付款金额需更正,退回原申请' });
const returned = (await call(finance, 'GET', base(b))).item;
assert.equal(returned.status, 'draft'); assert.equal(returned.reviewRequirement.nextStep, 'returned');
assert.equal(returned.relatedPaymentDecision, 'reject');
assert.equal(returned.processingProgress.stages[1].result, '已退回');
assert.equal(returned.processingProgress.stages[2].result, '待财务处理');
assert.equal((await balance(s)).outstanding, before.outstanding); assert.equal((await balance(s)).reserved, before.reserved);
await approve(b, manager, 409);
await assert.rejects(pool.query("UPDATE payment_batches SET status='reviewed' WHERE id=$1", [b.id]), e => e.code === '23514');
await assert.rejects(pool.query("UPDATE payment_batches SET review_comment='合成篡改退回理由' WHERE id=$1", [b.id]), e => e.code === '23514');
const deniedExport = await app.inject({ method: 'POST', url: `/api${base(b)}/payment-order`, headers: finance.headers, payload: {} });
assert.ok(deniedExport.statusCode >= 400 && deniedExport.statusCode < 500);
assert.equal(await prisma.paymentOrderExport.count({ where: { payment_batch_id: b.id } }), 0);
const notified = await prisma.notification.findMany({ where: { target_id: b.id, type: 'payment_batch.returned' } });
assert.deepEqual(notified.map(n => n.recipient_employee_id), [finance.id]);
await cancel(b); assert.equal((await balance(s)).reserved, '0.00');
assert.equal((await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } })).loan_approval_context.decision, 'reject');
const replacement = await batch(s, '8.00', false); assert.notEqual(replacement.id, b.id); await cancel(replacement);
}
checks.push('rp_return_keeps_reservation_immutable_reason_and_requires_finance_cancel_before_resubmit');
{
const s = await setup(); const b = await batch(s);
const detail = (await call(manager, 'GET', base(b))).item;
assert.equal(detail.loanRepayment.actionCapabilities.canPrepare, false);
assert.equal(detail.loanRepayment.actionCapabilities.canReviewReconciliation, true);
assert.equal(detail.processingProgress.stages[1].label, '公司代表关联方确认');
const notifications = await prisma.notification.findMany({ where: { target_id: b.id, type: 'payment_batch.review_requested' } });
assert.deepEqual(notifications.map(n => n.recipient_employee_id), [manager.id]);
assert.ok(notifications[0].title.includes('关联方'));
const audits = await prisma.auditLog.findMany({ where: { target_id: b.id, action: 'PAYMENT_BATCH_REVIEWED' } });
assert.ok(JSON.stringify(audits).includes(s.auth.id));
await action(s.auth, 'stop_payment');
await exportOrder(b.id, 409, 'loan.related_authorization_required');
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
try {
const rr = await receipt(b.id, '1000', `RP-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
assert.equal(rr.result.status, 'matched'); await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await balance(s)).outstanding, '490.00');
} finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
await call(finance, 'POST', `/loan-repayments/${b.loanRepaymentId}/accounting-handoff`, { reference: '合成已付会计交接' });
}
checks.push('rp_two_person_ocr_settlement_survives_payment_stop_and_historical_departure');
{
const s = await setup(); const b = await batch(s);
await receipt(b.id, '1000', `RP-DAMAGED-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
const before = await balance(s);
await damagedDecision('payment_batches', b.id, async () => {
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
const after = await balance(s); assert.equal(after.outstanding, before.outstanding); assert.equal(after.reserved, before.reserved);
});
await call(finance, 'POST', `${base(b)}/execute`, {});
}
checks.push('rp_final_settlement_rejects_corrupt_approval_provenance_without_money_change');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, null, `RP-MANUAL-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
assert.equal(rr.result.status, 'needs_review');
const attachment = await prisma.paymentBatchReceiptAttachment.findUniqueOrThrow({ where: { id: rr.attachment.id } });
const endpoint = `${base(b)}/receipt-attachments/${attachment.id}/manual-verification`;
const p = (await call(finance, 'POST', endpoint, { expectedVersion: attachment.receipt_ocr_version, reason: '合成原件金额清晰人工录入',
pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: [{ Name: '小写金额', Value: '10.00' }] }] })).item;
const d = (await call(manager, 'GET', endpoint)).item;
assert.equal(d.canReview, true); assert.equal(d.proposal.approvalMode, 'related_party');
await call(manager, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成代表公司核对原件金额' });
await damagedDecision('payment_receipt_manual_proposals', p.id, async () => {
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
assert.equal((await balance(s)).outstanding, '500.00'); assert.equal((await balance(s)).reserved, '10.00');
});
await action(s.auth, 'stop_payment'); await action(s.auth, 'stop_reconcile');
// Cold detail/readiness and final consumer must rebuild the saved evidence
// from its actual authorization, not apply independent-payee rules again.
assert.equal((await call(finance, 'GET', `${base(b)}/receipt-readiness`)).item.ready, true);
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await balance(s)).outstanding, '490.00');
const stored = await prisma.paymentReceiptManualProposal.findUniqueOrThrow({ where: { id: p.id } });
assert.equal(stored.loan_approval_mode, 'related_party');
}
checks.push('rp_manual_receipt_decision_cold_reconstruction_and_historical_consumption');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-NONFINAL-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }, { document: { final: false } });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
assert.equal(d.pages[0].document.kind, 'non_final');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成不得覆盖非最终说明', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'explicit_success', originalChecked: true, note: '合成强行声明仍然必须被拦截' },
}] }, 409, 'payment_batch.receipt_final_evidence_required');
assert.equal((await balance(s)).outstanding, '500.00'); assert.equal((await balance(s)).reserved, '10.00');
}
checks.push('receipt_nonfinal_fields_match_but_final_settlement_and_manual_override_rejected');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-BOOKED-${randomUUID()}`, { payeeAccount: s.lender.bankAccount, transactionStatus: null });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
assert.equal(d.pages[0].document.kind, 'bank_receipt'); assert.equal(d.pages[0].transactionStatus, null);
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成无状态也无记账依据',
pages: [{ pageNumber: 1, itemId: b.items[0].id }] }, 409, 'payment_batch.receipt_final_evidence_required');
const p = (await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成原件列明公司借方记账信息,未写成功字样', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'booked_debit', originalChecked: true,
bookedOn: '2026-10-01', note: '合成正式回单记载公司付款账户借方已记账支出' },
}] })).item;
const original = (await call(manager, 'GET', endpoint)).item;
assert.equal(original.pages[0].transactionStatus, null);
assert.equal(original.proposal.pages[0].document.proof.basis, 'booked_debit');
await call(finance, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成自审被拒' }, 403);
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(manager, 'POST', `${endpoint}/${p.id}/review`, { expectedVersion: p.version, decision: 'approve', reason: '合成代表公司核对完整原件及已记账依据' });
assert.equal((await call(finance, 'GET', `${base(b)}/receipt-readiness`)).item.ready, true);
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await balance(s)).outstanding, '490.00'); assert.equal((await balance(s)).reserved, '0.00');
}
checks.push('receipt_booked_debit_without_invented_status_two_person_review_and_final_settlement');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-CREDIT-${randomUUID()}`, { payeeAccount: s.lender.bankAccount }, { document: { direction: 'credit' } });
assert.equal(rr.result.status, 'needs_review');
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}/manual-verification`;
const d = (await call(finance, 'GET', endpoint)).item;
await call(finance, 'POST', endpoint, { expectedVersion: d.version, reason: '合成借入回单不能用于还本', pages: [{
pageNumber: 1, itemId: b.items[0].id, paymentProof: { basis: 'explicit_success', originalChecked: true, note: '合成方向矛盾必须拒绝' },
}] }, 409, 'payment_batch.receipt_final_evidence_required');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
assert.equal((await balance(s)).outstanding, '500.00');
}
checks.push('receipt_incoming_direction_cannot_settle_outgoing_principal');
{
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-LEGACY-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id);
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}`;
const d = (await call(finance, 'GET', `${endpoint}/manual-verification`)).item;
assert.equal(d.documentInspectionRequired, true);
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
await call(manager, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 403);
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version - 1 }, 409);
const verificationCount = await prisma.paymentReceiptVerification.count({ where: { attachment_id: rr.attachment.id } });
await pool.query(`CREATE FUNCTION synthetic_document_audit_fail() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action='PAYMENT_RECEIPT_DOCUMENT_CHECKED' THEN RAISE EXCEPTION 'synthetic document audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_document_audit_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_document_audit_fail()');
try {
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 500);
assert.equal((await prisma.paymentBatchReceiptAttachment.findUniqueOrThrow({ where: { id: rr.attachment.id } })).receipt_ocr_version, d.version);
assert.equal(await prisma.paymentReceiptVerification.count({ where: { attachment_id: rr.attachment.id } }), verificationCount);
assert.equal((await balance(s)).reserved, '10.00');
} finally {
await pool.query('DROP TRIGGER synthetic_document_audit_fail ON audit_logs');
await pool.query('DROP FUNCTION synthetic_document_audit_fail()');
}
const checked = (await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version })).item;
assert.equal(checked.version, d.version + 1);
assert.equal((await call(finance, 'GET', `${endpoint}/manual-verification`)).item.documentInspectionRequired, false);
await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: d.version }, 409);
assert.ok(await prisma.auditLog.findFirst({ where: { target_id: b.id, action: 'PAYMENT_RECEIPT_DOCUMENT_CHECKED' } }));
assert.equal((await prisma.paymentReceiptVerification.findUniqueOrThrow({ where: { id: old.id } })).normalized_encrypted, old.normalized_encrypted);
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await balance(s)).outstanding, '490.00');
}
checks.push('receipt_legacy_matched_requires_versioned_local_inspection_without_cloud_retransmission');
checks.push('receipt_local_inspection_audit_failure_rolls_back_new_version_and_evidence');
{
const s = await setup(); const b = await batch(s); const reference = `RP-OLD-MANUAL-${randomUUID()}`;
const rr = await receipt(b.id, null, reference, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id); const id = randomUUID();
const crypto = req('./src/services/crypto');
const oldFields = Object.entries({ 小写金额: '10.00', 币别: '人民币', 付款人账号: '8'.repeat(16),
收款人账号: s.lender.bankAccount, 凭证号: reference, 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value }));
// Represent a historically approved proposal without new document fields;
// current HTTP approval must never manufacture this obsolete evidence.
const saved = await prisma.$transaction(async tx => {
const storedBatch = await tx.paymentBatch.findUniqueOrThrow({ where: { id: b.id }, include: { items: true } });
const context = await req('./src/services/loanApprovalPolicy').decisionFields(tx, storedBatch, manager.id,
{ targetId: id, conflictIds: [finance.id] });
const p = await tx.paymentReceiptManualProposal.create({ data: { id, attachment_id: rr.attachment.id,
original_verification_id: old.id, source_sha256: old.source_sha256, source_version: old.source_version + 1,
status: 'approved', proposed_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), ...context,
payload_encrypted: crypto.encryptStringOrThrow(config, JSON.stringify({ pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: oldFields }],
reason: '合成旧策略人工核验', changedFields: [{ pageNumber: 1, fields: ['amount'] }] })),
review_note_encrypted: crypto.encryptStringOrThrow(config, '合成旧策略公司确认') } });
await tx.paymentBatchReceiptAttachment.update({ where: { id: rr.attachment.id },
data: { receipt_ocr_version: old.source_version + 1, receipt_ocr_state: 'manual_approved' } });
return p;
});
const endpoint = `${base(b)}/receipt-attachments/${rr.attachment.id}`;
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
assert.equal((await balance(s)).outstanding, '500.00');
assert.equal((await call(finance, 'GET', `${endpoint}/manual-verification`)).item.documentInspectionRequired, true);
const checked = (await call(finance, 'POST', `${endpoint}/document-check`, { expectedVersion: saved.source_version })).item;
assert.equal(checked.status, 'needs_review');
await call(finance, 'POST', `${base(b)}/execute`, {}, 409);
const renewed = (await call(finance, 'POST', `${endpoint}/manual-verification`, { expectedVersion: checked.version,
reason: '合成本地更新后重新核对原件金额', pages: [{ pageNumber: 1, itemId: b.items[0].id, fields: [{ Name: '小写金额', Value: '10.00' }] }] })).item;
await call(manager, 'POST', `${endpoint}/manual-verification/${renewed.id}/review`, { expectedVersion: renewed.version,
decision: 'approve', reason: '合成公司代表重新确认本轮依据' });
await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal((await prisma.paymentReceiptManualProposal.findUniqueOrThrow({ where: { id: saved.id } })).payload_encrypted, saved.payload_encrypted);
assert.equal((await prisma.paymentReceiptClaim.findFirstOrThrow({ where: { item_id: b.items[0].id } })).manual_proposal_id, renewed.id);
assert.equal((await balance(s)).outstanding, '490.00');
}
checks.push('receipt_old_manual_approval_needs_reinspection_and_new_confirmation_without_rewriting_history');
{
const s = await setup(); const a = await batch(s); await batch(s);
await receipt(a.id, '1000', `RP-ATTR-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
const before = await balance(s);
await call(finance, 'POST', `${base(a)}/execute`, {}, 409, 'loan.receipt_attribution_required');
assert.equal((await balance(s)).reserved, before.reserved);
const p = await attribution(a); await review(a, p, 'loan-receipt-attribution');
await batch(s); await call(finance, 'POST', `${base(a)}/execute`, {}, 409, 'loan.receipt_attribution_required');
const p2 = await attribution(a); await review(a, p2, 'loan-receipt-attribution');
await action(s.auth, 'stop_payment'); await call(finance, 'POST', `${base(a)}/execute`, {});
assert.equal((await balance(s)).outstanding, '490.00');
assert.equal((await prisma.loanReceiptAttribution.findUniqueOrThrow({ where: { id: p.id } })).status, 'superseded');
}
checks.push('rp_same_amount_attribution_staleness_and_reconfirmation_before_settlement');
{
const s = await setup(); const b = await batch(s); await batch(s);
const ref = `RP-FAILED-${randomUUID()}`;
const sub = await submission(b, ref); await attest(b);
const rr = await receipt(b.id, '1000', ref, { payeeAccount: s.lender.bankAccount, transactionStatus: 'failed' });
const endpoint = `${base(b)}/bank-failure-review`;
await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id }, 409, 'loan.receipt_attribution_required');
const p = await attribution(b, { kind: 'failure', attachmentId: rr.attachment.id, basisKind: 'bank_instruction_ref' });
await review(b, p, 'loan-receipt-attribution');
config.loanReleaseWaitDays = 3;
await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id }, 409, 'loan.release_wait_period');
config.loanReleaseWaitDays = 0;
await call(manager, 'POST', endpoint, { attachmentId: rr.attachment.id });
await action(s.auth, 'stop_reconcile'); await cancel(b, {}, 409, 'loan.related_authorization_required');
const renew = await authorization(s.lender, [s.loan]); await action(renew, 'stop_payment');
await cancel(b); assert.equal((await balance(s)).outstanding, '500.00');
assert.equal(await prisma.loanRepaymentAllocation.count({ where: { repayment_id: b.loanRepaymentId, reservation_state: 'reserved' } }), 0);
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: b.loanRepaymentId } })).authorization_id, s.auth.id);
assert.ok(sub.id);
}
checks.push('rp_failed_attribution_wait_current_release_authority_and_new_grant_version');
{
const s = await setup(); const b = await batch(s); await exportOrder(b.id);
const exps = await prisma.paymentOrderExport.findMany({ where: { payment_batch_id: b.id }, orderBy: { sequence: 'asc' } });
const one = await submission(b, `RP-ONE-${randomUUID()}`, exps[0]);
const two = await submission(b, `RP-TWO-${randomUUID()}`, exps[1]); await attest(b);
const f1 = await fact(b, one); await review(b, f1);
await notPaid(b, 409, 'loan.terminal_coverage_incomplete');
const f2 = await fact(b, two); await review(b, f2); await notPaid(b);
const ev = (await prisma.loanBankTerminalFact.findUniqueOrThrow({ where: { id: f1.id } })).evidence_id;
await call(finance, 'POST', `${base(b)}/loan-terminal-evidences/${ev}/void`, { reason: '合成补证换版' });
await cancel(b, {}, 409);
const recovery = await call(finance, 'POST', `${base(b)}/loan-release-recoveries`, { requestKey: randomUUID(), reason: '合成重新补齐第一条指令终态' });
const replacement = await fact(b, one); await review(b, replacement);
await call(finance, 'POST', `${base(b)}/loan-release-recoveries/${recovery.id}/submit`, {});
await review(b, recovery, 'loan-release-recoveries');
await cancel(b, { recoveryId: recovery.id, requestKey: randomUUID() });
assert.equal((await balance(s)).reserved, '0.00'); assert.equal((await balance(s)).outstanding, '500.00');
}
checks.push('rp_every_export_submission_terminal_and_evidence_recovery_full_consumer');
{
const s = await setup(); const b = await batch(s);
await prisma.employeeRole.create({ data: { employee_id: manager.id, role_code: 'finance' } });
await prisma.employeeRole.create({ data: { employee_id: finance.id, role_code: 'manager' } });
try {
await call(manager, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: s.lender.id, reason: '合成经办冲突', allocations: [{ loanId: s.loan.id, amount: '1.00' }] }, 403, 'loan.related_actor_conflict');
for (const suffix of ['payment-order', 'loan-bank-submissions', 'loan-submission-attestation', 'loan-terminal-facts', 'loan-release-recoveries', 'loan-receipt-attribution', 'loan-bank-results', 'execute', 'cancel']) {
await call(manager, 'POST', `${base(b)}/${suffix}`, {}, 403, 'loan.related_actor_conflict');
}
await evidence(b, 'bank_terminal', manager, 403);
const sub = await submission(b); const f = await fact(b, sub);
await review(b, f, 'loan-terminal-facts', 403, finance);
const q = { exportIds: [(await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: b.id } })).id], queryEvidenceId: randomUUID(), statementEvidenceId: randomUUID(), exporterStatements: [{ exporterId: finance.id, evidenceId: randomUUID() }], coverageFrom: new Date().toISOString(), coverageThrough: new Date().toISOString(), note: '合成不可用的人工未提交例外' };
await call(finance, 'POST', `${base(b)}/loan-not-submitted`, q, 409, 'loan.related_q6_unavailable');
await call(manager, 'POST', `${base(b)}/loan-not-submitted/${randomUUID()}/review`, { decision: 'approve', reason: '合成不能伪造双人特别复核' }, 409, 'loan.related_q6_unavailable');
const policy = req('./src/services/loanApprovalPolicy');
const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: b.id } });
await assert.rejects(() => policy.assertNotRelatedQ6(prisma, saved), e => e.details?.reasonCode === 'loan.related_q6_unavailable');
const caps = (await call(manager, 'GET', base(b))).item.loanRepayment.actionCapabilities;
assert.equal(caps.canPrepare, false); assert.equal(caps.canUseNotSubmitted, false);
// A second token for the same employee is still the same identity.
const login = await app.inject({ method: 'POST', url: '/api/auth/login', payload: { identifier: manager.email, password: h.password }, headers: { 'x-device-fingerprint': randomUUID() } });
assert.equal(login.statusCode, 200);
await call({ headers: { authorization: `Bearer ${login.json().data.accessToken}` } }, 'POST', `${base(b)}/loan-bank-submissions`, {}, 403, 'loan.related_actor_conflict');
} finally {
await prisma.employeeRole.deleteMany({ where: { employee_id: manager.id, role_code: 'finance' } });
await prisma.employeeRole.deleteMany({ where: { employee_id: finance.id, role_code: 'manager' } });
}
}
checks.push('rp_only_payee_conflict_waived_multiple_roles_tokens_and_q6_refused');
for (const [cancelled, paidAmount, reserveAmount] of [[false, '0.00', '0.00'], [true, '0.00', '70.00'], [true, '60.00', '0.00'], [true, '100.00', '0.00']]) {
const s = await setup('100.00'); const b = await batch(s, '80.00'); await terminate(s, b, cancelled);
if (paidAmount !== '0.00') {
const p = await batch(s, paidAmount); await receipt(p.id, String(Math.round(Number(paidAmount) * 100)), `RP-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
await call(finance, 'POST', `${base(p)}/execute`, {});
}
const reserved = reserveAmount !== '0.00' ? await repay(s.lender.id, s.loan.id, reserveAmount) : null;
const count = await prisma.paymentBatch.count(); const e = await bankEvent(s, b);
await review(b, e, 'loan-bank-results');
if (reserved) { await apply(b, e); assert.equal((await balance(s)).outstanding, '100.00'); await cancel(reserved); }
await action(s.auth, 'stop_payment'); await action(s.auth, 'stop_reconcile');
await apply(b, e); await apply(b, e);
const excess = Math.max(0, Number(paidAmount) - 20);
const result = await prisma.loanSupplementalSettlement.findFirstOrThrow({ where: { event_id: e.id } });
assert.equal(result.bank_amount.toFixed(2), '80.00'); assert.equal(result.receivable_amount.toFixed(2), excess.toFixed(2));
assert.equal(await prisma.paymentBatch.count(), count);
assert.equal((await balance(s)).reserved, '0.00');
if (excess) {
await authorization(s.lender, [s.loan]);
for (const value of [15, excess - 15]) {
const ret = await bankEvent(s, b, 'return', value.toFixed(2), e.id); await review(b, ret, 'loan-bank-results'); await apply(b, ret);
if (value === 15) assert.ok(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }));
}
}
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
}
checks.push('rp_late_success_reserved_conflict_partial_and_full_excess_historical_booking_returns');
{
const s = await setup('100.00'); const b = await batch(s, '80.00'); await terminate(s, b, true);
const e = await bankEvent(s, b); const e2 = await bankEvent(s, b, 'late_success', '80.00', undefined, `RP-OTHER-${randomUUID()}`);
await review(b, e, 'loan-bank-results', 409, manager, { decision: 'reject' });
const correction = await evidence(b);
await review(b, e, 'loan-bank-results', 200, manager, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: correction });
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
await review(b, e2, 'loan-bank-results', 200, manager, { decision: 'reject', correctionKind: 'duplicate', correctionEvidenceId: await evidence(b) });
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event: { payment_batch_id: b.id } } }), 0);
}
checks.push('rp_evidenced_false_alarm_closes_only_its_own_freeze');
{
const { Pool } = req('pg'); const locks = new Pool({ ...pool.options, max: 3 });
async function waitFor(n) {
for (let i = 0; i < 300; i++) {
const rows = await locks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'");
if (rows.rows[0].n >= n) return; await new Promise(resolve => setTimeout(resolve, 10));
}
throw new Error(`related authority race did not queue ${n} requests`);
}
try {
for (const kind of ['approval', 'export', 'fact', 'release']) for (const stopFirst of [true, false]) {
const s = await setup('100.00'); const b = await repay(s.lender.id, s.loan.id, '10.00');
let f;
if (kind !== 'approval') await approve(b);
if (['fact', 'release'].includes(kind)) { await exportOrder(b.id); const sub = await submission(b); await attest(b); f = await fact(b, sub); }
if (kind === 'release') { await review(b, f); await notPaid(b); }
const reconcile = ['fact', 'release'].includes(kind);
const request = () => app.inject({ method: 'POST', headers: (['approval', 'fact'].includes(kind) ? manager : finance).headers,
url: `/api${base(b)}/${kind === 'approval' ? 'review' : kind === 'export' ? 'payment-order' : kind === 'fact' ? `loan-terminal-facts/${f.id}/review` : 'cancel'}`,
payload: kind === 'fact' ? { decision: 'approve', reason: '合成并发公司确认' } : kind === 'release' ? { reason: '合成并发最终释放' } : {} });
const stop = () => app.inject({ method: 'POST', url: `/api/loan-related-authorizations/${s.auth.id}/actions`, headers: finance.headers,
payload: { action: reconcile ? 'stop_reconcile' : 'stop_payment', reason: '合成并发停止授权' } });
const holder = await locks.connect(); let decision; let stopped;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
if (stopFirst) { stopped = stop(); await waitFor(1); decision = request(); }
else { decision = request(); await waitFor(1); stopped = stop(); }
await waitFor(2); await holder.query('COMMIT');
const results = await Promise.all([decision, stopped]);
assert.equal(results[1].statusCode, 200, `stop during ${kind}`);
assert.equal(results[0].statusCode, stopFirst ? 409 : 200, `${kind} stopFirst=${stopFirst}: ${results[0].statusCode}`);
if (stopFirst) assert.equal(results[0].json().error?.details.reasonCode, 'loan.related_authorization_required');
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
const state = await balance(s);
assert.equal(state.outstanding, '100.00'); assert.equal(state.reserved, kind === 'release' && !stopFirst ? '0.00' : '10.00');
}
for (const checkFirst of [true, false]) {
const s = await setup(); const b = await batch(s);
const rr = await receipt(b.id, '1000', `RP-DOCUMENT-RACE-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
const old = await legacyVerification(rr.attachment.id);
const check = () => app.inject({ method: 'POST', url: `/api${base(b)}/receipt-attachments/${rr.attachment.id}/document-check`,
headers: finance.headers, payload: { expectedVersion: old.source_version } });
const execute = () => app.inject({ method: 'POST', url: `/api${base(b)}/execute`, headers: finance.headers, payload: {} });
const holder = await locks.connect(); let checked; let paid;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [b.id]);
if (checkFirst) { checked = check(); await waitFor(1); paid = execute(); }
else { paid = execute(); await waitFor(1); checked = check(); }
await waitFor(2); await holder.query('COMMIT');
const results = await Promise.all([checked, paid]);
assert.equal(results[0].statusCode, 200, 'local reinspection serializes with settlement');
assert.equal(results[1].statusCode, checkFirst ? 200 : 409, 'settlement uses evidence available at its turn');
assert.equal((await balance(s)).reserved, checkFirst ? '0.00' : '10.00');
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
if (!checkFirst) await call(finance, 'POST', `${base(b)}/execute`, {});
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: b.items[0].id } }), 1);
}
} finally { await locks.end(); }
}
checks.push('rp_authority_stop_serializes_with_approval_export_fact_and_release_both_orders');
checks.push('receipt_local_inspection_and_final_settlement_serialize_in_both_orders');
}
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) {
const s = await setup('100.00', null);
ui.push({ ...s, mode });
}
} finally { if (originalWait === undefined) delete config.loanReleaseWaitDays; else config.loanReleaseWaitDays = originalWait; }
console.log(JSON.stringify({ stage: 'loan_related_http', status: checks.length ? 'PASS' : 'NOT_RUN', checks }));
return { checks, ui, helpers: { execute: b => call(finance, 'POST', `${base(b)}/execute`, {}), draft: (s, amount) => repay(s.lender.id, s.loan.id, amount), setup, authorization, batch, receipt, processUploadedReceipt: h.processUploadedReceipt, submission, attest, evidence, fact, review, notPaid, terminate, pdf, bankEvent } };
};