区分正式付款回单、非最终回执与未知凭证,在核验构建、人工证据重建和最终核销时使用同一份有效依据。非最终说明、入账方向、状态矛盾及重复交易不能经人工覆盖。 正式回单无字面成功状态时,允许财务按原件实际记账支出、日期及说明提出核验,由本批次适用的确认人确认;不补造银行成功文字。旧核验可显式本地重检,新版本与审计原子保存,不重新外发 OCR,不沿用旧人工批准或改写已核销历史。 本地验证:82 个迁移、借款 HTTP 116 组、关联电脑/手机 18 组、共享回单数据库与真实认证 38 组、共享报销页面限定重验 24 项、最新回单/报销定向 292 项通过;9 个资金守卫变异均被捕获。服务端全量 4554 项通过、1 项既有可选跳过,后续合成数据及零页详情改动由最新定向验证覆盖。 本次无新增迁移,新增本地 PDF 文本解析依赖。后续对本提交运行完整 verify-candidate,实际候选与结果以本轮回执为准。 限制:独立代码复核及真实还款验收待完成;历史材料预验不证明真实还本或银行提交对应。未推送、未部署、未访问生产或写入真实业务数据。
1364 行
115 KiB
JavaScript
1364 行
115 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const { randomBytes, randomUUID } = require('node:crypto');
|
|
const { createRequire } = require('node:module');
|
|
const root = path.resolve(__dirname, '../..');
|
|
const req = createRequire(path.join(root, 'server/package.json'));
|
|
const { PrismaClient } = req('@prisma/client');
|
|
const { PrismaPg } = req('@prisma/adapter-pg');
|
|
const { hashPassword } = req('./src/utils/crypto');
|
|
|
|
module.exports = async function runLoanHttpFixture(pool, scratch) {
|
|
const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) });
|
|
const app = req('fastify')({ logger: false });
|
|
const config = {
|
|
fieldEncryptionKey: randomBytes(32).toString('base64'), jwtSecret: randomBytes(48).toString('hex'),
|
|
accessTokenTtl: '30m', refreshTtlSeconds: 3600, refreshCookieName: 'synthetic_refresh', cookieSecure: false,
|
|
rateLimitEnabled: false, emailEnabled: false, appOrigin: 'http://127.0.0.1',
|
|
storageDir: path.join(scratch, 'materials'), storageDriver: 'local', appTimezone: 'Asia/Shanghai',
|
|
paymentBatchEnabled: true,
|
|
};
|
|
app.decorate('config', config); app.decorate('prisma', prisma);
|
|
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({
|
|
code: error.code ?? 'SYS_001', details: error.details ?? {},
|
|
}));
|
|
await app.register(req('@fastify/cookie'));
|
|
await app.register(req('@fastify/multipart'));
|
|
await app.register(req('./src/plugins/auth'));
|
|
await app.register(req('./src/routes/auth'), { prefix: '/api/auth' });
|
|
await app.register(req('./src/routes/companyLoans'), { prefix: '/api' });
|
|
await app.register(req('./src/routes/paymentBatches'), { prefix: '/api' });
|
|
for (const route of ['me', 'systemWorkspace', 'notifications', 'masterData', 'paymentAccounts']) {
|
|
await app.register(req(`./src/routes/${route}`), { prefix: '/api' });
|
|
}
|
|
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
|
|
await app.register(req('@fastify/static'), { root: path.join(root, 'client/dist') });
|
|
app.setNotFoundHandler((request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ code: 'SYS_404' }) : reply.sendFile('index.html'));
|
|
}
|
|
await app.ready();
|
|
const checks = [];
|
|
try {
|
|
const company = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成公司' } });
|
|
const bank = await prisma.bankInstitution.upsert({ where: { code: 'CCB' }, update: {}, create: { code: 'CCB', name: '合成建行' } });
|
|
const department = await prisma.department.create({ data: { name: '合成财务部' } });
|
|
await prisma.featureFlag.create({ data: { flag_key: 'payment_batch_v1', override_mode: 'force_on', rollout_type: 'global' } });
|
|
const password = `SyntH!${randomBytes(16).toString('hex')}`;
|
|
const passwordHash = await hashPassword(password);
|
|
async function actor(roleCodes) {
|
|
const id = randomUUID();
|
|
const employee = await prisma.employee.create({ data: {
|
|
id, email: `${id}@example.invalid`, name: '合成用户', department_id: department.id,
|
|
legal_entity_id: company.id, title: '合成岗位', hire_date: new Date('2025-01-01'),
|
|
bank_name: '合成', bank_account_encrypted: req('./src/services/crypto').encryptStringOrThrow(config, '7'.repeat(16)), password_hash: passwordHash,
|
|
roles: { create: roleCodes.map(role_code => ({ role_code })) },
|
|
} });
|
|
const login = await app.inject({ method: 'POST', url: '/api/auth/login',
|
|
headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: employee.email, password } });
|
|
assert.equal(login.statusCode, 200, 'real synthetic password login');
|
|
return { id, email: employee.email, headers: { authorization: `Bearer ${login.json().data.accessToken}` } };
|
|
}
|
|
const finance = await actor(['finance']);
|
|
const manager = await actor(['manager']);
|
|
const system = await actor(['system_admin']);
|
|
const admin = await actor(['admin']);
|
|
const ordinary = await actor(['employee']);
|
|
async function call(who, method, url, payload, status = 200, reason) {
|
|
const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload });
|
|
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`);
|
|
if (reason) assert.equal(response.json().details?.reasonCode, reason);
|
|
return response.json().data;
|
|
}
|
|
for (const who of [manager, system, admin, ordinary]) {
|
|
await call(who, 'GET', '/company-loans', undefined, 403);
|
|
await call(who, 'POST', '/loan-lenders', {}, 403);
|
|
}
|
|
await call(null, 'GET', '/company-loans', undefined, 401);
|
|
checks.push('real_login_and_explicit_finance_permissions');
|
|
const bankAccount = '9'.repeat(16); // synthetic account, never log its value
|
|
const lenderInput = { requestKey: randomUUID(), legalEntityId: company.id, name: '合成出借人',
|
|
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount, accountName: '合成出借人' };
|
|
const lender = await call(finance, 'POST', '/loan-lenders', lenderInput);
|
|
assert.equal((await call(finance, 'POST', '/loan-lenders', lenderInput)).id, lender.id);
|
|
await call(finance, 'POST', '/loan-lenders', { ...lenderInput, name: 'different' }, 409, 'loan.duplicate_registration');
|
|
assert.equal(lender.identityStatus, 'unverified');
|
|
assert.ok(!JSON.stringify(lender).includes(bankAccount));
|
|
assert.ok(!JSON.stringify(lender).includes('v1:'));
|
|
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
|
|
expectedVersion: 1, employeeId: null, externalPersonConfirmed: false, reason: '合成核实',
|
|
}, 400, 'loan.invalid_input');
|
|
const verified = await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
|
|
expectedVersion: 1, employeeId: null, externalPersonConfirmed: true, reason: '合成非员工核实',
|
|
});
|
|
assert.equal(verified.identityStatus, 'verified');
|
|
checks.push('lender_encryption_identity_confirmation_and_idempotency');
|
|
const input = { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成原借入凭据一',
|
|
originalPrincipal: '1000.10', openingOutstandingPrincipal: '800.05',
|
|
receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true };
|
|
const loan = await call(finance, 'POST', '/company-loans', input);
|
|
assert.equal(loan.available, '0.00'); assert.equal(loan.outstanding, '800.05');
|
|
assert.equal((await call(finance, 'POST', '/company-loans', input)).id, loan.id);
|
|
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID() }, 409, 'loan.duplicate_registration');
|
|
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), receivedDate: '2025-02-30' }, 400);
|
|
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
|
|
const material = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n');
|
|
async function upload(kind, data = material, filename = 'synthetic.pdf', type = 'application/pdf', status = 200, loanId = loan.id) {
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
const payload = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]);
|
|
const response = await app.inject({ method: 'POST', url: `/api/company-loans/${loanId}/evidences/${kind}`,
|
|
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload });
|
|
assert.equal(response.statusCode, status, `upload ${kind}`);
|
|
return response.json().data;
|
|
}
|
|
await upload('agreement', Buffer.from('<script>synthetic</script>'), 'bad.pdf', 'application/pdf', 400);
|
|
const evidence = await upload('agreement'); await upload('incoming'); await upload('accounting_confirmation');
|
|
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
|
|
await upload('adjustment');
|
|
const confirmed = await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' });
|
|
assert.equal(confirmed.available, '800.05'); assert.equal(confirmed.version, 2);
|
|
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 2, accountingConfirmationRef: '改写' }, 409, 'loan.confirmed_immutable');
|
|
checks.push('historical_balance_evidence_and_outside_repayment_required');
|
|
await call(ordinary, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, { reason: '合成读取' }, 403);
|
|
await call(finance, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, {}, 400);
|
|
const downloaded = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${evidence.id}/download`,
|
|
headers: finance.headers, payload: { reason: '合成核对读取' } });
|
|
assert.equal(downloaded.statusCode, 200);
|
|
assert.ok(downloaded.headers['content-disposition'].startsWith('attachment;'));
|
|
assert.equal(downloaded.headers['x-content-type-options'], 'nosniff');
|
|
assert.ok(downloaded.headers['cache-control'].includes('no-store'));
|
|
checks.push('scoped_evidence_download_and_headers');
|
|
// Real database audit failure must roll back the source write and not merely log it.
|
|
await pool.query(`CREATE FUNCTION synthetic_fail_loan_audit() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN IF NEW.action='COMPANY_LOAN_REGISTERED' THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`);
|
|
await pool.query('CREATE TRIGGER synthetic_loan_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_loan_audit()');
|
|
const count = await prisma.companyLoan.count();
|
|
try {
|
|
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成失败' }, 500);
|
|
assert.equal(await prisma.companyLoan.count(), count);
|
|
} finally {
|
|
await pool.query('DROP TRIGGER synthetic_loan_audit ON audit_logs');
|
|
await pool.query('DROP FUNCTION synthetic_fail_loan_audit()');
|
|
}
|
|
checks.push('real_audit_failure_atomic_rollback');
|
|
const { encryptStringOrThrow } = req('./src/services/crypto');
|
|
const payerAccount = '8'.repeat(16);
|
|
await prisma.paymentAccount.create({ data: { legal_entity_id: company.id, bank_institution_id: bank.id,
|
|
account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } });
|
|
const second = await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成原借入凭据二',
|
|
originalPrincipal: '300.05', openingOutstandingPrincipal: '300.05' });
|
|
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, second.id);
|
|
await call(finance, 'POST', `/company-loans/${second.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对二' });
|
|
const repaymentInput = { requestKey: randomUUID(), lenderId: lender.id, reason: '合成两笔借入部分还本',
|
|
allocations: [{ loanId: loan.id, amount: '100.10' }, { loanId: second.id, amount: '200.05' }] };
|
|
const { item: batch } = await call(finance, 'POST', '/loan-repayments', repaymentInput);
|
|
assert.equal(batch.items.length, 1); assert.equal(String(batch.totalAmount), '300.15');
|
|
assert.equal((await call(finance, 'POST', '/loan-repayments', repaymentInput)).item.id, batch.id);
|
|
await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, reason: 'changed' }, 409, 'loan.duplicate_registration');
|
|
const balance = await call(finance, 'GET', `/company-loans/${loan.id}`);
|
|
assert.equal(balance.outstanding, '800.05'); assert.equal(balance.reserved, '100.10'); assert.equal(balance.available, '699.95');
|
|
await call(finance, 'POST', `/payment-batches/${batch.id}/review`, {}, 403);
|
|
await call(admin, 'POST', `/payment-batches/${batch.id}/execute`, {}, 403);
|
|
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {});
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
|
|
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 409, 'payment_batch.bank_receipt_required');
|
|
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '提前交接' }, 409, 'loan.handoff_not_ready');
|
|
checks.push('two_loans_one_payment_partial_allocation_idempotency_and_review');
|
|
const { runReceiptOcrOnce } = req('./src/jobs/paymentReceiptOcrJob');
|
|
// Receipts go through the real local chain: PDF decode, recognizePaymentReceipt,
|
|
// normalizeBankSlip, verification storage, matching and claims. Only the cloud
|
|
// BankSlipOCR response is synthetic, built from the intended slip fields.
|
|
const { recognizePaymentReceipt } = req('./src/services/paymentReceiptOcr');
|
|
const { PDFDocument } = req('pdf-lib');
|
|
const { syntheticBankReceiptPdf, syntheticDocumentEvidence } = require('./synthetic-bank-receipt.cjs');
|
|
const receiptPdf = (pages = 1, options = {}) => syntheticBankReceiptPdf({ pages, ...options });
|
|
const onePagePdf = await receiptPdf(1);
|
|
const STATUS_WORDING = { success: '交易成功', failed: '交易失败', pending: '处理中' };
|
|
function bankSlipFields(slip) {
|
|
const fields = [];
|
|
const add = (Name, Value) => { if (Value !== null && Value !== undefined) fields.push({ Name, Value: String(Value) }); };
|
|
if (slip.amount !== null && slip.amount !== undefined) {
|
|
const cents = BigInt(slip.amount);
|
|
add('小写金额', `${cents / 100n}.${String(cents % 100n).padStart(2, '0')}`);
|
|
}
|
|
add('币别', slip.currency === 'CNY' ? '人民币' : slip.currency);
|
|
add('付款人账号', slip.payerAccount); add('收款人账号', slip.payeeAccount); add('凭证号', slip.reference);
|
|
for (const wording of slip.statusWordings ?? (slip.transactionStatus ? [STATUS_WORDING[slip.transactionStatus]] : [])) {
|
|
add(fields.some((field) => field.Name === '单据状态') ? '交易状态' : '单据状态', wording);
|
|
}
|
|
return fields;
|
|
}
|
|
async function receipt(batchId, amount, reference, overrides = {}, { pdfPages = 1, normalizedPage = false, document = {} } = {}) {
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
const status = overrides.transactionStatus === null ? 'booked' : overrides.transactionStatus ??
|
|
(overrides.statusWordings?.some((value) => ['交易失败', '转账失败', '付款失败', '支付失败'].includes(value)) ? 'failed' : 'success');
|
|
const minor = overrides.amount ?? amount;
|
|
const storedBatch = minor === null ? await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batchId }, include: { items: true } }) : null;
|
|
const displayedAmount = minor === null ? String(storedBatch.items[0].amount) :
|
|
`${BigInt(minor) / 100n}.${String(BigInt(minor) % 100n).padStart(2, '0')}`;
|
|
const file = await receiptPdf(pdfPages, { status, amount: displayedAmount, currency: overrides.currency ?? '人民币',
|
|
payerAccount: overrides.payerAccount ?? payerAccount, payeeAccount: overrides.payeeAccount ?? bankAccount,
|
|
reference: overrides.reference ?? reference ?? 'SYNTHETIC-UNREAD-REFERENCE', ...document });
|
|
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
|
|
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), file, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
|
assert.equal(response.statusCode, 200, 'receipt upload');
|
|
const result = await processUploadedReceipt(amount, reference, overrides, { normalizedPage });
|
|
return { attachment: response.json().data.items[0], result };
|
|
}
|
|
async function processUploadedReceipt(amount, reference, overrides = {}, { normalizedPage = false } = {}) {
|
|
const slip = { amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount, transactionStatus: 'success', reference, ...overrides };
|
|
// normalizedPage: one explicitly synthetic defence-in-depth case that the
|
|
// real normalizer cannot produce; every other receipt uses the real chain.
|
|
const recognize = normalizedPage
|
|
? async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1, issues: [], ...slip,
|
|
document: syntheticDocumentEvidence({ status: slip.transactionStatus ?? 'booked' }) }] })
|
|
: (args) => recognizePaymentReceipt({ ...args, createClient: () => ({ BankSlipOCR: async () => ({ BankSlipInfos: bankSlipFields(slip) }) }) });
|
|
const result = await runReceiptOcrOnce({ prisma, config, recognize });
|
|
return result;
|
|
}
|
|
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
|
|
assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().details)}`);
|
|
assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1');
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
|
|
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe');
|
|
checks.push('actual_biff8_export_keeps_principal_and_prevents_unsafe_release');
|
|
const receiptResult = await receipt(batch.id, '30015', 'SYNTHETIC-BANK-1');
|
|
assert.equal(receiptResult.result.status, 'matched');
|
|
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '未知银行结果不能释放' }, 409, 'loan.bank_state_unsafe');
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
|
|
await pool.query(`CREATE FUNCTION synthetic_fail_settlement_audit() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN IF NEW.action='LOAN_PRINCIPAL_SETTLED' THEN RAISE EXCEPTION 'synthetic settlement audit unavailable'; END IF; RETURN NEW; END $$`);
|
|
await pool.query('CREATE TRIGGER synthetic_settlement_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_settlement_audit()');
|
|
try {
|
|
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 500);
|
|
assert.equal(await prisma.paymentReceiptClaim.count(), 0);
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
|
|
} finally {
|
|
await pool.query('DROP TRIGGER synthetic_settlement_audit ON audit_logs');
|
|
await pool.query('DROP FUNCTION synthetic_fail_settlement_audit()');
|
|
}
|
|
const paid = await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {});
|
|
assert.equal(paid.item.status, 'executed');
|
|
const after = await call(finance, 'GET', `/company-loans/${loan.id}`);
|
|
assert.equal(after.outstanding, '699.95'); assert.equal(after.reserved, '0.00');
|
|
assert.equal(await prisma.paymentReceiptClaim.count(), 1);
|
|
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {});
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95');
|
|
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '合成会计凭证' });
|
|
checks.push('real_receipt_worker_atomic_claim_settlement_rollback_retry_and_handoff');
|
|
const nextInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '300.15' }] };
|
|
const { item: duplicate } = await call(finance, 'POST', '/loan-repayments', nextInput);
|
|
await call(manager, 'POST', `/payment-batches/${duplicate.id}/review`, {});
|
|
await receipt(duplicate.id, '30015', 'SYNTHETIC-BANK-1');
|
|
await call(finance, 'POST', `/payment-batches/${duplicate.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused');
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95');
|
|
const concurrentInput = { ...repaymentInput, allocations: [{ loanId: loan.id, amount: '300.00' }] };
|
|
const concurrent = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments',
|
|
headers: finance.headers, payload: { ...concurrentInput, requestKey: randomUUID() } })));
|
|
assert.deepEqual(concurrent.map(r => r.statusCode).sort(), [200, 409]);
|
|
const winner = concurrent.find(r => r.statusCode === 200).json().data.item;
|
|
await call(finance, 'POST', `/payment-batches/${winner.id}/cancel`, { reason: '合成未发送取消' });
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
|
|
checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel');
|
|
// S1: failure needs a registered bank submission whose completeness was
|
|
// independently attested (registrar/proposer finance, reviewer `by`).
|
|
// The submission is registered under the instruction reference that the
|
|
// batch's current failed slip carries (as finance would read it off the bank).
|
|
const { decryptStringOrThrow } = req('./src/services/crypto');
|
|
async function slipReference(batchId) {
|
|
const attachments = await prisma.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batchId, archived_at: null },
|
|
include: { verifications: { orderBy: [{ created_at: 'desc' }, { id: 'desc' }], take: 1 } }, orderBy: { created_at: 'desc' } });
|
|
for (const attachment of attachments) {
|
|
const verification = attachment.verifications[0];
|
|
if (!verification) continue;
|
|
const page = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted)).pages?.[0];
|
|
if (page?.reference) return page.reference;
|
|
}
|
|
return `SYN-INSTR-${batchId.slice(0, 8)}`;
|
|
}
|
|
async function attestSubmission(batchId, { by = manager, registrar = finance, ref } = {}) {
|
|
const exported = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: batchId }, orderBy: { sequence: 'desc' } });
|
|
const submission = await call(registrar, 'POST', `/payment-batches/${batchId}/loan-bank-submissions`, {
|
|
...(exported ? { exportId: exported.id, channel: 'online_banking_import' } : { channel: 'manual_entry' }),
|
|
submittedAt: new Date().toISOString(), bankInstructionRef: ref ?? await slipReference(batchId), note: '合成网银提交登记' });
|
|
const attestation = await call(finance, 'POST', `/payment-batches/${batchId}/loan-submission-attestation`, { note: '合成提交清单完整' });
|
|
await call(by, 'POST', `/payment-batches/${batchId}/loan-submission-attestation/${attestation.id}/review`, { decision: 'approve', reason: '合成清单复核' });
|
|
return { submission, attestation };
|
|
}
|
|
const { item: failedBank } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '10.00' }] });
|
|
await call(manager, 'POST', `/payment-batches/${failedBank.id}/review`, {});
|
|
const failureReceipt = await receipt(failedBank.id, '1000', 'SYNTHETIC-FAILED', { transactionStatus: 'failed', issues: ['transaction_failed'] });
|
|
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
|
|
await call(finance, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 403);
|
|
await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 409, 'loan.submission_list_unverified');
|
|
await attestSubmission(failedBank.id);
|
|
await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id });
|
|
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
|
|
await call(finance, 'POST', `/payment-batches/${failedBank.id}/cancel`, { reason: '合成银行明确失败' });
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
|
|
checks.push('confirmed_bank_failure_requires_independent_matching_evidence');
|
|
const { item: manual } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '20.00' }] });
|
|
await call(manager, 'POST', `/payment-batches/${manual.id}/review`, {});
|
|
const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] },
|
|
{ document: { reference: 'SYNTHETIC-MANUAL' } });
|
|
assert.equal(unclear.result.status, 'needs_review');
|
|
const currentAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: unclear.attachment.id } });
|
|
const manualBase = `/payment-batches/${manual.id}/receipt-attachments/${unclear.attachment.id}/manual-verification`;
|
|
const proposal = await call(finance, 'POST', manualBase, { expectedVersion: currentAttachment.receipt_ocr_version,
|
|
reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id,
|
|
paymentProof: { basis: 'booked_debit', originalChecked: true, bookedOn: '2026-10-01', note: '合成正式支出回单已记账,无成功状态原文' }, fields: Object.entries({
|
|
小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: bankAccount, 凭证号: 'SYNTHETIC-MANUAL', 单据状态: '',
|
|
}).map(([Name, Value]) => ({ Name, Value })) }] });
|
|
const saved = proposal.item ?? proposal;
|
|
await call(finance, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成不允许自审' }, 403);
|
|
await call(manager, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成独立复核' });
|
|
await call(finance, 'POST', `/payment-batches/${manual.id}/execute`, {});
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95');
|
|
checks.push('external_lender_two_person_manual_receipt_and_real_settlement');
|
|
// Cross-source claim protection: seed only the prior reimbursement facts,
|
|
// then use actual receipt upload/worker/execute HTTP. No reimbursement paid
|
|
// result is seeded or asserted by this setup.
|
|
const source = await prisma.reimbursementForm.create({ data: {
|
|
form_number: `SYN-${randomUUID()}`, submitter_id: ordinary.id, owner_id: ordinary.id,
|
|
department_id: department.id, reimbursement_date: new Date('2026-01-01'), total_amount: '300.15', status: 'approved',
|
|
} });
|
|
const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id }, include: { items: true } });
|
|
const crossBatch = await prisma.paymentBatch.create({ data: {
|
|
batch_no: `SYN-${randomUUID()}`, name: '合成跨业务去重', entity_type: 'reimbursement', status: 'reviewed',
|
|
created_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), legal_entity_id: company.id,
|
|
payment_account_id: savedBatch.payment_account_id, payment_account_snapshot_json: savedBatch.payment_account_snapshot_json,
|
|
default_payment_channel: 'bank', items: { create: { entity_id: source.id, entity_code: source.form_number, entity_title: '合成报销来源',
|
|
amount: '300.15', snapshot_json: savedBatch.items[0].snapshot_json } },
|
|
} });
|
|
await receipt(crossBatch.id, '30015', 'SYNTHETIC-BANK-1');
|
|
await call(finance, 'POST', `/payment-batches/${crossBatch.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused');
|
|
assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: source.id } })).status, 'approved');
|
|
checks.push('same_bank_transaction_rejected_across_loan_and_reimbursement_http');
|
|
|
|
for (const [label, overrides] of [
|
|
['amount', { amount: '101' }], ['currency', { currency: 'USD' }],
|
|
['payer', { payerAccount: '1'.repeat(16) }], ['payee', { payeeAccount: '2'.repeat(16) }],
|
|
['pending', { transactionStatus: 'pending', issues: ['transaction_pending'] }],
|
|
]) {
|
|
const { item: rejected } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput,
|
|
requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] });
|
|
await call(manager, 'POST', `/payment-batches/${rejected.id}/review`, {});
|
|
const mismatch = await receipt(rejected.id, '100', `SYNTHETIC-NEGATIVE-${label}`, overrides);
|
|
assert.equal(mismatch.result.status, 'needs_review');
|
|
await call(finance, 'POST', `/payment-batches/${rejected.id}/execute`, {}, 409);
|
|
await call(manager, 'POST', `/payment-batches/${rejected.id}/bank-failure-review`, { attachmentId: mismatch.attachment.id }, 409);
|
|
await call(finance, 'POST', `/payment-batches/${rejected.id}/cancel`, { reason: '不明或不符不得释放' }, 409, 'loan.bank_state_unsafe');
|
|
}
|
|
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95');
|
|
checks.push('wrong_amount_currency_both_accounts_and_pending_never_settle_or_release');
|
|
const noReviewerInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] };
|
|
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
|
|
try { await call(finance, 'POST', '/loan-repayments', noReviewerInput, 409, 'loan.reviewer_required'); }
|
|
finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
|
|
const sameKey = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: noReviewerInput })));
|
|
assert.deepEqual(sameKey.map(r => r.statusCode), [200, 200]);
|
|
assert.equal(sameKey[0].json().data.item.id, sameKey[1].json().data.item.id);
|
|
await call(finance, 'POST', `/payment-batches/${sameKey[0].json().data.item.id}/cancel`, { reason: '合成并发验证结束' });
|
|
checks.push('no_eligible_reviewer_blocks_and_concurrent_same_key_is_idempotent');
|
|
const evidencePath = `/company-loans/${loan.id}/evidences/${evidence.id}/download`;
|
|
const reviewerDownload = await app.inject({ method: 'POST', url: `/api${evidencePath}`, headers: manager.headers,
|
|
payload: { reason: '合成还款审批材料', paymentBatchId: manual.id } });
|
|
assert.equal(reviewerDownload.statusCode, 200);
|
|
await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 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, account = digit.repeat(16)) {
|
|
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name,
|
|
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: account, accountName: name });
|
|
const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, {
|
|
expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' });
|
|
return { ...confirmedLender, bankAccount: account };
|
|
}
|
|
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} ${response.headers['content-type']?.includes('json') ? response.json().details?.reasonCode ?? '' : '<binary omitted>'}`);
|
|
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']);
|
|
const runRelated = () => require('./company-loan-related-fixture.cjs')({ prisma, pool, call, app, req, finance, manager, ordinary, independent, config, password,
|
|
verifiedLender, confirmedLoan, repay, exportOrder, receipt, processUploadedReceipt });
|
|
if (process.env.RUN_ISOLATED_LOAN_RELATED_ONLY === '1') {
|
|
const relatedParty = await runRelated();
|
|
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-related-browser.cjs')({ app, finance, manager, ordinary, password, prisma, config, relatedParty });
|
|
return;
|
|
}
|
|
|
|
|
|
// 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_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(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: [] },
|
|
{ document: { status: 'success', reference: 'SYNTHETIC-POST-PAY-MANUAL', payeeAccount: staffLender.bankAccount } });
|
|
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, amount] of [['creator', finance.id, '2.00'], ['lender_employee', lenderEmployee.id, '2.50']]) {
|
|
const forged = await repay(staffLender.id, staffLoan.id, amount);
|
|
await call(manager, 'POST', `/payment-batches/${forged.id}/review`, {});
|
|
await exportOrder(forged.id);
|
|
const forgedReceipt = await receipt(forged.id, String(Math.round(Number(amount) * 100)), `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: a matched receipt does not prove which same-lender, same-amount batch
|
|
// the bank paid. Settlement and failure release need an independently
|
|
// reviewed attribution; OCR matches and manual receipt reviews cannot bypass it.
|
|
const probeLender = await verifiedLender('合成出借人丁', null, '6');
|
|
const probeLoan = await confirmedLoan(probeLender.id, '500.00', '合成丁借入');
|
|
const probeBalance = async () => { const b = await call(finance, 'GET', `/company-loans/${probeLoan.id}`); return `${b.outstanding}/${b.reserved}`; };
|
|
const attribute = (who, batchId, body, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-receipt-attribution`, {
|
|
basisKind: 'customer_serial', basisNote: '合成回单客户方流水号与本批次制单流水号一致', exclusionNote: '合成其他候选批次流水号均不一致', ...body }, status, reason);
|
|
const reviewAttribution = (who, batchId, id, decision = 'approve', status = 200, reason) => call(who, 'POST',
|
|
`/payment-batches/${batchId}/loan-receipt-attribution/${id}/review`, { decision, reason: '合成独立复核归属' }, status, reason);
|
|
const detail = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}`)).item ?? (await call(finance, 'GET', `/payment-batches/${batchId}`));
|
|
|
|
// C1: two same-amount repayments in flight; P's genuine receipt uploaded to Q.
|
|
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 beforeC1 = await probeBalance();
|
|
const c1 = await receipt(c1q.id, '700', 'SYNTHETIC-C1-PAYMENT-OF-P', { payeeAccount: probeLender.bankAccount });
|
|
assert.equal(c1.result.status, 'matched');
|
|
await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
assert.equal(await probeBalance(), beforeC1, 'C1 wrong receipt must not settle or release');
|
|
const qState = (await detail(c1q.id)).loanRepayment.receiptAttribution;
|
|
assert.equal(qState.required, true); assert.deepEqual(qState.competitors.map((item) => item.id), [c1p.id]);
|
|
// Correct receipt for P still needs attribution because Q is a competitor.
|
|
await receipt(c1p.id, '700', 'SYNTHETIC-C1-P-OWN', { payeeAccount: probeLender.bankAccount });
|
|
await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
await attribute(finance, c1p.id, { basisKind: 'time_alignment' }, 400);
|
|
const proposed = await attribute(finance, c1p.id, {});
|
|
await attribute(finance, c1p.id, {}, 409, 'loan.receipt_attribution_exists');
|
|
await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
await reviewAttribution(finance, c1p.id, proposed.id, 'approve', 403);
|
|
const dual = await actor(['finance', 'manager']);
|
|
await reviewAttribution(dual, c1p.id, proposed.id, 'approve');
|
|
assert.equal((await call(finance, 'POST', `/payment-batches/${c1p.id}/execute`, {})).item.status, 'executed');
|
|
await call(finance, 'POST', `/payment-batches/${c1q.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
checks.push('same_amount_wrong_batch_receipt_blocked_and_reviewed_attribution_unlocks_settlement');
|
|
|
|
// A proposer holding manager rights cannot review their own attribution.
|
|
const selfLender = await verifiedLender('合成出借人戊', null, '7');
|
|
const selfLoan = await confirmedLoan(selfLender.id, '100.00', '合成戊借入');
|
|
const s1 = await repay(selfLender.id, selfLoan.id, '3.00'); const s2 = await repay(selfLender.id, selfLoan.id, '3.00');
|
|
for (const item of [s1, s2]) { await call(independent, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); }
|
|
await receipt(s1.id, '300', 'SYNTHETIC-SELF-REVIEW', { payeeAccount: selfLender.bankAccount });
|
|
const selfProposal = await attribute(dual, s1.id, {});
|
|
await reviewAttribution(dual, s1.id, selfProposal.id, 'approve', 403);
|
|
await reviewAttribution(manager, s1.id, selfProposal.id, 'reject');
|
|
await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
// An approval stops covering once a new competitor reaches the bank.
|
|
const reproposal = await attribute(finance, s1.id, {});
|
|
await reviewAttribution(manager, s1.id, reproposal.id, 'approve');
|
|
const s3 = await repay(selfLender.id, selfLoan.id, '3.00');
|
|
await call(independent, 'POST', `/payment-batches/${s3.id}/review`, {}); await exportOrder(s3.id);
|
|
await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
const staleState = (await detail(s1.id)).loanRepayment.receiptAttribution.current;
|
|
assert.equal(staleState.coversCurrentCompetitors, false); assert.equal(staleState.stale, true);
|
|
checks.push('attribution_self_review_rejection_and_new_competitor_invalidate_approval');
|
|
// The stale approval must not deadlock the batch: re-registering supersedes it.
|
|
const readiness = async (batchId) => (await call(finance, 'GET', `/payment-batches/${batchId}/receipt-readiness`)).item;
|
|
assert.deepEqual(await readiness(s1.id), { ready: false, receiptAttributionRequired: true });
|
|
const renewed = await attribute(finance, s1.id, {});
|
|
assert.equal((await prisma.loanReceiptAttribution.findUniqueOrThrow({ where: { id: reproposal.id } })).status, 'superseded');
|
|
await reviewAttribution(manager, s1.id, renewed.id, 'approve');
|
|
assert.deepEqual(await readiness(s1.id), { ready: true });
|
|
assert.equal((await call(finance, 'POST', `/payment-batches/${s1.id}/execute`, {})).item.status, 'executed');
|
|
checks.push('stale_approval_superseded_by_reregistration_then_settles');
|
|
// Swapping the receipt after approval: the old approval cannot unlock the
|
|
// new transaction, and a new registration for the new receipt works.
|
|
const swapReviewer = await actor(['manager']);
|
|
const swapExporter = await actor(['finance', 'manager']);
|
|
const swap = await repay(selfLender.id, selfLoan.id, '3.00');
|
|
await call(independent, 'POST', `/payment-batches/${swap.id}/review`, {});
|
|
const swapExport = await app.inject({ method: 'POST', url: `/api/payment-batches/${swap.id}/payment-order`, headers: swapExporter.headers, payload: {} });
|
|
assert.equal(swapExport.statusCode, 200, 'exporter with manager role exports');
|
|
const firstReceipt = await receipt(swap.id, '300', 'SYNTHETIC-SWAP-FIRST', { payeeAccount: selfLender.bankAccount });
|
|
const swapApproved = await attribute(finance, swap.id, {});
|
|
await reviewAttribution(swapExporter, swap.id, swapApproved.id, 'approve', 403);
|
|
await reviewAttribution(swapReviewer, swap.id, swapApproved.id, 'approve');
|
|
await call(finance, 'POST', `/payment-batches/${swap.id}/receipt-attachments/${firstReceipt.attachment.id}/void`, { reason: '合成回单上传错误作废' });
|
|
await receipt(swap.id, '300', 'SYNTHETIC-SWAP-SECOND', { payeeAccount: selfLender.bankAccount });
|
|
await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
assert.equal((await detail(swap.id)).loanRepayment.receiptAttribution.current.stale, true);
|
|
const swapRenewed = await attribute(finance, swap.id, {});
|
|
await reviewAttribution(swapReviewer, swap.id, swapRenewed.id, 'approve');
|
|
assert.equal((await call(finance, 'POST', `/payment-batches/${swap.id}/execute`, {})).item.status, 'executed');
|
|
checks.push('receipt_swap_invalidates_approval_exporter_cannot_review_and_reregistration_settles');
|
|
// A proposal whose receipt was voided can still be rejected (no receipt needed).
|
|
const orphanReceipt = await receipt(s2.id, '300', 'SYNTHETIC-ORPHAN', { payeeAccount: selfLender.bankAccount });
|
|
const orphan = await attribute(finance, s2.id, {});
|
|
await call(finance, 'POST', `/payment-batches/${s2.id}/receipt-attachments/${orphanReceipt.attachment.id}/void`, { reason: '合成回单作废' });
|
|
assert.equal((await detail(s2.id)).loanRepayment.receiptAttribution.receiptAvailable, false);
|
|
await attribute(finance, s2.id, {}, 409, 'loan.receipt_attribution_unavailable');
|
|
await reviewAttribution(swapReviewer, s2.id, orphan.id, 'reject');
|
|
checks.push('proposal_without_receipt_can_be_rejected');
|
|
// Same transaction, different evidence (re-scanned receipt with the same bank
|
|
// reference): the approval is bound to the reviewed evidence and stops unlocking.
|
|
const rescan = await repay(selfLender.id, selfLoan.id, '3.00');
|
|
await call(independent, 'POST', `/payment-batches/${rescan.id}/review`, {}); await exportOrder(rescan.id);
|
|
const rescanFirst = await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount });
|
|
const rescanApproved = await attribute(finance, rescan.id, {});
|
|
await reviewAttribution(swapReviewer, rescan.id, rescanApproved.id, 'approve');
|
|
await call(finance, 'POST', `/payment-batches/${rescan.id}/receipt-attachments/${rescanFirst.attachment.id}/void`, { reason: '合成重新扫描' });
|
|
await receipt(rescan.id, '300', 'SYNTHETIC-RESCAN-SAME-TXN', { payeeAccount: selfLender.bankAccount });
|
|
await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
assert.equal((await detail(rescan.id)).loanRepayment.receiptAttribution.current.stale, true);
|
|
const rescanRenewed = await attribute(finance, rescan.id, {});
|
|
const supersedeAudit = await prisma.auditLog.findFirst({ where: { action: 'LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED' }, orderBy: { created_at: 'desc' } });
|
|
assert.ok(JSON.stringify(supersedeAudit).includes('receipt_evidence_changed'));
|
|
await reviewAttribution(swapReviewer, rescan.id, rescanRenewed.id, 'approve');
|
|
assert.equal((await call(finance, 'POST', `/payment-batches/${rescan.id}/execute`, {})).item.status, 'executed');
|
|
checks.push('same_transaction_new_evidence_invalidates_approval_until_rereviewed');
|
|
|
|
// Settlement's competitor check and a competitor's first receipt upload are
|
|
// serialized by the lender lock. Deterministic interleaving in both orders.
|
|
const raceLender = await verifiedLender('合成出借人庚', null, '2');
|
|
const raceLoan = await confirmedLoan(raceLender.id, '100.00', '合成庚借入');
|
|
const uploadOnly = (batchId) => {
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
return app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
|
|
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
|
};
|
|
const executeRequest = (batchId) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/execute`, headers: finance.headers, payload: {} });
|
|
const racePool = new LockPool({ ...pool.options, max: 2 });
|
|
const raceWaiters = async () => Number((await racePool.query(
|
|
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n);
|
|
async function raceWaiting(count) {
|
|
for (let i = 0; i < 300; i++) { if (await raceWaiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); }
|
|
throw new Error(`expected ${count} requests queued behind the lender lock`);
|
|
}
|
|
try {
|
|
for (const [order, amount, cents] of [['settle_first', '9.00', '900'], ['upload_first', '8.00', '800']]) {
|
|
const settling = await repay(raceLender.id, raceLoan.id, amount);
|
|
await call(manager, 'POST', `/payment-batches/${settling.id}/review`, {}); await exportOrder(settling.id);
|
|
assert.equal((await receipt(settling.id, cents, `SYNTHETIC-RACE-${order}`, { payeeAccount: raceLender.bankAccount })).result.status, 'matched');
|
|
const competitor = await repay(raceLender.id, raceLoan.id, amount);
|
|
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
|
|
const holder = await racePool.connect();
|
|
let executed; let uploaded;
|
|
try {
|
|
await holder.query('BEGIN');
|
|
await holder.query('SELECT id FROM loan_lenders WHERE id = $1 FOR UPDATE', [raceLender.id]);
|
|
if (order === 'settle_first') {
|
|
executed = executeRequest(settling.id); await raceWaiting(1);
|
|
uploaded = uploadOnly(competitor.id); await raceWaiting(2);
|
|
} else {
|
|
uploaded = uploadOnly(competitor.id); await raceWaiting(1);
|
|
executed = executeRequest(settling.id); await raceWaiting(2);
|
|
}
|
|
await holder.query('COMMIT');
|
|
[executed, uploaded] = [await executed, await uploaded];
|
|
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
|
|
assert.equal(uploaded.statusCode, 200, `${order} competitor upload`);
|
|
if (order === 'settle_first') assert.equal(executed.statusCode, 200, 'settlement checked before the competitor existed');
|
|
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().details?.reasonCode, 'loan.receipt_attribution_required'); }
|
|
// Keep the shared OCR queue clean for later scenarios.
|
|
await call(finance, 'POST', `/payment-batches/${competitor.id}/receipt-attachments/${uploaded.json().data.items[0].id}/void`, { reason: '合成并发验证结束' });
|
|
}
|
|
} finally { await racePool.end(); }
|
|
checks.push('competitor_upload_and_settlement_serialize_on_lender_lock_both_orders');
|
|
|
|
// S1: exports and bank submissions are separate facts; failure confirmation
|
|
// is gated on one attested submission and on voided receipts being resolved.
|
|
const s1Lender = await verifiedLender('合成出借人辛', null, '1', '1357913579135791');
|
|
const s1Loan = await confirmedLoan(s1Lender.id, '1000.00', '合成辛借入');
|
|
const s1Batch = async (amount) => { const item = await repay(s1Lender.id, s1Loan.id, amount); await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); return item; };
|
|
const failReceipt = (batchId, cents, ref) => receipt(batchId, cents, ref, { payeeAccount: s1Lender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
|
|
const confirmFailure = (batchId, attachmentId, status = 200, reason) => call(manager, 'POST', `/payment-batches/${batchId}/bank-failure-review`, { attachmentId }, status, reason);
|
|
// Two exports: recorded separately; one failed receipt cannot close both.
|
|
const twice = await s1Batch('11.00');
|
|
await exportOrder(twice.id); await exportOrder(twice.id);
|
|
const exportsState = (await detail(twice.id)).loanRepayment.bankSubmissions.exports;
|
|
assert.deepEqual(exportsState.map((item) => item.sequence), [1, 2]);
|
|
assert.ok(exportsState.every((item) => Array.isArray(item.customerSerials) && item.customerSerials.length === 1));
|
|
const twiceFail = await failReceipt(twice.id, '1100', 'SYNTHETIC-S1-TWICE');
|
|
await attestSubmission(twice.id);
|
|
await confirmFailure(twice.id, twiceFail.attachment.id, 409, 'loan.multiple_submissions_unresolved');
|
|
checks.push('two_exports_recorded_and_single_failure_cannot_close_them');
|
|
// A voided receipt that once showed success or pending keeps blocking.
|
|
for (const [label, status, cents] of [['success', 'success', '1200'], ['pending', 'pending', '1300']]) {
|
|
const voidedCase = await s1Batch(String(Number(cents) / 100) + '.00');
|
|
const earlier = await receipt(voidedCase.id, cents, `SYNTHETIC-S1-VOIDED-${label}`, { payeeAccount: s1Lender.bankAccount,
|
|
transactionStatus: status, issues: status === 'pending' ? ['transaction_pending'] : [] });
|
|
await call(finance, 'POST', `/payment-batches/${voidedCase.id}/receipt-attachments/${earlier.attachment.id}/void`, { reason: '合成作废' });
|
|
const laterFail = await failReceipt(voidedCase.id, cents, `SYNTHETIC-S1-AFTER-${label}`);
|
|
await attestSubmission(voidedCase.id);
|
|
await confirmFailure(voidedCase.id, laterFail.attachment.id, 409, 'loan.voided_receipt_unresolved');
|
|
}
|
|
checks.push('voided_success_or_pending_receipt_keeps_blocking_failure');
|
|
// Voided material fails closed: without readable proof that it is a failure
|
|
// or a clearly different transaction, it keeps blocking.
|
|
const voidedBlocks = [
|
|
['before_ocr', null],
|
|
['unreadable', { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] }],
|
|
['ambiguous_status', { transactionStatus: null, statusWordings: ['交易失败', '处理中'] }],
|
|
// Explicitly synthetic (real normalization cannot produce it): defence in depth.
|
|
['failed_with_pending_issue', { transactionStatus: 'failed', transactionStatusRaw: '交易失败', issues: ['transaction_failed', 'transaction_pending'] }],
|
|
// Same payer and payee, different amount: possibly a misread of this payment.
|
|
['same_accounts_other_amount', { amount: '1' }],
|
|
// A voided "revoked" slip is not a clear failure: it keeps blocking.
|
|
['voided_revoked', { transactionStatus: 'failed', statusWordings: ['已撤销'] }],
|
|
['voided_failed_plus_revoked', { transactionStatus: 'failed', statusWordings: ['交易失败', '已撤销'] }],
|
|
];
|
|
const voidedAmounts = { before_ocr: '17', unreadable: '18', ambiguous_status: '19', failed_with_pending_issue: '21', same_accounts_other_amount: '22', voided_revoked: '23', voided_failed_plus_revoked: '24' };
|
|
for (const [label, overrides] of voidedBlocks) {
|
|
const blocked = await s1Batch(`${voidedAmounts[label]}.00`);
|
|
const cents = `${voidedAmounts[label]}00`;
|
|
let voidedId;
|
|
if (overrides === null) {
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
const raw = await app.inject({ method: 'POST', url: `/api/payment-batches/${blocked.id}/receipt-attachments`,
|
|
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
|
voidedId = raw.json().data.items[0].id;
|
|
} else {
|
|
voidedId = (await receipt(blocked.id, cents, `SYNTHETIC-S1-${label}`, { payeeAccount: s1Lender.bankAccount, ...overrides },
|
|
{ normalizedPage: label === 'failed_with_pending_issue' })).attachment.id;
|
|
}
|
|
await call(finance, 'POST', `/payment-batches/${blocked.id}/receipt-attachments/${voidedId}/void`, { reason: '合成作废' });
|
|
const laterFail = await failReceipt(blocked.id, cents, `SYNTHETIC-S1-AFTER-${label}`);
|
|
await attestSubmission(blocked.id);
|
|
await confirmFailure(blocked.id, laterFail.attachment.id, 409, 'loan.voided_receipt_unresolved');
|
|
}
|
|
checks.push('voided_without_readable_proof_fails_closed');
|
|
// A voided, readable success receipt of a different payment no longer blocks;
|
|
// attestation independence; any later add or withdraw invalidates approval.
|
|
const misupload = await s1Batch('14.00');
|
|
const otherPayment = await receipt(misupload.id, '9900', 'SYNTHETIC-S1-OTHER-PAYMENT', { payeeAccount: '9'.repeat(16) });
|
|
await call(finance, 'POST', `/payment-batches/${misupload.id}/receipt-attachments/${otherPayment.attachment.id}/void`, { reason: '合成误传他笔回单' });
|
|
const misuploadFail = await failReceipt(misupload.id, '1400', 'SYNTHETIC-S1-MISUPLOAD');
|
|
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成无提交清单' }, 409, 'loan.submission_required');
|
|
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'online_banking_import',
|
|
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-NO-EXPORT', note: '合成网银导入无导出' }, 409, 'loan.submission_export_invalid');
|
|
const registrarDual = await actor(['finance', 'manager']);
|
|
const firstSubmission = await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'manual_entry',
|
|
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYNTHETIC-S1-MISUPLOAD', note: '合成手工提交登记' });
|
|
await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'manual_entry',
|
|
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-MANAGER', note: '合成经理不能登记' }, 403);
|
|
// Registered but not yet attested: still refused.
|
|
await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified');
|
|
const misAttest = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成提交清单完整' });
|
|
await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified');
|
|
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成重复提交清单' }, 409, 'loan.submission_attestation_exists');
|
|
await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${misAttest.id}/review`, { decision: 'approve', reason: '合成登记人自审' }, 403);
|
|
await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${misAttest.id}/review`, { decision: 'approve', reason: '合成清单复核' });
|
|
const extra = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions`, { channel: 'other',
|
|
submittedAt: new Date().toISOString(), bankInstructionRef: 'SYN-S1-EXTRA', note: '合成补登第二次提交' });
|
|
await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.multiple_submissions_unresolved');
|
|
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成误登撤回' });
|
|
await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-bank-submissions/${extra.id}/withdraw`, { reason: '合成重复撤回' }, 409);
|
|
// The unreviewed add-then-withdraw does not revive the earlier approval.
|
|
assert.equal((await detail(misupload.id)).loanRepayment.bankSubmissions.attestation.stale, true);
|
|
await confirmFailure(misupload.id, misuploadFail.attachment.id, 409, 'loan.submission_list_unverified');
|
|
const reAttest = await call(finance, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation`, { note: '合成撤回后重新确认清单' });
|
|
assert.equal((await prisma.loanBankSubmissionAttestation.findUniqueOrThrow({ where: { id: misAttest.id } })).status, 'superseded');
|
|
await call(manager, 'POST', `/payment-batches/${misupload.id}/loan-submission-attestation/${reAttest.id}/review`, { decision: 'approve', reason: '合成重新复核' });
|
|
// The submission registrar (even with manager rights) cannot confirm the failure.
|
|
await call(registrarDual, 'POST', `/payment-batches/${misupload.id}/bank-failure-review`, { attachmentId: misuploadFail.attachment.id }, 403);
|
|
await confirmFailure(misupload.id, misuploadFail.attachment.id);
|
|
await call(finance, 'POST', `/payment-batches/${misupload.id}/cancel`, { reason: '合成确认失败后释放' });
|
|
assert.ok(firstSubmission.id);
|
|
checks.push('voided_other_payment_ignored_attestation_independent_and_any_list_change_invalidates');
|
|
// A pre-existing batch carries only a known export count: never treated as
|
|
// a real export or submission; failure confirmation stays blocked.
|
|
const legacyBatch = await s1Batch('15.00');
|
|
await prisma.$executeRaw`INSERT INTO loan_legacy_export_summaries (payment_batch_id, known_export_count) VALUES (${legacyBatch.id}::uuid, 2)`;
|
|
const legacyFail = await failReceipt(legacyBatch.id, '1500', 'SYNTHETIC-S1-LEGACY');
|
|
const legacyState = (await detail(legacyBatch.id)).loanRepayment.bankSubmissions;
|
|
assert.equal(legacyState.legacy.knownExportCount, 2); assert.equal(legacyState.exports.length, 0);
|
|
await confirmFailure(legacyBatch.id, legacyFail.attachment.id, 409, 'loan.submission_history_unverified');
|
|
checks.push('legacy_export_summary_keeps_known_count_and_blocks_failure');
|
|
// Failure confirmation is a post-payment fact: a departed lender employee
|
|
// does not block it; the confirming reviewer is still judged currently.
|
|
const departingLender = await actor(['employee']);
|
|
const staffFailLender = await verifiedLender('合成出借人壬', departingLender.id, '2', '2468024680246802');
|
|
const staffFailLoan = await confirmedLoan(staffFailLender.id, '100.00', '合成壬借入');
|
|
const staffFail = await repay(staffFailLender.id, staffFailLoan.id, '16.00');
|
|
await call(manager, 'POST', `/payment-batches/${staffFail.id}/review`, {});
|
|
const staffFailReceipt = await receipt(staffFail.id, '1600', 'SYNTHETIC-S1-DEPARTED', { payeeAccount: staffFailLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
|
|
await attestSubmission(staffFail.id);
|
|
await setStatus(departingLender, 'archived');
|
|
await call(manager, 'POST', `/payment-batches/${staffFail.id}/bank-failure-review`, { attachmentId: staffFailReceipt.attachment.id });
|
|
await setStatus(departingLender, 'active');
|
|
checks.push('failure_confirmation_after_lender_employee_departure');
|
|
// Failure wording: only the four "failed" wordings prove no money left;
|
|
// revoked/reversed/returned, incomplete or mismatched slips are refused.
|
|
const wordingCases = [
|
|
['转账失败', {}, 200, null],
|
|
['付款失败', {}, 200, null],
|
|
['支付失败', {}, 200, null],
|
|
// Two status fields that both normalize to "failed": refused in either order.
|
|
['交易失败', { statusWordings: ['交易失败', '已撤销'] }, 409, 'loan.bank_state_unsafe'],
|
|
['已撤销', { statusWordings: ['已撤销', '交易失败'] }, 409, 'loan.bank_state_unsafe'],
|
|
['已撤销', {}, 409, 'loan.bank_state_unsafe'],
|
|
['已冲正', {}, 409, 'loan.bank_state_unsafe'],
|
|
['已退汇', {}, 409, 'loan.bank_state_unsafe'],
|
|
['交易失败', { reference: null }, 409, 'loan.bank_state_unsafe'],
|
|
['交易失败', { pdfPages: 2 }, 409, 'loan.bank_state_unsafe'],
|
|
['交易失败', { amount: '1' }, 409, 'loan.bank_state_unsafe'],
|
|
// Explicitly synthetic: an extra issue besides the failure itself.
|
|
['交易失败', { normalizedPage: true, transactionStatusRaw: '交易失败', issues: ['transaction_failed', 'payerAccount_ambiguous'] }, 409, 'loan.bank_state_unsafe'],
|
|
];
|
|
let wordingAmount = 30;
|
|
for (const [wording, variant, status, reason] of wordingCases) {
|
|
wordingAmount += 1;
|
|
const item = await s1Batch(`${wordingAmount}.00`);
|
|
const { pdfPages, normalizedPage, ...slip } = variant;
|
|
const failed = await receipt(item.id, `${wordingAmount}00`, `SYNTHETIC-WORDING-${wordingAmount}`,
|
|
{ payeeAccount: s1Lender.bankAccount, transactionStatus: 'failed', statusWordings: [wording], ...slip }, { pdfPages: pdfPages ?? 1, normalizedPage });
|
|
assert.notEqual(failed.result.status, 'matched');
|
|
await attestSubmission(item.id);
|
|
await confirmFailure(item.id, failed.attachment.id, status, reason);
|
|
}
|
|
checks.push('only_four_failure_wordings_with_complete_single_page_slip_confirm_failure');
|
|
// ===== S3: bank terminal facts and "not paid" release =====
|
|
config.loanReleaseWaitDays = 0;
|
|
const s3Lender = await verifiedLender('合成出借人癸', null, '3', '3579135791357913');
|
|
const s3Loan = await confirmedLoan(s3Lender.id, '1000.00', '合成癸借入');
|
|
const s3Batch = async (amount) => { const item = await repay(s3Lender.id, s3Loan.id, amount); await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); return item; };
|
|
const s3Exports = async (batchId) => prisma.paymentOrderExport.findMany({ where: { payment_batch_id: batchId }, orderBy: { sequence: 'asc' } });
|
|
const register = (batchId, ref, exportId, who = finance, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-bank-submissions`, {
|
|
...(exportId ? { exportId, channel: 'online_banking_import' } : { channel: 'manual_entry' }),
|
|
submittedAt: new Date().toISOString(), bankInstructionRef: ref, note: '合成银行提交登记' }, status, reason);
|
|
const attest = async (batchId) => {
|
|
const proposal = await call(finance, 'POST', `/payment-batches/${batchId}/loan-submission-attestation`, { note: '合成提交清单完整' });
|
|
await call(manager, 'POST', `/payment-batches/${batchId}/loan-submission-attestation/${proposal.id}/review`, { decision: 'approve', reason: '合成清单复核' });
|
|
};
|
|
// Each synthetic bank letter is a distinct document (unique title).
|
|
const bankLetterPdf = async () => { const doc = await PDFDocument.create(); doc.setTitle(`合成银行回复 ${randomUUID()}`); doc.addPage([200, 200]); return Buffer.from(await doc.save()); };
|
|
async function uploadEvidence(batchId, bytes = null) {
|
|
const letter = bytes ?? await bankLetterPdf();
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-terminal-evidences`,
|
|
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), letter, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
|
assert.equal(response.statusCode, 200, `terminal evidence upload ${response.body.slice(0, 120)}`);
|
|
return response.json().data.id;
|
|
}
|
|
const proposeFact = (batchId, body, status = 200, reason) => call(finance, 'POST', `/payment-batches/${batchId}/loan-terminal-facts`, { note: '合成银行终态说明', ...body }, status, reason);
|
|
const reviewFact = (batchId, factId, who = manager, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-terminal-facts/${factId}/review`, { decision: 'approve', reason: '合成终态复核' }, status, reason);
|
|
const confirmNotPaid = (batchId, who = manager, status = 200, reason) => call(who, 'POST', `/payment-batches/${batchId}/loan-not-paid/review`, {}, status, reason);
|
|
const cancelBatch = (batchId, status = 200, reason) => call(finance, 'POST', `/payment-batches/${batchId}/cancel`, { reason: '合成确认未付款后释放' }, status, reason);
|
|
const s3Available = async () => Number((await call(finance, 'GET', `/company-loans/${s3Loan.id}`)).available);
|
|
const s3Slip = (batchId, cents, ref, status, wording) => receipt(batchId, cents, ref, { payeeAccount: s3Lender.bankAccount, transactionStatus: status,
|
|
...(wording ? { statusWordings: [wording] } : {}) });
|
|
|
|
// Two exports, two submissions: each needs its own terminal fact; one
|
|
// instruction's terminal state does not end the other.
|
|
const multi = await s3Batch('41.00');
|
|
await exportOrder(multi.id); await exportOrder(multi.id);
|
|
const [mE1, mE2] = await s3Exports(multi.id);
|
|
const mS1 = await register(multi.id, 'S3-MULTI-1', mE1.id); const mS2 = await register(multi.id, 'S3-MULTI-2', mE2.id, registrarDual);
|
|
const conflictBatch = await s3Batch('42.00');
|
|
await register(conflictBatch.id, 's3-multi-1', null, finance, 409, 'loan.instruction_ref_conflict');
|
|
await attest(multi.id);
|
|
await confirmNotPaid(multi.id, manager, 409, 'loan.terminal_coverage_incomplete');
|
|
const evidence1 = await uploadEvidence(multi.id);
|
|
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: multi.loanRepaymentId } })).bank_result_state, 'unknown');
|
|
assert.equal(await prisma.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: multi.id } }), 0, 'evidence is not a payment receipt');
|
|
const fact1 = await proposeFact(multi.id, { submissionId: mS1.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: evidence1 });
|
|
await proposeFact(multi.id, { submissionId: mS1.id, kind: 'termination_evidence', terminalStatus: 'revoked', evidenceId: evidence1 }, 409, 'loan.terminal_fact_exists');
|
|
await reviewFact(multi.id, fact1.id, finance, 403);
|
|
await reviewFact(multi.id, fact1.id);
|
|
await confirmNotPaid(multi.id, manager, 409, 'loan.terminal_coverage_incomplete');
|
|
const mFail = await s3Slip(multi.id, '4100', 'S3-MULTI-2', 'failed');
|
|
// A failed slip of a different instruction cannot cover this submission.
|
|
await proposeFact(multi.id, { submissionId: mS1.id, kind: 'failure_receipt', receiptAttachmentId: mFail.attachment.id }, 409, 'loan.terminal_fact_exists');
|
|
const fact2 = await proposeFact(multi.id, { submissionId: mS2.id, kind: 'failure_receipt', receiptAttachmentId: mFail.attachment.id });
|
|
await reviewFact(multi.id, fact2.id);
|
|
await confirmNotPaid(multi.id, registrarDual, 403);
|
|
const beforeMulti = await s3Available();
|
|
await confirmNotPaid(multi.id);
|
|
await cancelBatch(multi.id);
|
|
assert.equal(await s3Available(), beforeMulti + 41);
|
|
checks.push('s3_each_submission_needs_own_terminal_fact_then_release');
|
|
|
|
// A failed slip whose reference is another instruction is rejected for this submission.
|
|
const wrongRef = await s3Batch('43.00');
|
|
await exportOrder(wrongRef.id);
|
|
const [wE] = await s3Exports(wrongRef.id);
|
|
const wS = await register(wrongRef.id, 'S3-WRONGREF-1', wE.id);
|
|
await attest(wrongRef.id);
|
|
const wFail = await s3Slip(wrongRef.id, '4300', 'S3-SOMETHING-ELSE', 'failed');
|
|
await proposeFact(wrongRef.id, { submissionId: wS.id, kind: 'failure_receipt', receiptAttachmentId: wFail.attachment.id }, 409, 'loan.terminal_fact_invalid');
|
|
checks.push('s3_failure_slip_bound_to_submission_instruction_ref');
|
|
|
|
// Export not linked to any submission blocks release.
|
|
const unlinked = await s3Batch('44.00');
|
|
await exportOrder(unlinked.id);
|
|
const uS = await register(unlinked.id, 'S3-UNLINKED-1', null);
|
|
await attest(unlinked.id);
|
|
const uEv = await uploadEvidence(unlinked.id);
|
|
const uFact = await proposeFact(unlinked.id, { submissionId: uS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: uEv });
|
|
await reviewFact(unlinked.id, uFact.id);
|
|
await confirmNotPaid(unlinked.id, manager, 409, 'loan.export_unlinked');
|
|
checks.push('s3_unlinked_export_blocks_release');
|
|
|
|
// Pending slip of the same instruction is settled by its terminal fact,
|
|
// whether still active or already voided; another instruction cannot.
|
|
for (const voidFirst of [false, true]) {
|
|
const pend = await s3Batch(voidFirst ? '46.00' : '45.00');
|
|
await exportOrder(pend.id); await exportOrder(pend.id);
|
|
const [pE1, pE2] = await s3Exports(pend.id);
|
|
const ref1 = voidFirst ? 'S3-PEND-V-1' : 'S3-PEND-A-1'; const ref2 = voidFirst ? 'S3-PEND-V-2' : 'S3-PEND-A-2';
|
|
const pS1 = await register(pend.id, ref1, pE1.id); const pS2 = await register(pend.id, ref2, pE2.id);
|
|
const pending = await s3Slip(pend.id, voidFirst ? '4600' : '4500', ref1, 'pending');
|
|
if (voidFirst) await call(finance, 'POST', `/payment-batches/${pend.id}/receipt-attachments/${pending.attachment.id}/void`, { reason: '合成处理中回单作废' });
|
|
await attest(pend.id);
|
|
const pEv = await uploadEvidence(pend.id);
|
|
// Another submission's terminal fact cannot settle this pending slip.
|
|
await proposeFact(pend.id, { submissionId: pS2.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pEv,
|
|
resolvesAttachmentIds: [pending.attachment.id] }, 409, 'loan.terminal_fact_invalid');
|
|
const pF1 = await proposeFact(pend.id, { submissionId: pS1.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pEv,
|
|
resolvesAttachmentIds: [pending.attachment.id] });
|
|
if (voidFirst) {
|
|
const url = `/api/payment-batches/${pend.id}/receipt-attachments/${pending.attachment.id}/file`;
|
|
assert.equal((await app.inject({ method: 'GET', url, headers: manager.headers })).statusCode, 409);
|
|
const query = '?purpose=loan_reconciliation&reason=' + encodeURIComponent('合成复核已作废的处理中银行事实');
|
|
const historical = await app.inject({ method: 'GET', url: url + query, headers: manager.headers });
|
|
assert.equal(historical.statusCode, 200); assert.match(historical.headers['cache-control'], /no-store/);
|
|
assert.equal((await app.inject({ method: 'GET', url: url + query, headers: ordinary.headers })).statusCode, 403);
|
|
assert.equal((await app.inject({ method: 'GET', url: url + '?purpose=loan_reconciliation', headers: manager.headers })).statusCode, 400);
|
|
assert.ok(await prisma.auditLog.count({ where: { target_id: pend.id, action: 'PAYMENT_BATCH_RECEIPT_DOWNLOADED' } }));
|
|
}
|
|
await reviewFact(pend.id, pF1.id);
|
|
const pF2 = await proposeFact(pend.id, { submissionId: pS2.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: pEv });
|
|
await reviewFact(pend.id, pF2.id);
|
|
await confirmNotPaid(pend.id);
|
|
await cancelBatch(pend.id);
|
|
}
|
|
// Without a resolving fact the pending slip keeps blocking.
|
|
const pendBlocked = await s3Batch('47.00');
|
|
await exportOrder(pendBlocked.id);
|
|
const [pbE] = await s3Exports(pendBlocked.id);
|
|
const pbS = await register(pendBlocked.id, 'S3-PEND-B-1', pbE.id);
|
|
await s3Slip(pendBlocked.id, '4700', 'S3-PEND-B-1', 'pending');
|
|
await attest(pendBlocked.id);
|
|
const pbEv = await uploadEvidence(pendBlocked.id);
|
|
const pbF = await proposeFact(pendBlocked.id, { submissionId: pbS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pbEv });
|
|
await reviewFact(pendBlocked.id, pbF.id);
|
|
await confirmNotPaid(pendBlocked.id, manager, 409, 'loan.receipt_unresolved_for_release');
|
|
checks.push('s3_pending_slip_resolved_only_by_same_instruction_terminal_fact');
|
|
|
|
// Internal wait period.
|
|
const waiting = await s3Batch('48.00');
|
|
await exportOrder(waiting.id);
|
|
const [waE] = await s3Exports(waiting.id);
|
|
const waS = await register(waiting.id, 'S3-WAIT-1', waE.id);
|
|
await attest(waiting.id);
|
|
const waEv = await uploadEvidence(waiting.id);
|
|
const waF = await proposeFact(waiting.id, { submissionId: waS.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: waEv });
|
|
await reviewFact(waiting.id, waF.id);
|
|
config.loanReleaseWaitDays = 3;
|
|
await confirmNotPaid(waiting.id, manager, 409, 'loan.release_wait_period');
|
|
config.loanReleaseWaitDays = 0;
|
|
await confirmNotPaid(waiting.id);
|
|
// Final release re-checks: evidence voided after confirmation blocks the cancel.
|
|
await call(finance, 'POST', `/payment-batches/${waiting.id}/loan-terminal-evidences/${waEv}/void`, { reason: '合成证据作废' });
|
|
await cancelBatch(waiting.id, 409, 'loan.terminal_coverage_incomplete');
|
|
checks.push('s3_wait_period_and_voided_evidence_blocks_final_release');
|
|
|
|
// Final release re-checks in the cancel transaction, with deterministic
|
|
// interleaving against a late receipt and a new submission (batch lock).
|
|
const s3LockPool = new LockPool({ ...pool.options, max: 2 });
|
|
const s3Waiters = async () => Number((await s3LockPool.query(
|
|
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND datname = current_database()")).rows[0].n);
|
|
const s3Waiting = async (count) => { for (let i = 0; i < 300; i++) { if (await s3Waiters() >= count) return; await new Promise((r) => setTimeout(r, 10)); } throw new Error('not queued'); };
|
|
const readyToRelease = async (amount, ref) => {
|
|
const item = await s3Batch(amount);
|
|
await exportOrder(item.id);
|
|
const [e] = await s3Exports(item.id);
|
|
const sub = await register(item.id, ref, e.id);
|
|
await attest(item.id);
|
|
const ev = await uploadEvidence(item.id);
|
|
const f = await proposeFact(item.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: ev });
|
|
await reviewFact(item.id, f.id);
|
|
await confirmNotPaid(item.id);
|
|
return item;
|
|
};
|
|
const lateUpload = (batchId, ref) => {
|
|
const boundary = `synthetic-${randomUUID()}`;
|
|
return app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
|
|
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="late.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), onePagePdf, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
|
};
|
|
const lateSubmission = (batchId, ref) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-bank-submissions`, headers: finance.headers,
|
|
payload: { channel: 'other', submittedAt: new Date().toISOString(), bankInstructionRef: ref, note: '合成晚到的另一次提交' } });
|
|
const cancelRequest = (batchId) => app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/cancel`, headers: finance.headers, payload: { reason: '合成并发释放' } });
|
|
try {
|
|
let n = 50;
|
|
for (const [label, makeLate] of [['receipt', lateUpload], ['submission', lateSubmission]]) {
|
|
for (const order of ['release_first', 'late_first']) {
|
|
n += 1;
|
|
const item = await readyToRelease(`${n}.00`, `S3-RACE-${label}-${order}`.toUpperCase());
|
|
const holder = await s3LockPool.connect();
|
|
let released; let late;
|
|
try {
|
|
await holder.query('BEGIN');
|
|
await holder.query('SELECT id FROM payment_batches WHERE id = $1 FOR UPDATE', [item.id]);
|
|
if (order === 'release_first') { released = cancelRequest(item.id); await s3Waiting(1); late = makeLate(item.id, `S3-LATE-${label}-${n}`.toUpperCase()); await s3Waiting(2); }
|
|
else { late = makeLate(item.id, `S3-LATE-${label}-${n}`.toUpperCase()); await s3Waiting(1); released = cancelRequest(item.id); await s3Waiting(2); }
|
|
await holder.query('COMMIT');
|
|
[released, late] = [await released, await late];
|
|
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
|
|
if (order === 'release_first') {
|
|
assert.equal(released.statusCode, 200, `${label} release first`);
|
|
assert.equal(late.statusCode, 409, `${label} after release is refused`);
|
|
} else {
|
|
assert.equal(late.statusCode, 200, `${label} late first`);
|
|
assert.equal(released.statusCode, 409, `${label} release after late ${released.body.slice(0, 100)}`);
|
|
}
|
|
}
|
|
}
|
|
} finally { await s3LockPool.end(); }
|
|
// Drain the OCR queue of the late uploads (recognized as unreadable) so later
|
|
// scenarios process their own receipts.
|
|
for (let i = 0; i < 10; i++) {
|
|
const drained = await runReceiptOcrOnce({ prisma, config, recognize: async () => ({ status: 'needs_review', pagesExpected: 1, pagesProcessed: 1,
|
|
pages: [{ pageNumber: 1, amount: null, currency: null, payerAccount: null, payeeAccount: null, transactionStatus: null, reference: null, issues: ['amount_unresolved'] }] }) });
|
|
if (drained.status === 'idle') break;
|
|
}
|
|
checks.push('s3_final_release_rechecks_and_serializes_with_late_receipt_and_submission');
|
|
|
|
// The single-failure path also re-checks at release: a late receipt blocks it.
|
|
const failPath = await s3Batch('60.00');
|
|
const fpFail = await s3Slip(failPath.id, '6000', 'S3-FAILPATH-1', 'failed');
|
|
await attestSubmission(failPath.id);
|
|
await call(manager, 'POST', `/payment-batches/${failPath.id}/bank-failure-review`, { attachmentId: fpFail.attachment.id });
|
|
await s3Slip(failPath.id, '6000', 'S3-FAILPATH-LATE', 'success');
|
|
await cancelBatch(failPath.id, 409, 'loan.bank_state_unsafe');
|
|
checks.push('s3_failure_path_rechecks_at_release');
|
|
// The single failure path also honours the internal wait after the last export,
|
|
// both when confirming and again at the final cancel.
|
|
const failWait = await s3Batch('68.00');
|
|
await exportOrder(failWait.id);
|
|
const fwFail = await s3Slip(failWait.id, '6800', 'S3-FAILWAIT-1', 'failed');
|
|
await attestSubmission(failWait.id, { ref: 'S3-FAILWAIT-1' });
|
|
config.loanReleaseWaitDays = 3;
|
|
await call(manager, 'POST', `/payment-batches/${failWait.id}/bank-failure-review`, { attachmentId: fwFail.attachment.id }, 409, 'loan.release_wait_period');
|
|
config.loanReleaseWaitDays = 0;
|
|
await call(manager, 'POST', `/payment-batches/${failWait.id}/bank-failure-review`, { attachmentId: fwFail.attachment.id });
|
|
config.loanReleaseWaitDays = 3;
|
|
await cancelBatch(failWait.id, 409, 'loan.release_wait_period');
|
|
config.loanReleaseWaitDays = 0;
|
|
await cancelBatch(failWait.id);
|
|
checks.push('s3_failure_path_honours_wait_period_at_confirm_and_cancel');
|
|
|
|
// B1: registering a submission ends "never sent"; it cannot be cancelled directly.
|
|
const neverSent = await s3Batch('61.00');
|
|
await register(neverSent.id, 'S3-NEVERSENT-1', null);
|
|
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: neverSent.loanRepaymentId } })).bank_result_state, 'unknown');
|
|
await cancelBatch(neverSent.id, 409, 'loan.bank_state_unsafe');
|
|
await register(neverSent.id, 's3-neversent-1', null, finance, 409, 'loan.instruction_ref_conflict');
|
|
checks.push('s3_submission_registration_ends_never_sent_and_refs_unique_within_batch');
|
|
|
|
// B3: reviewers can view the evidence they approve, with a reason and audit.
|
|
const viewEvidence = await prisma.loanBankTerminalEvidence.findFirstOrThrow({ where: { payment_batch_id: multi.id } });
|
|
const viewPath = `/api/payment-batches/${multi.id}/loan-terminal-evidences/${viewEvidence.id}/file`;
|
|
assert.equal((await app.inject({ method: 'GET', url: viewPath, headers: manager.headers })).statusCode, 400, 'reason required');
|
|
const viewed = await app.inject({ method: 'GET', url: viewPath, headers: { ...manager.headers, 'x-access-reason': encodeURIComponent('合成复核查看证据') } });
|
|
assert.equal(viewed.statusCode, 200);
|
|
assert.equal(require('node:crypto').createHash('sha256').update(viewed.rawPayload).digest('hex'), viewEvidence.file_sha256, 'evidence bytes served unchanged');
|
|
assert.equal(viewed.headers['cache-control'], 'no-store');
|
|
assert.equal((await app.inject({ method: 'GET', url: viewPath, headers: { ...ordinary.headers, 'x-access-reason': 'x1x' } })).statusCode, 403);
|
|
assert.ok(await prisma.auditLog.count({ where: { action: 'LOAN_TERMINAL_EVIDENCE_VIEWED' } }) >= 1);
|
|
checks.push('s3_terminal_evidence_viewable_by_reviewer_with_reason_and_audit');
|
|
|
|
// Non-blocking hardening: no export after not-paid; no void after release.
|
|
await call(finance, 'POST', `/payment-batches/${multi.id}/loan-terminal-evidences/${viewEvidence.id}/void`, { reason: '合成释放后作废' }, 409, 'loan.payment_not_ready');
|
|
const noReexport = await readyToRelease('62.00', 'S3-NOREEXPORT-1');
|
|
await exportOrder(noReexport.id, 409, 'loan.bank_state_unsafe');
|
|
await cancelBatch(noReexport.id);
|
|
await exportOrder(noReexport.id, 409, 'loan.payment_not_ready');
|
|
checks.push('s3_no_reexport_after_conclusion_or_release_and_no_void_after_release');
|
|
|
|
// B2: with same-amount competitors a failure_receipt terminal fact needs a
|
|
// per-submission failure attribution; one failed transaction backs only one batch.
|
|
const compA = await s3Batch('63.00'); const compB = await s3Batch('63.00');
|
|
for (const item of [compA, compB]) await exportOrder(item.id);
|
|
const [cAE] = await s3Exports(compA.id);
|
|
const cAS = await register(compA.id, 'S3-COMP-A', cAE.id);
|
|
await attest(compA.id);
|
|
const cAFail = await s3Slip(compA.id, '6300', 'S3-COMP-A', 'failed');
|
|
await proposeFact(compA.id, { submissionId: cAS.id, kind: 'failure_receipt', receiptAttachmentId: cAFail.attachment.id }, 409, 'loan.receipt_attribution_required');
|
|
const perSubmission = await attribute(finance, compA.id, { kind: 'failure', attachmentId: cAFail.attachment.id, submissionId: cAS.id,
|
|
basisKind: 'bank_instruction_ref', basisNote: '合成失败回单流水号即本批次登记的指令号', exclusionNote: '合成同额批次未登记该指令号' });
|
|
await reviewAttribution(independent, compA.id, perSubmission.id, 'approve');
|
|
const cAFact = await proposeFact(compA.id, { submissionId: cAS.id, kind: 'failure_receipt', receiptAttachmentId: cAFail.attachment.id });
|
|
await reviewFact(compA.id, cAFact.id);
|
|
await confirmNotPaid(compA.id);
|
|
await cancelBatch(compA.id);
|
|
// The same failed transaction cannot also release batch B through the single path.
|
|
const cBFail = await s3Slip(compB.id, '6300', 'S3-COMP-A', 'failed');
|
|
const [cBE] = await s3Exports(compB.id);
|
|
await register(compB.id, 'S3-COMP-B', cBE.id); await attest(compB.id);
|
|
// Bound to its own instruction, B cannot even attribute A's failed slip; the
|
|
// evidence-use registry stays as defence in depth behind this binding.
|
|
await attribute(finance, compB.id, { kind: 'failure', attachmentId: cBFail.attachment.id, basisKind: 'bank_instruction_ref',
|
|
basisNote: '合成试图复用他批失败回单', exclusionNote: '合成试图复用他批失败回单' }, 409, 'loan.terminal_fact_invalid');
|
|
await call(manager, 'POST', `/payment-batches/${compB.id}/bank-failure-review`, { attachmentId: cBFail.attachment.id }, 409, 'loan.terminal_fact_invalid');
|
|
assert.equal((await prisma.loanFailureEvidenceUse.findMany({ where: { payment_batch_id: compA.id } })).length, 1, 'A registered its failure evidence use');
|
|
checks.push('s3_failure_fact_needs_attribution_with_competitors_and_failure_evidence_backs_one_batch');
|
|
// N1: a failed slip of another batch's instruction cannot release this batch
|
|
// through the single path, even when that batch only registered a submission.
|
|
const p1A = await s3Batch('64.00'); const p1B = await s3Batch('64.00');
|
|
await exportOrder(p1A.id);
|
|
await register(p1B.id, 'S3-P1-B-REF', null);
|
|
const p1Fail = await s3Slip(p1A.id, '6400', 'S3-P1-B-REF', 'failed');
|
|
await attestSubmission(p1A.id, { ref: 'S3-P1-A-REF' });
|
|
await call(manager, 'POST', `/payment-batches/${p1A.id}/bank-failure-review`, { attachmentId: p1Fail.attachment.id }, 409, 'loan.terminal_fact_invalid');
|
|
assert.ok((await detail(p1A.id)).loanRepayment.receiptAttribution.competitors.some((item) => item.id === p1B.id), 'submission-only batch is a competitor');
|
|
checks.push('s3_single_path_failure_slip_bound_to_own_instruction_and_submission_counts_as_competitor');
|
|
// Batch-scope and submission-scope failure attributions can coexist.
|
|
const both = await s3Batch('65.00'); const bothRival = await s3Batch('65.00');
|
|
await exportOrder(both.id); await exportOrder(bothRival.id);
|
|
const bothFail = await s3Slip(both.id, '6500', 'S3-BOTH-REF', 'failed');
|
|
const { submission: bothSub } = await attestSubmission(both.id, { ref: 'S3-BOTH-REF' });
|
|
const bothBatchScope = await attribute(finance, both.id, { kind: 'failure', attachmentId: bothFail.attachment.id, basisKind: 'bank_instruction_ref',
|
|
basisNote: '合成失败回单流水号即本批次指令号', exclusionNote: '合成同额批次指令号不同' });
|
|
await reviewAttribution(independent, both.id, bothBatchScope.id, 'approve');
|
|
const bothSubmissionScope = await attribute(finance, both.id, { kind: 'failure', attachmentId: bothFail.attachment.id, submissionId: bothSub.id,
|
|
basisKind: 'bank_instruction_ref', basisNote: '合成失败回单流水号即本批次指令号', exclusionNote: '合成同额批次指令号不同' });
|
|
await reviewAttribution(independent, both.id, bothSubmissionScope.id, 'approve');
|
|
checks.push('s3_batch_and_submission_scope_failure_attributions_coexist');
|
|
// A withdrawn instruction reference cannot be re-registered on another batch,
|
|
// and the same bank letter cannot be uploaded to two batches.
|
|
const wdA = await s3Batch('66.00'); const wdB = await s3Batch('67.00');
|
|
const wdSub = await register(wdA.id, 'S3-WITHDRAWN-REF', null);
|
|
await call(finance, 'POST', `/payment-batches/${wdA.id}/loan-bank-submissions/${wdSub.id}/withdraw`, { reason: '合成撤回指令' });
|
|
await register(wdB.id, 'S3-WITHDRAWN-REF', null, finance, 409, 'loan.instruction_ref_conflict');
|
|
const sharedLetter = await bankLetterPdf();
|
|
await uploadEvidence(wdA.id, sharedLetter);
|
|
const dupBoundary = `synthetic-${randomUUID()}`;
|
|
const dupEvidence = await app.inject({ method: 'POST', url: `/api/payment-batches/${wdB.id}/loan-terminal-evidences`,
|
|
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${dupBoundary}` },
|
|
payload: Buffer.concat([Buffer.from(`--${dupBoundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), sharedLetter, Buffer.from(`\r\n--${dupBoundary}--\r\n`)]) });
|
|
assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().details?.reasonCode, 'loan.terminal_evidence_invalid');
|
|
checks.push('s3_withdrawn_ref_and_bank_letter_cannot_move_to_another_batch');
|
|
|
|
// Evidence separation and history.
|
|
const sepEvidence = await prisma.loanBankTerminalEvidence.findFirstOrThrow({ where: { payment_batch_id: multi.id } });
|
|
await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: sepEvidence.id }, data: { original_name: '改名.pdf' } }));
|
|
await assert.rejects(prisma.loanBankTerminalFact.update({ where: { id: fact1.id }, data: { terminal_status: 'failed' } }));
|
|
await assert.rejects(prisma.loanBankTerminalFact.delete({ where: { id: fact1.id } }));
|
|
checks.push('s3_evidence_and_fact_history_immutable');
|
|
checks.push(...await require('./company-loan-recovery-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
|
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
|
checks.push(...await require('./company-loan-not-submitted-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
|
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
|
checks.push(...await require('./company-loan-freeze-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
|
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
|
checks.push(...await require('./company-loan-bank-results-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
|
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
|
const relatedParty = await runRelated();
|
|
checks.push(...relatedParty.checks);
|
|
delete config.loanReleaseWaitDays;
|
|
|
|
// History guards in the database.
|
|
const anyExport = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: twice.id } });
|
|
await assert.rejects(prisma.paymentOrderExport.update({ where: { id: anyExport.id }, data: { sequence: 9 } }));
|
|
await assert.rejects(prisma.paymentOrderExport.delete({ where: { id: anyExport.id } }));
|
|
await assert.rejects(prisma.loanBankSubmission.update({ where: { id: firstSubmission.id }, data: { note: '合成改写提交' } }));
|
|
await assert.rejects(prisma.loanLegacyExportSummary.update({ where: { payment_batch_id: legacyBatch.id }, data: { known_export_count: 1 } }));
|
|
await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: misAttest.id }, data: { status: 'rejected' } }));
|
|
await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: reAttest.id }, data: { all_submission_ids: [] } }));
|
|
await assert.rejects(prisma.loanBankSubmissionAttestation.update({ where: { id: reAttest.id }, data: { export_ids: ['00000000-0000-0000-0000-000000000000'] } }));
|
|
await assert.rejects(prisma.loanBankSubmission.update({ where: { id: extra.id }, data: { status: 'active', withdrawn_at: null, withdrawn_reason: null } }));
|
|
checks.push('reconciliation_history_append_only_in_database');
|
|
|
|
// C2: old batch confirmed failed and cancelled; its late success receipt
|
|
// lands on a new same-amount batch, via OCR and via manual receipt review.
|
|
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 attestSubmission(c2old.id);
|
|
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 beforeC2 = await probeBalance();
|
|
const c2 = await receipt(c2new.id, '600', 'SYNTHETIC-C2-OLD-LATE-SUCCESS', { payeeAccount: probeLender.bankAccount });
|
|
assert.equal(c2.result.status, 'matched');
|
|
await call(finance, 'POST', `/payment-batches/${c2new.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
assert.equal(await probeBalance(), beforeC2, 'C2 late receipt must not settle the new batch');
|
|
const c2manualBatch = await repay(probeLender.id, probeLoan.id, '6.00');
|
|
await call(manager, 'POST', `/payment-batches/${c2manualBatch.id}/review`, {}); await exportOrder(c2manualBatch.id);
|
|
const beforeManual = await probeBalance();
|
|
const c2unclear = await receipt(c2manualBatch.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] },
|
|
{ document: { status: 'success', reference: 'SYNTHETIC-C2-MANUAL', payeeAccount: probeLender.bankAccount } });
|
|
const c2attachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: c2unclear.attachment.id } });
|
|
const c2path = `/payment-batches/${c2manualBatch.id}/receipt-attachments/${c2unclear.attachment.id}/manual-verification`;
|
|
const c2proposal = await call(dual, 'POST', c2path, { expectedVersion: c2attachment.receipt_ocr_version, reason: '合成人工核对',
|
|
pages: [{ pageNumber: 1, itemId: c2manualBatch.items[0].id, fields: Object.entries({ 小写金额: '6.00', 币别: 'CNY', 付款人账号: payerAccount,
|
|
收款人账号: probeLender.bankAccount, 凭证号: 'SYNTHETIC-C2-MANUAL', 单据状态: '交易成功' }).map(([Name, Value]) => ({ Name, Value })) }] });
|
|
const c2saved = c2proposal.item ?? c2proposal;
|
|
await call(manager, 'POST', `${c2path}/${c2saved.id}/review`, { expectedVersion: c2saved.version, decision: 'approve', reason: '合成人工复核' });
|
|
await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {}, 409, 'loan.receipt_attribution_required');
|
|
assert.equal(await probeBalance(), beforeManual, 'manual receipt review must not bypass attribution');
|
|
// The person who keyed the manual receipt cannot also approve its attribution.
|
|
const c2attribution = await attribute(finance, c2manualBatch.id, {});
|
|
await reviewAttribution(dual, c2manualBatch.id, c2attribution.id, 'approve', 403);
|
|
await reviewAttribution(independent, c2manualBatch.id, c2attribution.id, 'approve');
|
|
assert.equal((await call(finance, 'POST', `/payment-batches/${c2manualBatch.id}/execute`, {})).item.status, 'executed');
|
|
checks.push('late_receipt_of_cancelled_batch_blocked_via_ocr_and_manual_review');
|
|
|
|
// A failed receipt cannot release a reservation while competitors exist.
|
|
const c2failNew = await repay(probeLender.id, probeLoan.id, '6.00');
|
|
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/review`, {});
|
|
const c2failReceipt = await receipt(c2failNew.id, '600', 'SYNTHETIC-C2-AMBIGUOUS-FAILED', { payeeAccount: probeLender.bankAccount, transactionStatus: 'failed', issues: ['transaction_failed'] });
|
|
const c2failAttachment = c2failReceipt.attachment.id;
|
|
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }, 409, 'loan.submission_list_unverified');
|
|
const failureAttribution = { kind: 'failure', attachmentId: c2failAttachment, basisKind: 'bank_instruction_ref',
|
|
basisNote: '合成失败回单载明本批次网银指令号', exclusionNote: '合成其他同额批次的指令号与本回单不同' };
|
|
await attribute(finance, c2failNew.id, failureAttribution, 409, 'loan.submission_list_unverified');
|
|
await attestSubmission(c2failNew.id);
|
|
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment }, 409, 'loan.receipt_attribution_required');
|
|
await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '歧义失败不得释放' }, 409, 'loan.bank_state_unsafe');
|
|
checks.push('ambiguous_failed_receipt_cannot_release_reservation');
|
|
// S2: a reviewed failure attribution covering the attested submission unlocks it.
|
|
const failureProposal = await attribute(finance, c2failNew.id, failureAttribution);
|
|
await attribute(finance, c2failNew.id, failureAttribution, 409, 'loan.receipt_attribution_exists');
|
|
await reviewAttribution(finance, c2failNew.id, failureProposal.id, 'approve', 403);
|
|
await reviewAttribution(independent, c2failNew.id, failureProposal.id, 'approve');
|
|
const settlementSlot = (await detail(c2failNew.id)).loanRepayment.receiptAttribution.current;
|
|
assert.equal(settlementSlot, null, 'failure attribution does not occupy the settlement slot');
|
|
const beforeFailureRelease = (await call(finance, 'GET', `/company-loans/${probeLoan.id}`)).available;
|
|
await call(manager, 'POST', `/payment-batches/${c2failNew.id}/bank-failure-review`, { attachmentId: c2failAttachment });
|
|
await call(finance, 'POST', `/payment-batches/${c2failNew.id}/cancel`, { reason: '合成失败归属确认后释放' });
|
|
assert.equal(Number((await call(finance, 'GET', `/company-loans/${probeLoan.id}`)).available), Number(beforeFailureRelease) + 6);
|
|
checks.push('failure_attribution_reviewed_unlocks_ambiguous_failure_release');
|
|
|
|
// No competitor: attribution is neither required nor accepted.
|
|
const soloLender = await verifiedLender('合成出借人己', null, '1');
|
|
const soloLoan = await confirmedLoan(soloLender.id, '50.00', '合成己借入');
|
|
const solo = await repay(soloLender.id, soloLoan.id, '4.00');
|
|
await call(manager, 'POST', `/payment-batches/${solo.id}/review`, {}); await exportOrder(solo.id);
|
|
await receipt(solo.id, '400', 'SYNTHETIC-SOLO', { payeeAccount: soloLender.bankAccount });
|
|
await attribute(finance, solo.id, {}, 409, 'loan.receipt_attribution_not_required');
|
|
assert.equal((await call(finance, 'POST', `/payment-batches/${solo.id}/execute`, {})).item.status, 'executed');
|
|
checks.push('unique_amount_settles_without_attribution');
|
|
// Database guards: reviewed history immutable, reviewer differs from proposer.
|
|
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { review_note: '改写' } }));
|
|
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: proposed.id }, data: { status: 'rejected' } }));
|
|
await assert.rejects(prisma.loanReceiptAttribution.update({ where: { id: reproposal.id }, data: { status: 'approved' } }));
|
|
await assert.rejects(prisma.loanReceiptAttribution.delete({ where: { id: proposed.id } }));
|
|
checks.push('attribution_history_immutable_in_database');
|
|
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
|
|
// Attribution UI scenario: a matched receipt on one of two same-amount exported batches.
|
|
const uiLender = await verifiedLender('合成浏览器归属出借人', null, '0');
|
|
const uiLoan = await confirmedLoan(uiLender.id, '100.00', '合成浏览器归属借入');
|
|
const uiExported = async () => { const item = await repay(uiLender.id, uiLoan.id, '5.00');
|
|
await call(manager, 'POST', `/payment-batches/${item.id}/review`, {}); await exportOrder(item.id); return item; };
|
|
const uiTarget = await uiExported(); await uiExported();
|
|
assert.equal((await receipt(uiTarget.id, '500', 'SYNTHETIC-UI-ATTRIBUTION', { payeeAccount: uiLender.bankAccount })).result.status, 'matched');
|
|
const attributionUi = { batchId: uiTarget.id, addCompetitor: uiExported,
|
|
execute: () => app.inject({ method: 'POST', url: `/api/payment-batches/${uiTarget.id}/execute`, headers: finance.headers, payload: {} }) };
|
|
// HTTP cases above verify the real three-day gate. UI fixtures use the
|
|
// supported synthetic wait override so this run does not wait real days.
|
|
config.loanReleaseWaitDays = 0;
|
|
const reconciliationUi = { batches: [], independent, bankLetterPdf };
|
|
const uiManualLender = await verifiedLender('合成特别复核界面出借人', null, '6');
|
|
const uiManualLoan = await confirmedLoan(uiManualLender.id, '100.00', '合成特别复核界面借款');
|
|
for (const amount of ['1.00', '2.00']) {
|
|
const batch = await repay(uiManualLender.id, uiManualLoan.id, amount);
|
|
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id); await attest(batch.id);
|
|
reconciliationUi.batches.push(batch.id);
|
|
}
|
|
reconciliationUi.terminalBatches = []; reconciliationUi.recoveryBatches = [];
|
|
for (const recovery of [false, true]) for (const amount of (recovery ? ['5.00', '6.00'] : ['3.00', '4.00'])) {
|
|
const batch = await repay(uiManualLender.id, uiManualLoan.id, amount);
|
|
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id);
|
|
const exported = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: batch.id } });
|
|
const ref = `UI-TERMINAL-${amount}`; const sub = await register(batch.id, ref, exported.id); await attest(batch.id);
|
|
if (recovery) {
|
|
const evidenceId = await uploadEvidence(batch.id);
|
|
const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
|
|
await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id);
|
|
await call(finance, 'POST', `/payment-batches/${batch.id}/loan-terminal-evidences/${evidenceId}/void`, { reason: '合成旧证据撤回,浏览器重新补证' });
|
|
} else {
|
|
for (let i = 0; i < 2; i++) { const uploaded = await receipt(batch.id, String(Number(amount) * 100), ref, { payeeAccount: uiManualLender.bankAccount, transactionStatus: 'pending' }); if (i === 0) await call(finance, 'POST', `/payment-batches/${batch.id}/receipt-attachments/${uploaded.attachment.id}/void`, { reason: '合成作废的处理中事实仍供复核' }); }
|
|
await uploadEvidence(batch.id);
|
|
}
|
|
(recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).push({ id: batch.id, instruction: ref });
|
|
}
|
|
reconciliationUi.failures = []; reconciliationUi.late = [];
|
|
for (const mode of ['desktop', 'mobile']) {
|
|
const failLender = await verifiedLender(`合成失败界面-${mode}`, null, '7');
|
|
const failLoan = await confirmedLoan(failLender.id, '100.00', `合成失败归属-${mode}`);
|
|
const target = await repay(failLender.id, failLoan.id, '7.00');
|
|
const competitor = await repay(failLender.id, failLoan.id, '7.00');
|
|
for (const b of [target, competitor]) { await call(manager, 'POST', `/payment-batches/${b.id}/review`, {}); await exportOrder(b.id); }
|
|
const reference = `UI-FAIL-${mode}`.toUpperCase();
|
|
await receipt(target.id, '700', reference, { payeeAccount: failLender.bankAccount, transactionStatus: 'failed' });
|
|
reconciliationUi.failures.push({ id: target.id, repaymentId: target.loanRepaymentId, reference });
|
|
|
|
const lateLender = await verifiedLender(`合成追回界面-${mode}`, null, '8');
|
|
const lateLoan = await confirmedLoan(lateLender.id, '100.00', `合成晚到追回-${mode}`);
|
|
const original = await repay(lateLender.id, lateLoan.id, '80.00');
|
|
await call(manager, 'POST', `/payment-batches/${original.id}/review`, {}); await exportOrder(original.id);
|
|
const exp = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: original.id } });
|
|
const sub = await register(original.id, `UI-ORIGINAL-${mode}`, exp.id); await attest(original.id);
|
|
const fact = await proposeFact(original.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: await uploadEvidence(original.id) });
|
|
await reviewFact(original.id, fact.id); await confirmNotPaid(original.id); await cancelBatch(original.id);
|
|
const paid = await repay(lateLender.id, lateLoan.id, '60.00');
|
|
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
|
|
await receipt(paid.id, '6000', `UI-PAID-${mode}`, { payeeAccount: lateLender.bankAccount });
|
|
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
|
|
const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: original.id } });
|
|
reconciliationUi.late.push({ id: original.id, lenderId: lateLender.id, reference: `UI-LATE-${mode}`,
|
|
payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted), payee: lateLender.bankAccount });
|
|
}
|
|
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty });
|
|
}
|
|
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
|
|
} finally { await app.close(); await prisma.$disconnect(); }
|
|
};
|