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 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 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 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"); 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}`, }, }; } 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: { 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 ?? [], 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 = { 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) { 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" })), }, $queryRaw: vi.fn(async () => [{ 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: "Asia/Shanghai", 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"); 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 () => { 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).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", }, }); await app.close(); }); it("allows a manager with payment review permission to read the batch list and detail", async () => { const { app } = 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" }, }); const detailResponse = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}`, headers: { authorization: "Bearer test" }, }); expect(listResponse.statusCode).toBe(200); expect(listResponse.json().data.items[0].id).toBe(BATCH_ID); expect(detailResponse.statusCode).toBe(200); expect(detailResponse.json().data.item.id).toBe(BATCH_ID); 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("adds a non-identifying random suffix to concurrent-style batch numbers", 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); expect(batchNos[0]).toMatch(/^PB-\d{8}-\d{6}-\d{3}-[0-9A-F]{6}$/); expect(batchNos[1]).toMatch(/^PB-\d{8}-\d{6}-\d{3}-[0-9A-F]{6}$/); expect(batchNos[0]).not.toBe(batchNos[1]); 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("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", paymentChannelOverride: "bank", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.item).toMatchObject({ paymentVoucherNoOverride: "BANK-RECEIPT-001", paymentDateOverride: "2026-04-30", paymentChannelOverride: "bank", }); expect(state.batch.items[0]).toMatchObject({ payment_voucher_no_override: "BANK-RECEIPT-001", payment_channel_override: "bank", }); 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("uploads, downloads and voids bank receipt attachments without physical deletion", async () => { const { app, prisma, state, tempDir } = await buildApp({ 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("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(); }); });