const fs = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const Fastify = require("fastify"); const multipart = require("@fastify/multipart"); const { AppError } = require("../../../src/utils/AppError"); const { currentDateString } = require("../../../src/utils/date"); const featureFlags = require("../../../src/services/featureFlags"); const notifications = require("../../../src/services/notifications"); const email = require("../../../src/services/email"); const notificationDispatcher = require("../../../src/services/notificationDispatcher"); const erpJournal = require("../../../src/services/integrations/erpJournal"); const { encryptStringOrThrow } = require("../../../src/services/crypto"); const evaluateFeatureFlagMock = vi.fn(); const createNotificationsWithRecordsMock = vi.fn(async () => []); const sendNotificationEmailSafeMock = vi.fn(async () => undefined); const flushNotificationDispatchTasksMock = vi.fn(async () => undefined); const enqueueReimbursementJournalExportMock = vi.fn(async () => ({ id: "outbox-1", status: "pending", duplicated: false, requeued: false, })); const originalEvaluateFeatureFlag = featureFlags.evaluateFeatureFlag; const originalCreateNotificationsWithRecords = notifications.createNotificationsWithRecords; const originalSendNotificationEmailSafe = email.sendNotificationEmailSafe; const originalFlushNotificationDispatchTasks = notificationDispatcher.flushNotificationDispatchTasks; const originalEnqueueReimbursementJournalExport = erpJournal.enqueueReimbursementJournalExport; featureFlags.evaluateFeatureFlag = evaluateFeatureFlagMock; notifications.createNotificationsWithRecords = createNotificationsWithRecordsMock; email.sendNotificationEmailSafe = sendNotificationEmailSafeMock; notificationDispatcher.flushNotificationDispatchTasks = flushNotificationDispatchTasksMock; erpJournal.enqueueReimbursementJournalExport = enqueueReimbursementJournalExportMock; delete require.cache[require.resolve("../../../src/routes/paymentBatches")]; const paymentBatchesRoutes = require("../../../src/routes/paymentBatches"); const ACTOR_ID = "11111111-1111-4111-8111-111111111111"; const MAKER_ID = "22222222-2222-4222-8222-222222222222"; const REVIEWER_ID = "33333333-3333-4333-8333-333333333333"; const REIMBURSEMENT_ID = "44444444-4444-4444-8444-444444444444"; const BATCH_ID = "55555555-5555-4555-8555-555555555555"; const ITEM_ID = "66666666-6666-4666-8666-666666666666"; const EXECUTED_ITEM_ID = "aaaaaaaa-1111-4111-8111-111111111111"; const EXECUTED_REIMBURSEMENT_ID = "bbbbbbbb-2222-4222-8222-222222222222"; const OWNER_ID = "77777777-7777-4777-8777-777777777777"; const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888"; const RECEIPT_ID = "99999999-9999-4999-8999-999999999999"; const LEGAL_ENTITY_ID = "12121212-1212-4121-8121-121212121212"; const BANK_INSTITUTION_ID = "13131313-1313-4131-8131-131313131313"; const PAYMENT_ACCOUNT_ID = "14141414-1414-4141-8141-141414141414"; const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii"); const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); const SYNTHETIC_BANK_ACCOUNT = `6222${"0".repeat(8)}5678`; function buildMultipartRequest({ files, fields = {} }) { const boundary = "----codex-payment-batch-receipt-boundary"; const segments = []; for (const [name, value] of Object.entries(fields)) { segments.push( Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, ), ); } for (const file of files) { segments.push( Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, ), Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content, "utf8"), Buffer.from("\r\n"), ); } segments.push(Buffer.from(`--${boundary}--\r\n`)); return { payload: Buffer.concat(segments), headers: { "content-type": `multipart/form-data; boundary=${boundary}`, }, }; } async function listStoredReceiptFiles(rootDir) { const files = []; const pending = [rootDir]; while (pending.length) { const current = pending.pop(); const entries = await fs.readdir(current, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(current, entry.name); if (entry.isDirectory()) { pending.push(entryPath); } else if (entry.isFile()) { files.push(entryPath); } } } return files; } function buildPaymentBatch(overrides = {}) { return { id: BATCH_ID, batch_no: "PB-20260429-001", entity_type: "reimbursement", name: "报销付款批次", status: "reviewed", default_payment_date: new Date("2026-04-29T00:00:00.000Z"), default_payment_channel: "bank", voucher_prefix: "PBX", created_by: MAKER_ID, reviewed_at: new Date("2026-04-29T08:00:00.000Z"), reviewed_by: REVIEWER_ID, secondary_reviewed_at: null, secondary_reviewed_by: null, executed_at: null, executed_by: null, cancelled_at: null, cancelled_by: null, cancelled_reason: null, review_comment: null, secondary_review_comment: null, execution_summary_json: null, created_at: new Date("2026-04-29T07:00:00.000Z"), updated_by: MAKER_ID, items: [ { id: ITEM_ID, payment_batch_id: BATCH_ID, entity_id: REIMBURSEMENT_ID, entity_code: "BX-20260429-0001", entity_title: "研发报销", amount: "1683.00", currency_code: "CNY", status: "pending", payment_voucher_no_override: null, payment_date_override: null, payment_channel_override: null, failure_code: null, failure_message: null, snapshot_json: {}, created_at: new Date("2026-04-29T07:00:00.000Z"), }, ], ...overrides, }; } function buildReimbursement(overrides = {}) { return { id: REIMBURSEMENT_ID, status: "approved", form_number: "BX-20260429-0001", owner_id: OWNER_ID, submitter_id: SUBMITTER_ID, department_id: "dept-1", reimbursement_date: new Date("2026-04-28T00:00:00.000Z"), description: "研发报销", total_amount: "1683.00", payment_voucher_no: null, payment_date: null, payment_channel: null, paid_at: null, paid_by: null, owner: { id: OWNER_ID, employee_no: "EMP001", name: "测试员工", legal_entity_id: LEGAL_ENTITY_ID, bank_name: "中国建设银行", bank_institution: { id: BANK_INSTITUTION_ID, code: "CCB", name: "中国建设银行", }, bank_account_encrypted: encryptStringOrThrow( { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_BANK_ACCOUNT, ), legal_entity: { code: "HYB", }, }, submitter: { legal_entity: { code: "HYB", }, }, ...overrides, }; } function buildPrisma(options = {}) { const defaultReceiptAttachments = [ { id: RECEIPT_ID, payment_batch_id: BATCH_ID, storage_path: "payment-batch-receipts/20260429/receipt.pdf", original_name: "receipt.pdf", mime_type: "application/pdf", file_size: 12, file_sha256: "sha256-receipt", uploaded_by: ACTOR_ID, created_at: new Date("2026-04-29T09:00:00.000Z"), governance_status: "approved", sensitivity_level: "confidential", attachment_type: "receipt", archived_by: null, archived_at: null, archive_note: null, download_policy: "reason_required", uploader: { id: ACTOR_ID, employee_no: "FIN001", name: "出纳", }, }, ]; const state = { batch: buildPaymentBatch(options.batch), reimbursement: buildReimbursement(options.reimbursement), additionalReimbursements: (options.additionalReimbursements ?? []).map((item) => buildReimbursement(item), ), travelOrder: { id: TRAVEL_ORDER_ID, status: "confirmed", amount: "1683.00", currency: "CNY", payment_voucher_no: null, payment_date: null, payment_channel: null, paid_at: null, paid_by: null, ...(options.travelOrder ?? {}), }, receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments, reservedEntityIds: options.reservedEntityIds ?? [], paymentAccount: { id: PAYMENT_ACCOUNT_ID, legal_entity_id: LEGAL_ENTITY_ID, bank_institution_id: BANK_INSTITUTION_ID, account_name: "测试公司", account_number_encrypted: encryptStringOrThrow( { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, `6217${"0".repeat(8)}1234`, ), enabled: true, archived_at: null, legal_entity: { id: LEGAL_ENTITY_ID, code: "HYB", name: "测试公司" }, bank_institution: { id: BANK_INSTITUTION_ID, code: "CCB", name: "中国建设银行" }, ...(options.paymentAccount ?? {}), }, employees: options.employees ?? [ { id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] }, { id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] }, ], invoice: { linked_reimbursement_id: REIMBURSEMENT_ID, status: "bound", linked_item_id: "invoice-item-1", archived_reason: null, archived_at: null, updated_by: null, ...(options.invoice ?? {}), }, }; const tx = { paymentAccount: { findFirst: vi.fn(async () => ({ ...state.paymentAccount })), }, paymentBatch: { findUnique: vi.fn(async ({ where }) => { if (where.id !== state.batch.id) { return null; } return { ...state.batch, items: state.batch.items.map((item) => ({ ...item })), _count: { receipt_attachments: state.receiptAttachments.filter((item) => !item.archived_at).length, }, }; }), findMany: vi.fn(async () => [{ ...state.batch, items: state.batch.items.map((item) => ({ ...item })), _count: { receipt_attachments: state.receiptAttachments.filter((item) => !item.archived_at).length, }, }]), create: vi.fn(async ({ data }) => { state.batch = buildPaymentBatch({ ...data, id: BATCH_ID, items: [], created_at: new Date("2026-04-29T07:00:00.000Z"), }); return { ...state.batch }; }), update: vi.fn(async ({ where, data }) => { if (where.id !== state.batch.id) { throw new Error("batch not found"); } state.batch = { ...state.batch, ...data, }; return { ...state.batch, items: state.batch.items.map((item) => ({ ...item })), _count: { receipt_attachments: state.receiptAttachments.filter((item) => !item.archived_at).length, }, }; }), }, paymentBatchItem: { findMany: vi.fn(async ({ where } = {}) => { const requestedIds = where?.entity_id?.in ?? null; return state.reservedEntityIds .filter((id) => !requestedIds || requestedIds.includes(id)) .map((entityId) => ({ entity_id: entityId })); }), create: vi.fn(async ({ data }) => { const created = { id: ITEM_ID, ...data, payment_voucher_no_override: null, payment_date_override: null, payment_channel_override: null, failure_code: null, failure_message: null, created_at: new Date("2026-04-29T07:00:00.000Z"), }; state.batch.items.push(created); return { ...created }; }), update: vi.fn(async ({ where, data }) => { const item = state.batch.items.find((entry) => entry.id === where.id); if (!item) { throw new Error("item not found"); } Object.assign(item, data); return { ...item }; }), updateMany: vi.fn(async ({ where, data }) => { const ids = where?.id?.in ?? []; let count = 0; for (const item of state.batch.items) { if (ids.includes(item.id)) { Object.assign(item, data); count += 1; } } return { count }; }), }, paymentBatchReceiptAttachment: { count: vi.fn(async ({ where }) => state.receiptAttachments.filter((item) => item.payment_batch_id === where.payment_batch_id && (where.archived_at !== null || item.archived_at == null), ).length, ), findMany: vi.fn(async ({ where }) => state.receiptAttachments .filter((item) => item.payment_batch_id === where.payment_batch_id) .map((item) => ({ ...item })), ), create: vi.fn(async ({ data }) => { const created = { id: `aaaaaaaa-aaaa-4aaa-8aaa-${String(state.receiptAttachments.length + 1).padStart(12, "0")}`, ...data, created_at: new Date("2026-04-29T10:00:00.000Z"), uploader: { id: ACTOR_ID, employee_no: "FIN001", name: "出纳", }, }; state.receiptAttachments.push(created); return { ...created }; }), findFirst: vi.fn(async ({ where }) => { const item = state.receiptAttachments.find( (candidate) => candidate.id === where.id && candidate.payment_batch_id === where.payment_batch_id, ); return item ? { ...item } : null; }), update: vi.fn(async ({ where, data }) => { const item = state.receiptAttachments.find((candidate) => candidate.id === where.id); if (!item) throw new Error("receipt not found"); Object.assign(item, data); return { ...item }; }), deleteMany: vi.fn(async ({ where }) => { const before = state.receiptAttachments.length; state.receiptAttachments = state.receiptAttachments.filter( (item) => !(item.id === where.id && item.payment_batch_id === where.payment_batch_id), ); return { count: before - state.receiptAttachments.length }; }), }, reimbursementForm: { count: vi.fn(async () => options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1), ), findMany: vi.fn(async ({ select } = {}) => { if (select?.owner?.select?.employee_no) { return state.reservedEntityIds.includes(state.reimbursement.id) ? [] : [{ ...state.reimbursement }]; } const reimbursements = [state.reimbursement, ...state.additionalReimbursements]; return reimbursements.map((item) => ({ ...item })); }), findUnique: vi.fn(async ({ where }) => { if (where.id !== state.reimbursement.id) { return null; } return { ...state.reimbursement }; }), update: vi.fn(async ({ where, data }) => { if (where.id !== state.reimbursement.id) { throw new Error("reimbursement not found"); } state.reimbursement = { ...state.reimbursement, ...data, }; return { ...state.reimbursement }; }), }, travelOrder: { findUnique: vi.fn(async ({ where }) => where.id === state.travelOrder.id ? { ...state.travelOrder } : null, ), update: vi.fn(async ({ where, data }) => { if (where.id !== state.travelOrder.id) throw new Error("travel order not found"); Object.assign(state.travelOrder, data); return { ...state.travelOrder }; }), }, cardTransaction: { findFirst: vi.fn(async () => ({ id: "card-transaction-1" })), }, invoicePool: { updateMany: vi.fn(async ({ where, data }) => { const matches = state.invoice.linked_reimbursement_id === where.linked_reimbursement_id && state.invoice.status === where.status; if (!matches) { return { count: 0 }; } state.invoice = { ...state.invoice, ...data, }; return { count: 1 }; }), }, entityRiskFlag: { findMany: vi.fn(async () => []), findFirst: vi.fn(async () => null), updateMany: vi.fn(async () => ({ count: 0 })), create: vi.fn(async ({ data }) => ({ id: "risk-1", ...data })), }, auditLog: { create: vi.fn(async () => ({ id: "audit-1" })), }, employee: { findMany: vi.fn(async () => state.employees.map((employee) => ({ ...employee }))), }, $executeRaw: vi.fn(async () => 0), $queryRaw: vi.fn(async (query, ...values) => { const text = Array.isArray(query) ? query.join(" ") : String(query?.sql ?? query ?? ""); if (text.includes('ORDER BY "batch_no" DESC')) { const pattern = values[1]; return typeof pattern === "string" && new RegExp(pattern).test(state.batch.batch_no) ? [{ batch_no: state.batch.batch_no }] : []; } return [{ id: BATCH_ID }]; }), }; const prisma = { ...tx, $transaction: vi.fn(async (callback) => callback(tx)), }; return { prisma, tx, state }; } async function buildApp(options = {}) { const { prisma, tx, state } = buildPrisma(options); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-cashv-payment-receipts-")); const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.manage", "risk.review.manage"], roleCodes: ["finance"], }; const paymentBatchEnabled = options.paymentBatchEnabled ?? true; const paymentBatchAllowedDepartmentIds = options.paymentBatchAllowedDepartmentIds ?? null; const erpSyncEnabled = options.erpSyncEnabled ?? true; evaluateFeatureFlagMock.mockImplementation(async ({ flagKey, departmentId = null, strictDepartment = true }) => { if (flagKey === featureFlags.FEATURE_FLAG_KEYS.paymentBatch) { if (paymentBatchEnabled && Array.isArray(paymentBatchAllowedDepartmentIds)) { const hasDepartment = typeof departmentId === "string" && departmentId.length > 0; const enabled = hasDepartment ? paymentBatchAllowedDepartmentIds.includes(departmentId) : !strictDepartment; return { flagKey, enabled, reason: enabled ? hasDepartment ? "mock_department_allowed" : "department_deferred" : hasDepartment ? "department_not_allowed" : "department_missing", envEnabled: true, matchedDepartmentId: hasDepartment ? departmentId : null, requiresDepartment: true, }; } return { flagKey, enabled: paymentBatchEnabled, reason: paymentBatchEnabled ? "mock_on" : "mock_off", envEnabled: paymentBatchEnabled, matchedDepartmentId: null, requiresDepartment: false, }; } if (flagKey === featureFlags.FEATURE_FLAG_KEYS.erpSync) { return { flagKey, enabled: erpSyncEnabled, reason: erpSyncEnabled ? "mock_on" : "mock_off", envEnabled: erpSyncEnabled, matchedDepartmentId: null, requiresDepartment: false, }; } return { flagKey, enabled: false, reason: "mock_off", envEnabled: false, matchedDepartmentId: null, requiresDepartment: false, }; }); app.decorate("prisma", prisma); app.decorate("config", { appTimezone: options.appTimezone ?? "Asia/Shanghai", fieldEncryptionKey: FIELD_ENCRYPTION_KEY, storageDir: tempDir, maxUploadSizeBytes: 5 * 1024 * 1024, paymentBatchEnabled: true, erpSyncEnabled: true, integrationOutboxMaxAttempts: 5, imNotifierEnabled: false, appOrigin: "http://127.0.0.1:3001", }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = actor; }); app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode), ); app.decorate("requirePermission", (permissionCode) => async (request) => { await app.authenticate(request); if (!app.hasPermission(request.employee, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details ?? {}, }, }); } return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: "服务器内部错误", details: {} }, }); }); await app.register(multipart, { attachFieldsToBody: false, limits: { files: 5, fileSize: 5 * 1024 * 1024 }, }); await app.register(paymentBatchesRoutes, { prefix: "/api" }); return { app, prisma, tx, state, tempDir }; } afterEach(() => { vi.clearAllMocks(); }); afterAll(() => { featureFlags.evaluateFeatureFlag = originalEvaluateFeatureFlag; notifications.createNotificationsWithRecords = originalCreateNotificationsWithRecords; email.sendNotificationEmailSafe = originalSendNotificationEmailSafe; notificationDispatcher.flushNotificationDispatchTasks = originalFlushNotificationDispatchTasks; erpJournal.enqueueReimbursementJournalExport = originalEnqueueReimbursementJournalExport; }); describe("payment batch reimbursement routes", () => { it("executes reimbursement batch payment and queues downstream follow-ups", async () => { createNotificationsWithRecordsMock.mockResolvedValueOnce([ { id: "notif-1", recipient_employee_id: OWNER_ID }, { id: "notif-2", recipient_employee_id: SUBMITTER_ID }, ]); const { app, tx, state } = await buildApp(); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.item.status).toBe("executed"); expect(state.batch.status).toBe("executed"); expect(state.batch.items[0].status).toBe("executed"); expect(state.reimbursement.status).toBe("paid"); expect(state.reimbursement.payment_voucher_no).toBe("PBX-001"); expect(state.reimbursement.payment_channel).toBe("bank"); expect(state.invoice.status).toBe("archived"); expect(state.invoice.archived_reason).toBe("used_complete"); expect(tx.invoicePool.updateMany).toHaveBeenCalledWith({ where: { linked_reimbursement_id: REIMBURSEMENT_ID, status: "bound", }, data: expect.objectContaining({ status: "archived", archived_reason: "used_complete", updated_by: ACTOR_ID, }), }); expect(enqueueReimbursementJournalExportMock).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ paymentBatchEnabled: true, erpSyncEnabled: true, }), expect.objectContaining({ actorId: ACTOR_ID, reimbursement: expect.objectContaining({ id: REIMBURSEMENT_ID, status: "paid", }), }), ); expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ recipientEmployeeIds: [OWNER_ID, SUBMITTER_ID], targetId: REIMBURSEMENT_ID, }), ); expect(flushNotificationDispatchTasksMock).toHaveBeenCalledWith( expect.objectContaining({ tasks: expect.arrayContaining([ expect.objectContaining({ targetId: REIMBURSEMENT_ID, type: "reimbursement.paid", }), ]), }), ); await app.close(); }); it("rejects first review when maker tries to review own batch", async () => { const { app } = await buildApp({ actor: { id: MAKER_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["manager"], }, batch: { status: "draft", reviewed_at: null, reviewed_by: null, }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/review`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.review).toBe("maker_checker_conflict"); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled(); await app.close(); }); it("rejects repeated payment review without creating another responsibility notification", async () => { const { app } = await buildApp({ actor: { id: REVIEWER_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["manager"], }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/review`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.review).toBe("batch_not_reviewable"); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled(); await app.close(); }); it("rejects review when the batch maker identity is missing without review-side writes", async () => { const { app, tx, state } = await buildApp({ actor: { id: REVIEWER_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["manager"], }, batch: { status: "draft", created_by: null, reviewed_at: null, reviewed_by: null, }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/review`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.review).toBe("maker_identity_missing"); expect(state.batch.status).toBe("draft"); expect(tx.entityRiskFlag.updateMany).not.toHaveBeenCalled(); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("lists only approved unreserved reimbursement candidates with business display fields", async () => { const { app } = await buildApp(); const response = await app.inject({ method: "GET", url: "/api/payment-batches/candidates?q=BX&page=1&pageSize=20", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ page: 1, pageSize: 20, total: 1, items: [ { entityId: REIMBURSEMENT_ID, entityType: "reimbursement", businessNo: "BX-20260429-0001", employee: { id: OWNER_ID, employeeNo: "EMP001", name: "测试员工" }, amount: "1683.00", currencyCode: "CNY", businessDate: "2026-04-28", summary: "研发报销", }, ], }); await app.close(); }); it("omits reimbursements already reserved by a non-cancelled payment batch", async () => { const { app } = await buildApp({ reservedEntityIds: [REIMBURSEMENT_ID] }); const response = await app.inject({ method: "GET", url: "/api/payment-batches/candidates", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ total: 0, items: [] }); await app.close(); }); it("omits reimbursement candidates outside the payment rollout department allowlist", async () => { const { app } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] }); const response = await app.inject({ method: "GET", url: "/api/payment-batches/candidates", headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toMatchObject({ total: 0, items: [] }); await app.close(); }); it("allows one manager review for a high-amount batch and does not require a second reviewer", async () => { createNotificationsWithRecordsMock.mockResolvedValueOnce([ { id: "notif-execute-1", recipient_employee_id: MAKER_ID }, ]); const { app } = await buildApp({ actor: { id: REVIEWER_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["manager"], }, batch: { status: "draft", reviewed_at: null, reviewed_by: null, items: [ { ...buildPaymentBatch().items[0], amount: "60000.00", }, ], }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/review`, headers: { authorization: "Bearer test" }, payload: { comment: "已核对高额付款对象和金额" }, }); expect(response.statusCode, response.body).toBe(200); expect(response.json().data.item).toMatchObject({ status: "reviewed", highRisk: true, reviewRequirement: { requiredReviewCount: 1, completedReviewCount: 1, nextStep: "ready_for_execute", reviewerPermission: "payment.batch.review", }, }); expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ recipientEmployeeIds: [MAKER_ID], type: "payment_batch.execution_requested", targetType: "payment_batch", targetId: BATCH_ID, }), ); expect(flushNotificationDispatchTasksMock).toHaveBeenCalledWith( expect.objectContaining({ tasks: [expect.objectContaining({ type: "payment_batch.execution_requested", targetPath: `/payment-batches?id=${BATCH_ID}`, })], }), ); await app.close(); }); it("falls back to another active finance payment manager after review when the maker is unavailable", async () => { const fallbackFinanceId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; createNotificationsWithRecordsMock.mockResolvedValueOnce([ { id: "notif-execute-fallback", recipient_employee_id: fallbackFinanceId }, ]); const { app } = await buildApp({ actor: { id: REVIEWER_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["manager"], }, batch: { status: "draft", reviewed_at: null, reviewed_by: null, }, employees: [ { id: fallbackFinanceId, employee_no: "FIN002", status: "active", roles: [{ role_code: "finance" }] }, { id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] }, ], }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/review`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode, response.body).toBe(200); expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ recipientEmployeeIds: [fallbackFinanceId], type: "payment_batch.execution_requested", }), ); await app.close(); }); it("keeps the batch list compact and loads payee review facts only for detail", async () => { const { app, tx } = await buildApp({ actor: { id: REVIEWER_ID, department_id: "dept-1", permissionCodes: ["payment.batch.review"], roleCodes: ["manager"], }, }); const listResponse = await app.inject({ method: "GET", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, }); expect(listResponse.statusCode).toBe(200); expect(listResponse.json().data.items[0].id).toBe(BATCH_ID); expect(listResponse.json().data.items[0].items[0]).toMatchObject({ payeeName: null, bankName: null, bankAccountMasked: null, }); expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson"); expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1); const detailResponse = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}`, headers: { authorization: "Bearer test" }, }); expect(detailResponse.statusCode).toBe(200); expect(detailResponse.json().data.item.id).toBe(BATCH_ID); expect(detailResponse.json().data.item.items[0]).toMatchObject({ payeeName: "测试员工", bankName: "中国建设银行", bankAccountMasked: "6222********5678", }); expect(detailResponse.json().data.item.paymentOrderExportBlockers).toEqual(expect.arrayContaining([ { code: "legacy_payment_account_snapshot_missing" }, { code: "legacy_payee_snapshot_missing", entityCode: "BX-20260429-0001" }, ])); expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson"); await app.close(); }); it("never exposes an untrusted stored payment snapshot", async () => { const ciphertextSentinel = "v1:synthetic:encrypted:payload"; const { app } = await buildApp({ batch: { items: [{ ...buildPaymentBatch().items[0], snapshot_json: { payee: { name: SYNTHETIC_BANK_ACCOUNT, bankName: ciphertextSentinel, bankAccountMasked: SYNTHETIC_BANK_ACCOUNT, }, bank_account_encrypted: ciphertextSentinel, }, }], }, }); const response = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.item.items[0]).toMatchObject({ payeeName: "测试员工", bankName: "中国建设银行", bankAccountMasked: "6222********5678", }); expect(response.json().data.item.items[0]).not.toHaveProperty("snapshotJson"); expect(response.body).not.toContain(SYNTHETIC_BANK_ACCOUNT); expect(response.body).not.toContain(ciphertextSentinel); expect(response.body).not.toContain("bank_account_encrypted"); await app.close(); }); it("rejects an admin-only account from the business payment review path", async () => { const { app, tx } = await buildApp({ actor: { id: REVIEWER_ID, department_id: "dept-1", permissionCodes: ["*"], roleCodes: ["admin"], }, batch: { status: "draft", reviewed_at: null, reviewed_by: null }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/review`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(403); expect(tx.$queryRaw).not.toHaveBeenCalled(); await app.close(); }); it("rejects creation when a reimbursement is already in an active payment batch", async () => { const { app, tx } = await buildApp({ reservedEntityIds: [REIMBURSEMENT_ID] }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", name: "重复付款批次", entityIds: [REIMBURSEMENT_ID], }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.entityIds).toBe("contains_active_payment_batch"); expect(tx.paymentBatch.create).not.toHaveBeenCalled(); await app.close(); }); it("rejects batch creation outside the source department rollout with zero business writes", async () => { const { app, tx } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", name: "部门范围外付款批次", entityIds: [REIMBURSEMENT_ID], }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toMatchObject({ featureFlag: "payment_batch_v1_disabled", reason: "department_not_allowed", }); expect(tx.paymentBatch.create).not.toHaveBeenCalled(); expect(tx.paymentBatchItem.create).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("notifies active non-maker payment reviewers after creating a reimbursement batch", async () => { createNotificationsWithRecordsMock.mockResolvedValueOnce([ { id: "notif-review-1", recipient_employee_id: REVIEWER_ID }, ]); const { app, tx } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", name: "本周付款", entityIds: [REIMBURSEMENT_ID], }, }); expect(response.statusCode).toBe(201); expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( tx, expect.objectContaining({ recipientEmployeeIds: [REVIEWER_ID], type: "payment_batch.review_requested", targetType: "payment_batch", targetId: BATCH_ID, }), ); expect(flushNotificationDispatchTasksMock).toHaveBeenCalledWith( expect.objectContaining({ tasks: [expect.objectContaining({ type: "payment_batch.review_requested", targetPath: `/payment-batches?id=${BATCH_ID}`, })], }), ); await app.close(); }); it("generates the batch title and bank defaults when the client only selects reimbursements", async () => { const { app, tx } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], }, }); expect(response.statusCode).toBe(201); expect(tx.paymentBatch.create).toHaveBeenCalledWith({ data: expect.objectContaining({ batch_no: `PB-${currentDateString("Asia/Shanghai").replaceAll("-", "")}-001`, name: `${currentDateString("Asia/Shanghai")} 付款批次 001`, default_payment_date: null, default_payment_channel: "bank", voucher_prefix: null, status: "draft", }), }); expect(tx.paymentBatchItem.create).toHaveBeenCalledWith({ data: expect.objectContaining({ snapshot_json: expect.objectContaining({ payee: expect.objectContaining({ name: "测试员工", bankName: "中国建设银行", bankAccountMasked: "6222********5678", }), }), }), }); expect(response.json().data.item.items[0]).toMatchObject({ payeeName: "测试员工", bankName: "中国建设银行", bankAccountMasked: "6222********5678", }); expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT); expect(JSON.stringify(response.json())).not.toContain("bank_account_encrypted"); await app.close(); }); it("persists the payment channel selected at creation as the review fact", async () => { const { app, tx } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], defaultPaymentChannel: "alipay", }, }); expect(response.statusCode).toBe(201); expect(tx.paymentBatch.create).toHaveBeenCalledWith({ data: expect.objectContaining({ default_payment_channel: "alipay" }), }); expect(response.json().data.item.defaultPaymentChannel).toBe("alipay"); await app.close(); }); it("fails closed before batch writes when the payee account cannot be decrypted", async () => { const unreadableAccount = encryptStringOrThrow( { fieldEncryptionKey: Buffer.alloc(32, 8).toString("base64") }, SYNTHETIC_BANK_ACCOUNT, ); const { app, tx } = await buildApp({ reimbursement: { owner: { ...buildReimbursement().owner, bank_account_encrypted: unreadableAccount, }, }, }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.payeeBankAccount).toBe("decrypt_failed"); expect(tx.paymentBatch.create).not.toHaveBeenCalled(); expect(tx.paymentBatchItem.create).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("notifies one active chief executive before falling back to managers", async () => { const chiefExecutiveId = "99999999-1111-4111-8111-111111111111"; const { app, tx } = await buildApp({ employees: [ { id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] }, { id: chiefExecutiveId, employee_no: "CEO001", status: "active", roles: [{ role_code: "ceo" }] }, ], }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] }, }); expect(response.statusCode).toBe(201); expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( tx, expect.objectContaining({ recipientEmployeeIds: [chiefExecutiveId] }), ); await app.close(); }); it("rejects creation without an eligible non-maker reviewer and leaves no side effects", async () => { const { app, tx } = await buildApp({ employees: [ { id: ACTOR_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] }, ], }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID], }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.review).toBe("no_eligible_reviewer"); expect(tx.paymentBatch.create).not.toHaveBeenCalled(); expect(tx.paymentBatchItem.create).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled(); await app.close(); }); it("uses the company-local registration date when no payment date was entered", async () => { const { app, state } = await buildApp({ batch: { default_payment_date: null }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(200); expect(state.reimbursement.payment_date?.toISOString().slice(0, 10)).toBe(currentDateString("Asia/Shanghai")); await app.close(); }); it("keeps a created batch when payment review notification delivery fails", async () => { createNotificationsWithRecordsMock.mockResolvedValueOnce([ { id: "notif-review-failed", recipient_employee_id: REVIEWER_ID }, ]); flushNotificationDispatchTasksMock.mockRejectedValueOnce(new Error("synthetic dispatch failure")); const { app, state } = await buildApp(); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", name: "投递失败不回滚", entityIds: [REIMBURSEMENT_ID], }, }); expect(response.statusCode).toBe(201); expect(state.batch.status).toBe("draft"); expect(state.batch.items).toHaveLength(1); await app.close(); }); it("allocates an increasing company-local daily sequence and preserves an explicit name", async () => { const { app, tx } = await buildApp(); const payload = { entityType: "reimbursement", name: "付款批次编号验证", entityIds: [REIMBURSEMENT_ID], }; const first = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload, }); const second = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload, }); expect(first.statusCode).toBe(201); expect(second.statusCode).toBe(201); const batchNos = tx.paymentBatch.create.mock.calls.map(([argument]) => argument.data.batch_no); expect(batchNos).toHaveLength(2); const compactDate = currentDateString("Asia/Shanghai").replaceAll("-", ""); expect(batchNos).toEqual([ `PB-${compactDate}-001`, `PB-${compactDate}-002`, ]); expect(tx.paymentBatch.create.mock.calls[0][0].data.name).toBe("付款批次编号验证"); expect(tx.paymentBatch.create.mock.calls[1][0].data.name).toBe("付款批次编号验证"); const advisoryLockCalls = tx.$executeRaw.mock.calls.filter(([query]) => Array.isArray(query) && query.join(" ").includes("pg_advisory_xact_lock"), ); expect(advisoryLockCalls).toHaveLength(2); await app.close(); }); it("uses the configured company timezone and starts its local day at 001", async () => { const appTimezone = "Pacific/Honolulu"; const businessDate = currentDateString(appTimezone); const { app, tx } = await buildApp({ appTimezone }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] }, }); expect(response.statusCode).toBe(201); expect(tx.paymentBatch.create).toHaveBeenCalledWith({ data: expect.objectContaining({ batch_no: `PB-${businessDate.replaceAll("-", "")}-001`, name: `${businessDate} 付款批次 001`, }), }); await app.close(); }); it("ignores a legacy long batch number from the same company-local day", async () => { const businessDate = currentDateString("Asia/Shanghai"); const compactDate = businessDate.replaceAll("-", ""); const { app, tx } = await buildApp({ batch: { batch_no: `PB-${compactDate}-063245-812-A1B2C3` }, }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] }, }); expect(response.statusCode).toBe(201); expect(tx.paymentBatch.create).toHaveBeenCalledWith({ data: expect.objectContaining({ batch_no: `PB-${compactDate}-001`, name: `${businessDate} 付款批次 001`, }), }); await app.close(); }); it("fails closed when the company-local daily sequence is exhausted", async () => { const businessDate = currentDateString("Asia/Shanghai"); const { app, tx } = await buildApp({ batch: { batch_no: `PB-${businessDate.replaceAll("-", "")}-999` }, }); const response = await app.inject({ method: "POST", url: "/api/payment-batches", headers: { authorization: "Bearer test" }, payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.batchNo).toBe("daily_sequence_exhausted"); expect(tx.paymentBatch.create).not.toHaveBeenCalled(); expect(tx.paymentBatchItem.create).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("requires a reason when cancelling and retains executed items while cancelling open items", async () => { const executedItemId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const { app, state, tx } = await buildApp({ batch: { status: "failed", items: [ { ...buildPaymentBatch().items[0], status: "failed", failure_code: "VALID_001", failure_message: "合成失败原因", }, { ...buildPaymentBatch().items[0], id: executedItemId, entity_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", status: "executed", }, ], }, }); const missingReason = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/cancel`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(missingReason.statusCode).toBe(400); expect(state.batch.status).toBe("failed"); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/cancel`, headers: { authorization: "Bearer test" }, payload: { reason: "本批次剩余款项不再支付" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.item.cancelledReason).toBe("本批次剩余款项不再支付"); expect(state.batch.status).toBe("cancelled"); expect(state.batch.items.find((item) => item.id === ITEM_ID)?.status).toBe("cancelled"); expect(state.batch.items.find((item) => item.id === ITEM_ID)?.failure_message).toBe("合成失败原因"); expect(state.batch.items.find((item) => item.id === executedItemId)?.status).toBe("executed"); expect(tx.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "PAYMENT_BATCH_CANCELLED", extra: expect.objectContaining({ reason: "本批次剩余款项不再支付", cancelledOpenItemCount: 1, executedItemCount: 1, }), }), }), ); await app.close(); }); it("rejects bank-channel execution when no bank receipt attachment exists", async () => { const { app, state } = await buildApp({ receiptAttachments: [] }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.receiptAttachments).toBe("bank_receipt_required"); expect(state.batch.status).toBe("reviewed"); expect(state.reimbursement.status).toBe("approved"); expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); await app.close(); }); it("treats a legacy reimbursement batch without a channel as bank without rewriting it", async () => { const { app, state } = await buildApp({ batch: { default_payment_channel: null }, receiptAttachments: [], }); const detail = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}`, headers: { authorization: "Bearer test" }, }); expect(detail.statusCode).toBe(200); expect(detail.json().data.item.defaultPaymentChannel).toBe("bank"); expect(detail.json().data.item.items[0]).toMatchObject({ payeeName: "测试员工", bankName: "中国建设银行", bankAccountMasked: "6222********5678", }); expect(state.batch.items[0].snapshot_json).toEqual({}); expect(JSON.stringify(detail.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT); expect(state.batch.default_payment_channel).toBeNull(); const execute = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(execute.statusCode).toBe(409); expect(execute.json().error.details.receiptAttachments).toBe("bank_receipt_required"); expect(state.batch.default_payment_channel).toBeNull(); expect(state.reimbursement.status).toBe("approved"); await app.close(); }); it("rejects execution after the source department leaves the rollout with zero payment side effects", async () => { const { app, state, tx } = await buildApp({ paymentBatchAllowedDepartmentIds: ["dept-2"] }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.featureFlag).toBe("payment_batch_v1_disabled"); expect(state.batch.status).toBe("reviewed"); expect(state.batch.items[0].status).toBe("pending"); expect(state.reimbursement.status).toBe("approved"); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); expect(tx.paymentBatchItem.update).not.toHaveBeenCalled(); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); await app.close(); }); it("returns a conflict without payment audit when the source was already paid", async () => { const { app, state, tx } = await buildApp({ reimbursement: { status: "paid", payment_voucher_no: "PBX-001", payment_date: new Date("2026-04-29T00:00:00.000Z"), payment_channel: "bank", paid_at: new Date("2026-04-29T09:30:00.000Z"), }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.payment).toBe("already_paid_conflict"); expect(state.batch.status).toBe("reviewed"); expect(state.batch.items[0].status).toBe("pending"); expect(tx.auditLog.create).not.toHaveBeenCalled(); expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); await app.close(); }); it("rolls back the whole execution path on an unexpected database error", async () => { const { app, state, tx } = await buildApp(); tx.invoicePool.updateMany.mockRejectedValueOnce(new Error("synthetic_database_failure")); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(500); expect(state.batch.status).toBe("reviewed"); expect(state.batch.items[0].status).toBe("pending"); expect(state.reimbursement.status).toBe("approved"); expect(tx.paymentBatchItem.update).not.toHaveBeenCalled(); expect(tx.entityRiskFlag.create).not.toHaveBeenCalled(); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); await app.close(); }); it("returns a conflict without batch audit when a travel source was already paid", async () => { const travelItem = { ...buildPaymentBatch().items[0], entity_id: TRAVEL_ORDER_ID, entity_code: "TRAVEL-001", entity_title: "合成差旅订单", }; const { app, state, tx } = await buildApp({ batch: { entity_type: "travel_order", items: [travelItem], }, travelOrder: { status: "paid", payment_voucher_no: "PBX-001", payment_date: new Date("2026-04-29T00:00:00.000Z"), payment_channel: "bank", paid_at: new Date("2026-04-29T09:30:00.000Z"), }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.payment).toBe("already_paid_conflict"); expect(state.batch.status).toBe("reviewed"); expect(state.batch.items[0].status).toBe("pending"); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("allows payment metadata updates after review before execution", async () => { const { app, state, tx } = await buildApp(); const response = await app.inject({ method: "PATCH", url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`, headers: { authorization: "Bearer test" }, payload: { paymentVoucherNoOverride: "BANK-RECEIPT-001", paymentDateOverride: "2026-04-30", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.item).toMatchObject({ paymentVoucherNoOverride: "BANK-RECEIPT-001", paymentDateOverride: "2026-04-30", }); expect(state.batch.items[0]).toMatchObject({ payment_voucher_no_override: "BANK-RECEIPT-001", payment_channel_override: null, }); expect(state.batch.items[0].payment_date_override).toEqual(new Date("2026-04-30T00:00:00.000Z")); expect(tx.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "PAYMENT_BATCH_ITEM_METADATA_UPDATED", extra: expect.objectContaining({ itemId: ITEM_ID, before: expect.objectContaining({ paymentVoucherNoOverride: null }), after: expect.objectContaining({ paymentVoucherNoOverride: "BANK-RECEIPT-001" }), }), }), }), ); await app.close(); }); it("allows payment metadata updates while retrying a failed batch", async () => { const { app, state, tx } = await buildApp({ batch: { status: "failed", items: [{ ...buildPaymentBatch().items[0], status: "failed" }], }, }); const response = await app.inject({ method: "PATCH", url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`, headers: { authorization: "Bearer test" }, payload: { paymentVoucherNoOverride: "RETRY-001" }, }); expect(response.statusCode).toBe(200); expect(state.batch.items[0].payment_voucher_no_override).toBe("RETRY-001"); expect(tx.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "PAYMENT_BATCH_ITEM_METADATA_UPDATED" }), }), ); await app.close(); }); it("keeps draft payment metadata immutable and rejects channel overrides", async () => { const draft = await buildApp({ batch: { status: "draft", reviewed_at: null, reviewed_by: null } }); const draftResponse = await draft.app.inject({ method: "PATCH", url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`, headers: { authorization: "Bearer test" }, payload: { paymentVoucherNoOverride: "SHOULD-NOT-WRITE" }, }); expect(draftResponse.statusCode).toBe(409); expect(draftResponse.json().error.details.status).toBe("payment_metadata_not_editable"); expect(draft.tx.paymentBatchItem.update).not.toHaveBeenCalled(); expect(draft.tx.auditLog.create).not.toHaveBeenCalled(); await draft.app.close(); const reviewed = await buildApp(); const channelResponse = await reviewed.app.inject({ method: "PATCH", url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`, headers: { authorization: "Bearer test" }, payload: { paymentChannelOverride: "cash" }, }); expect(channelResponse.statusCode).toBe(400); expect(reviewed.tx.paymentBatchItem.update).not.toHaveBeenCalled(); expect(reviewed.tx.auditLog.create).not.toHaveBeenCalled(); await reviewed.app.close(); }); it.each(["executed", "cancelled"])("keeps %s payment metadata immutable", async (status) => { const { app, tx } = await buildApp({ batch: { status } }); const response = await app.inject({ method: "PATCH", url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`, headers: { authorization: "Bearer test" }, payload: { paymentDateOverride: "2026-04-30" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.status).toBe("payment_metadata_not_editable"); expect(tx.paymentBatchItem.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it.each(["reviewed", "failed"])( "uploads, downloads and voids bank receipt attachments in %s without physical deletion", async (status) => { const { app, prisma, state, tempDir } = await buildApp({ batch: { status }, receiptAttachments: [], }); try { const requestPayload = buildMultipartRequest({ fields: { governanceStatus: "draft", sensitivityLevel: "public", attachmentType: "other", downloadPolicy: "standard", }, files: [ { filename: "bank-receipt.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF, }, ], }); const uploadResponse = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers: { authorization: "Bearer test", ...requestPayload.headers, }, payload: requestPayload.payload, }); expect(uploadResponse.statusCode).toBe(200); expect(uploadResponse.json().data.items).toHaveLength(1); expect(uploadResponse.json().data.items[0]).toMatchObject({ originalName: "bank-receipt.pdf", mimeType: "application/pdf", governanceStatus: "approved", sensitivityLevel: "confidential", attachmentType: "receipt", downloadPolicy: "reason_required", }); const attachmentId = uploadResponse.json().data.items[0].id; expect(prisma.paymentBatchReceiptAttachment.create).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "PAYMENT_BATCH_RECEIPT_UPLOADED", target_id: BATCH_ID, }), }), ); const storedPath = state.receiptAttachments[0].storage_path; const stored = await fs.readFile(path.join(tempDir, storedPath)); expect(stored).toEqual(SYNTHETIC_RECEIPT_PDF); const listResponse = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers: { authorization: "Bearer test" }, }); expect(listResponse.statusCode).toBe(200); expect(listResponse.json().data.items).toHaveLength(1); expect(listResponse.json().data.items[0]).toMatchObject({ governanceStatus: "approved", sensitivityLevel: "confidential", attachmentType: "receipt", downloadPolicy: "reason_required", }); const missingReasonResponse = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file`, headers: { authorization: "Bearer test" }, }); expect(missingReasonResponse.statusCode).toBe(400); expect(missingReasonResponse.json().error.details).toEqual({ reason: "required" }); const downloadResponse = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file?reason=付款复核`, headers: { authorization: "Bearer test" }, }); expect(downloadResponse.statusCode).toBe(200); expect(Buffer.from(downloadResponse.rawPayload)).toEqual(SYNTHETIC_RECEIPT_PDF); expect(downloadResponse.headers["content-type"]).toContain("application/octet-stream"); expect(downloadResponse.headers["content-disposition"]).toContain("attachment;"); expect(downloadResponse.headers["x-content-type-options"]).toBe("nosniff"); expect(downloadResponse.headers["cache-control"]).toContain("private, no-store"); expect(downloadResponse.headers["referrer-policy"]).toBe("no-referrer"); expect(downloadResponse.headers["content-security-policy"]).toContain("sandbox"); expect(prisma.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "PAYMENT_BATCH_RECEIPT_DOWNLOADED", target_id: BATCH_ID, extra: expect.objectContaining({ attachmentId, attachmentType: "receipt", sensitivityLevel: "confidential", governanceStatus: "approved", reason: "付款复核", }), }), }), ); const deleteResponse = await app.inject({ method: "DELETE", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}`, headers: { authorization: "Bearer test" }, }); expect(deleteResponse.statusCode).toBe(409); expect(deleteResponse.json().error.details.receiptAttachment).toBe("void_required"); const voidResponse = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/void`, headers: { authorization: "Bearer test" }, payload: { reason: "上传附件有误,保留记录并作废" }, }); expect(voidResponse.statusCode).toBe(200); expect(voidResponse.json().data.outcome).toBe("voided"); expect(state.receiptAttachments).toHaveLength(1); expect(state.receiptAttachments[0]).toMatchObject({ governance_status: "archived", archived_by: ACTOR_ID, archive_note: "上传附件有误,保留记录并作废", }); expect(prisma.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "PAYMENT_BATCH_RECEIPT_VOIDED", target_id: BATCH_ID, }), }), ); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }, ); it.each(["draft", "executed", "cancelled"])( "rejects receipt upload and void in %s without attachment side effects", async (status) => { const { app, tx, state, tempDir } = await buildApp({ batch: { status } }); try { const requestPayload = buildMultipartRequest({ files: [{ filename: "blocked-receipt.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF, }], }); const uploadResponse = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers: { authorization: "Bearer test", ...requestPayload.headers }, payload: requestPayload.payload, }); expect(uploadResponse.statusCode).toBe(409); expect(uploadResponse.json().error.details.status).toBe("receipt_attachment_not_editable"); expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled(); expect(await listStoredReceiptFiles(tempDir)).toEqual([]); const originalAttachment = { ...state.receiptAttachments[0] }; const voidResponse = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/void`, headers: { authorization: "Bearer test" }, payload: { reason: "状态不允许时不得作废" }, }); expect(voidResponse.statusCode).toBe(409); expect(voidResponse.json().error.details.status).toBe("receipt_attachment_not_editable"); expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled(); expect(state.receiptAttachments[0]).toEqual(originalAttachment); expect(tx.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }, ); it("removes uploaded receipt objects when the locked batch becomes read-only", async () => { const { app, tx, state, tempDir } = await buildApp({ receiptAttachments: [] }); tx.paymentBatch.findUnique .mockImplementationOnce(async () => ({ ...state.batch, items: state.batch.items.map((item) => ({ ...item })), _count: { receipt_attachments: 0 }, })) .mockImplementationOnce(async () => ({ ...state.batch, status: "executed", items: state.batch.items.map((item) => ({ ...item })), _count: { receipt_attachments: 0 }, })); try { const requestPayload = buildMultipartRequest({ files: [{ filename: "race-receipt.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF, }], }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers: { authorization: "Bearer test", ...requestPayload.headers }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.status).toBe("receipt_attachment_not_editable"); expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); expect(await listStoredReceiptFiles(tempDir)).toEqual([]); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("checks receipt upload permission before feature evaluation or multipart consumption", async () => { const { app, prisma, tx, tempDir } = await buildApp({ actor: { id: REVIEWER_ID, department_id: "dept-1", permissionCodes: [], roleCodes: ["employee"], }, paymentBatchEnabled: false, receiptAttachments: [], }); try { const requestPayload = buildMultipartRequest({ files: [{ filename: "bank-receipt.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF, }], }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers: { authorization: "Bearer test", ...requestPayload.headers }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(403); expect(evaluateFeatureFlagMock).not.toHaveBeenCalled(); expect(tx.paymentBatch.findUnique).not.toHaveBeenCalled(); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("removes every newly stored receipt object when the upload transaction fails", async () => { const { app, state, tx, tempDir } = await buildApp({ receiptAttachments: [] }); tx.auditLog.create.mockRejectedValueOnce(new Error("synthetic_upload_audit_failure")); try { const requestPayload = buildMultipartRequest({ files: [ { filename: "bank-receipt-a.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF, }, { filename: "bank-receipt-b.pdf", contentType: "application/pdf", content: SYNTHETIC_RECEIPT_PDF, }, ], }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers: { authorization: "Bearer test", ...requestPayload.headers }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(500); expect(tx.$queryRaw).toHaveBeenCalled(); expect(tx.paymentBatchReceiptAttachment.create).toHaveBeenCalledTimes(2); expect(state.receiptAttachments).toHaveLength(2); for (const attachment of state.receiptAttachments) { await expect(fs.access(path.join(tempDir, attachment.storage_path))).rejects.toMatchObject({ code: "ENOENT" }); } } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("blocks unverifiable stored receipt content before sending file bytes", async () => { const { app, tx, state, tempDir } = await buildApp(); const attachment = state.receiptAttachments[0]; const absolutePath = path.join(tempDir, attachment.storage_path); try { await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, Buffer.from("synthetic-invalid-receipt", "utf8")); const response = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachment.id}/file?reason=付款复核`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.receiptAttachment).toBe("security_validation_failed"); expect(response.headers["content-disposition"]).toBeUndefined(); expect(tx.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "PAYMENT_BATCH_RECEIPT_DOWNLOAD_BLOCKED" }), }), ); expect(tx.auditLog.create.mock.calls.map(([input]) => input.data.action)).not.toContain( "PAYMENT_BATCH_RECEIPT_DOWNLOADED", ); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("returns 500 without file headers when successful-download audit persistence fails", async () => { const { app, tx, state, tempDir } = await buildApp(); const attachment = state.receiptAttachments[0]; const absolutePath = path.join(tempDir, attachment.storage_path); try { await fs.mkdir(path.dirname(absolutePath), { recursive: true }); await fs.writeFile(absolutePath, SYNTHETIC_RECEIPT_PDF); tx.auditLog.create.mockRejectedValueOnce(new Error("synthetic_download_audit_failure")); const response = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachment.id}/file?reason=付款复核`, headers: { authorization: "Bearer test" }, }); expect(response.statusCode).toBe(500); expect(response.headers["content-disposition"]).toBeUndefined(); expect(response.headers["x-content-type-options"]).toBeUndefined(); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("rejects unsupported bank receipt attachment MIME types", async () => { const { app, tempDir } = await buildApp({ receiptAttachments: [] }); try { const requestPayload = buildMultipartRequest({ files: [ { filename: "receipt.txt", contentType: "text/plain", content: "not a receipt attachment type", }, ], }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`, headers: { authorization: "Bearer test", ...requestPayload.headers, }, payload: requestPayload.payload, }); expect(response.statusCode).toBe(400); expect(response.json().error.details.file).toBe("unsupported_type"); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); it("retries only failed items and recomputes the batch summary from every final item state", async () => { const baseItem = buildPaymentBatch().items[0]; const { app, tx, state } = await buildApp({ batch: { status: "failed", execution_summary_json: { successCount: 1, failedCount: 1, attemptedCount: 2 }, items: [ { ...baseItem, id: EXECUTED_ITEM_ID, entity_id: EXECUTED_REIMBURSEMENT_ID, entity_code: "BX-20260429-0000", entity_title: "已完成报销", amount: "500.00", status: "executed", }, { ...baseItem, status: "failed", failure_code: "VALID_001", failure_message: "参数校验失败", }, ], }, additionalReimbursements: [ { id: EXECUTED_REIMBURSEMENT_ID, form_number: "BX-20260429-0000", total_amount: "500.00", status: "paid", paid_at: new Date("2026-04-29T08:30:00.000Z"), paid_by: ACTOR_ID, }, ], }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.item.status).toBe("executed"); expect(state.batch.status).toBe("executed"); expect(state.batch.execution_summary_json).toEqual({ successCount: 2, failedCount: 0, attemptedCount: 2, }); expect(state.batch.items.find((item) => item.id === EXECUTED_ITEM_ID)?.status).toBe("executed"); expect(state.batch.items.find((item) => item.id === ITEM_ID)).toEqual( expect.objectContaining({ status: "executed", failure_code: null, failure_message: null, }), ); expect(tx.paymentBatchItem.update).toHaveBeenCalledTimes(1); expect(tx.paymentBatchItem.update).toHaveBeenCalledWith({ where: { id: ITEM_ID }, data: { status: "executed", failure_code: null, failure_message: null, }, }); expect(tx.reimbursementForm.update).toHaveBeenCalledTimes(1); expect(tx.reimbursementForm.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: REIMBURSEMENT_ID } }), ); expect(tx.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_BATCH_EXECUTED", extra: expect.objectContaining({ successCount: 1, failedCount: 0, attemptedCount: 1, summaryScope: "current_attempt", retry: true, finalItemSummary: { successCount: 2, failedCount: 0, attemptedCount: 2, }, }), }), }); expect(enqueueReimbursementJournalExportMock).toHaveBeenCalledTimes(1); expect(enqueueReimbursementJournalExportMock).toHaveBeenCalledWith( expect.any(Object), expect.any(Object), expect.objectContaining({ reimbursement: expect.objectContaining({ id: REIMBURSEMENT_ID }), }), ); expect(createNotificationsWithRecordsMock).toHaveBeenCalledTimes(1); expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ targetId: REIMBURSEMENT_ID }), ); await app.close(); }); it("marks batch item failed when reimbursement is not payable and does not queue follow-ups", async () => { const { app, state } = await buildApp({ reimbursement: { status: "reviewing", }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/execute`, headers: { authorization: "Bearer test" }, payload: {}, }); expect(response.statusCode).toBe(200); expect(response.json().data.item.status).toBe("failed"); expect(state.batch.status).toBe("failed"); expect(state.batch.items[0].status).toBe("failed"); expect(state.batch.items[0].failure_code).toBe("VALID_001"); expect(state.reimbursement.status).toBe("reviewing"); expect(state.invoice.status).toBe("bound"); expect(enqueueReimbursementJournalExportMock).not.toHaveBeenCalled(); expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled(); await app.close(); }); it("exports a reviewed CCB batch as BIFF8 and records the first export", async () => { const payerEncrypted = encryptStringOrThrow( { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, `6217${"0".repeat(8)}1234`, ); const payeeEncrypted = encryptStringOrThrow( { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_BANK_ACCOUNT, ); const { app, tx, state } = await buildApp({ actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.manage", "payment.order.export"], roleCodes: ["finance"], }, batch: { status: "reviewed", payment_account_snapshot_json: { accountName: "合成测试公司", bankName: "中国建设银行", bankInstitutionCode: "CCB", accountNumberMasked: "6217********1234", accountNumberEncrypted: payerEncrypted, }, payment_order_export_count: 0, items: [buildPaymentBatch().items[0],].map((item) => ({ ...item, snapshot_json: { payee: { name: "测试员工", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountMasked: "6222********5678", bankAccountEncrypted: payeeEncrypted, }, }, })), }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/payment-order`, headers: { authorization: "Bearer test" }, payload: { reason: "导入企业网银" }, }); expect(response.statusCode).toBe(200); expect(response.rawPayload.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1"); expect(response.headers["cache-control"]).toBe("no-store"); expect(state.batch.payment_order_export_count).toBe(1); expect(tx.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ reason: "导入企业网银", exportCount: 1 }) }), }); await app.close(); }); it("does not let an admin without a finance business role export a payment order", async () => { const { app, tx } = await buildApp({ actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["*"], roleCodes: ["admin"], }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/payment-order`, headers: { authorization: "Bearer test" }, payload: { reason: "权限负向验证" }, }); expect(response.statusCode).toBe(403); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED" }), }); await app.close(); }); it.each(["draft", "failed", "cancelled", "executed"])( "rejects direct payment-order export while the batch is %s", async (status) => { const { app, tx } = await buildApp({ actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.manage", "payment.order.export"], roleCodes: ["finance"], }, batch: { status }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/payment-order`, headers: { authorization: "Bearer test" }, payload: { reason: "状态门禁验证" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.reason).toBe("status_not_reviewed"); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalledWith({ data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }), }); await app.close(); }, ); it("permanently rejects payment-order export for a legacy batch without frozen accounts", async () => { const { app, tx } = await buildApp({ actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.manage", "payment.order.export"], roleCodes: ["finance"], }, batch: { status: "reviewed", payment_account_snapshot_json: null }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/payment-order`, headers: { authorization: "Bearer test" }, payload: { reason: "旧批次尝试" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.reason).toBe("legacy_payment_account_snapshot_missing"); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); await app.close(); }); it("rejects direct payment-order export when an item effective channel is not bank", async () => { const payerEncrypted = encryptStringOrThrow( { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, `6217${"0".repeat(8)}1234`, ); const payeeEncrypted = encryptStringOrThrow( { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_BANK_ACCOUNT, ); const { app, tx } = await buildApp({ actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.manage", "payment.order.export"], roleCodes: ["finance"], }, batch: { status: "reviewed", default_payment_channel: "bank", payment_account_snapshot_json: { accountName: "合成测试公司", bankName: "中国建设银行", bankInstitutionCode: "CCB", accountNumberMasked: "6217********1234", accountNumberEncrypted: payerEncrypted, }, items: buildPaymentBatch().items.map((item) => ({ ...item, payment_channel_override: "cash", snapshot_json: { payee: { name: "测试员工", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountMasked: "6222********5678", bankAccountEncrypted: payeeEncrypted, }, }, })), }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/payment-order`, headers: { authorization: "Bearer test" }, payload: { reason: "验证付款渠道门禁" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual(expect.objectContaining({ reason: "bank_channel_required", blockers: expect.arrayContaining([ { code: "bank_channel_required", entityCode: "BX-20260429-0001" }, ]), })); expect(tx.paymentBatch.update).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalledWith({ data: expect.objectContaining({ action: expect.stringMatching(/^PAYMENT_ORDER_(?:RE)?EXPORTED$/) }), }); await app.close(); }); it("reveals the frozen payee account to finance only after a reason and audits the view", async () => { const payeeEncrypted = encryptStringOrThrow( { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_BANK_ACCOUNT, ); const { app, tx } = await buildApp({ actor: { id: ACTOR_ID, department_id: "dept-1", permissionCodes: ["payment.batch.manage", "employee.bank.read"], roleCodes: ["finance"], }, batch: { status: "reviewed", items: buildPaymentBatch().items.map((item) => ({ ...item, snapshot_json: { payee: { bankAccountEncrypted: payeeEncrypted } }, })), }, }); const response = await app.inject({ method: "POST", url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}/payee-bank/reveal`, headers: { authorization: "Bearer test" }, payload: { reason: "人工转账核对" }, }); expect(response.statusCode).toBe(200); expect(response.json().data.bankAccount).toBe(SYNTHETIC_BANK_ACCOUNT); expect(tx.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_BATCH_PAYEE_BANK_REVEALED", extra: expect.objectContaining({ reason: "人工转账核对", paymentBatchItemId: ITEM_ID }) }), }); await app.close(); }); });