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 OWNER_ID = "77777777-7777-4777-8777-777777777777"; const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888"; const RECEIPT_ID = "99999999-9999-4999-8999-999999999999"; function buildMultipartRequest({ files }) { const boundary = "----codex-payment-batch-receipt-boundary"; const segments = []; 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, 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", total_amount: "1683.00", payment_voucher_no: null, payment_date: null, payment_channel: null, paid_at: null, paid_by: null, owner: { 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"), uploader: { id: ACTOR_ID, employee_no: "FIN001", name: "出纳", }, }, ]; const state = { batch: buildPaymentBatch(options.batch), reimbursement: buildReimbursement(options.reimbursement), receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments, 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 })), }; }), 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 })), }; }), }, paymentBatchItem: { 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 }; }), }, paymentBatchReceiptAttachment: { count: vi.fn(async ({ where }) => state.receiptAttachments.filter((item) => item.payment_batch_id === where.payment_batch_id).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; }), 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: { 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 }; }), }, 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" })), }, }; 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"], }; const paymentBatchEnabled = options.paymentBatchEnabled ?? true; const erpSyncEnabled = options.erpSyncEnabled ?? true; evaluateFeatureFlagMock.mockImplementation(async ({ flagKey }) => { if (flagKey === featureFlags.FEATURE_FLAG_KEYS.paymentBatch) { 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: ["risk.review.manage"], }, 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 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("allows payment metadata updates after review before execution", async () => { const { app, state } = 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", }); await app.close(); }); it("uploads, downloads and deletes bank receipt attachments", async () => { const { app, prisma, state, tempDir } = await buildApp({ receiptAttachments: [] }); try { const requestPayload = buildMultipartRequest({ files: [ { filename: "bank-receipt.pdf", contentType: "application/pdf", content: "%PDF bank receipt", }, ], }); 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", }); 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.toString("utf8")).toBe("%PDF bank receipt"); 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); const downloadResponse = await app.inject({ method: "GET", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}/file`, headers: { authorization: "Bearer test" }, }); expect(downloadResponse.statusCode).toBe(200); expect(downloadResponse.body).toBe("%PDF bank receipt"); const deleteResponse = await app.inject({ method: "DELETE", url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${attachmentId}`, headers: { authorization: "Bearer test" }, }); expect(deleteResponse.statusCode).toBe(200); expect(state.receiptAttachments).toHaveLength(0); } 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_mime_type"); } finally { await app.close(); await fs.rm(tempDir, { recursive: true, force: true }); } }); 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(); }); });