const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const reimbursementRoutes = require("../../../src/routes/reimbursements"); const REIMBURSEMENT_ID = "11111111-1111-4111-8111-111111111111"; const ACTOR_ID = "22222222-2222-4222-8222-222222222222"; const DEPARTMENT_ID = "33333333-3333-4333-8333-333333333333"; async function buildApp(options = {}) { const rolloutType = options.rolloutType ?? "global"; const departmentIds = options.departmentIds ?? []; const activePaymentBatch = options.activePaymentBatch ?? false; const tx = { $queryRaw: vi.fn(async () => [{ id: REIMBURSEMENT_ID, status: "approved", form_number: "BX-20260718-001", owner_id: ACTOR_ID, submitter_id: ACTOR_ID, department_id: DEPARTMENT_ID, payment_voucher_no: null, payment_date: null, payment_channel: null, paid_at: null, paid_by: null, }]), featureFlag: { findUnique: vi.fn(async ({ where }) => { if (where.flag_key !== "payment_batch_v1") return null; return { id: "flag-payment-batch", flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: rolloutType, department_ids: departmentIds, note: "", version: 1, created_at: null, created_by: null, updated_at: null, updated_by: null, }; }), }, paymentBatchItem: { findFirst: vi.fn(async () => activePaymentBatch ? { id: "active-batch-item" } : null), }, reimbursementForm: { update: vi.fn() }, invoicePool: { updateMany: vi.fn() }, auditLog: { create: vi.fn() }, }; const prisma = { ...tx, $transaction: vi.fn(async (callback) => callback(tx)), }; const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { paymentBatchEnabled: true, cutoverConsoleEnabled: false, appTimezone: "Asia/Shanghai", }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: ACTOR_ID, department_id: DEPARTMENT_ID, legal_entity_id: null, roleCodes: ["finance"], permissionCodes: ["expense.finance.pay"], }; }); 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.decorate("assertEntityPermission", () => {}); 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(reimbursementRoutes, { prefix: "/api" }); return { app, prisma, tx }; } describe("reimbursement direct payment batch gate", () => { it("returns 409 with zero payment writes when payment_batch_v1 is enabled", async () => { const { app, prisma, tx } = await buildApp(); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/pay`, headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: "SYNTHETIC-001", paymentDate: "2026-07-18", paymentChannel: "bank", }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.payment).toBe("payment_batch_v1_required"); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("requires the batch path when the source department is in the rollout allowlist", async () => { const { app, tx } = await buildApp({ rolloutType: "department_allowlist", departmentIds: [DEPARTMENT_ID], }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/pay`, headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: "SYNTHETIC-ALLOWLIST-001", paymentDate: "2026-07-18", paymentChannel: "bank", }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.payment).toBe("payment_batch_v1_required"); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); it("keeps direct payment closed for an active batch after the source department leaves rollout", async () => { const { app, tx } = await buildApp({ rolloutType: "department_allowlist", departmentIds: ["44444444-4444-4444-8444-444444444444"], activePaymentBatch: true, }); const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/pay`, headers: { authorization: "Bearer test" }, payload: { paymentVoucherNo: "SYNTHETIC-ACTIVE-BATCH-001", paymentDate: "2026-07-18", paymentChannel: "bank", }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toMatchObject({ payment: "payment_batch_v1_required", paymentBatch: "active_batch_exists", }); expect(tx.paymentBatchItem.findFirst).toHaveBeenCalledTimes(1); expect(tx.reimbursementForm.update).not.toHaveBeenCalled(); expect(tx.invoicePool.updateMany).not.toHaveBeenCalled(); expect(tx.auditLog.create).not.toHaveBeenCalled(); await app.close(); }); });