const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const travelTransactionsRoutes = require("../../../src/routes/travelTransactions"); function buildOrder(overrides = {}) { return { id: "order-1", external_order_no: "EXT-001", employee_id: "employee-1", travel_request_id: "travel-1", amount: "500.00", currency: "CNY", status: "imported", ...overrides, }; } function buildPrisma({ employees = [{ id: "employee-1", department_id: "dept-1" }], travelRequests = [{ id: "travel-1", applicant_employee_id: "employee-1", budget_pool_id: "pool-1" }], authorizations = [], travelOrders = [buildOrder()], existing = null, existingLedger = [], budgetPool = { id: "pool-1", used_amount: "1000.00" }, } = {}) { const state = { order: travelOrders[0] ? { ...travelOrders[0] } : null, transaction: null, }; const prisma = { employee: { findMany: vi.fn().mockResolvedValue(employees), }, travelRequest: { findMany: vi.fn().mockResolvedValue(travelRequests), }, cardAuthorization: { findMany: vi.fn().mockResolvedValue(authorizations), findUnique: vi.fn().mockImplementation(async () => null), update: vi.fn().mockResolvedValue({ id: "auth-1" }), }, travelOrder: { findMany: vi.fn().mockResolvedValue(travelOrders), findUnique: vi.fn().mockImplementation(async () => state.order), update: vi.fn().mockImplementation(async ({ data }) => { state.order = { ...state.order, ...data }; return state.order; }), }, cardTransaction: { findUnique: vi.fn().mockResolvedValue(existing), upsert: vi.fn().mockImplementation(async ({ create, update }) => { state.transaction = { id: "33333333-3333-4333-8333-333333333333", source: create.source, external_txn_id: create.external_txn_id, card_authorization_id: update.card_authorization_id ?? create.card_authorization_id ?? null, employee_id: create.employee_id, department_id: create.department_id, travel_request_id: create.travel_request_id, travel_order_id: update.travel_order_id ?? create.travel_order_id ?? null, amount: create.amount, currency: create.currency, txn_type: create.txn_type, status: create.status, occurred_at: create.occurred_at, freeze_suggested_at: null, freeze_suggested_by: null, freeze_reason_code: null, freeze_reason_note: null, created_at: new Date("2026-03-10T00:00:00.000Z"), updated_at: new Date("2026-03-10T00:00:00.000Z"), }; return state.transaction; }), findFirst: vi.fn().mockResolvedValue(null), update: vi.fn().mockImplementation(async ({ data }) => ({ ...(state.transaction ?? { id: "33333333-3333-4333-8333-333333333333", source: "corp-card", external_txn_id: "TXN-1", card_authorization_id: null, employee_id: "employee-1", department_id: "dept-1", travel_request_id: "travel-1", travel_order_id: "order-1", amount: "500.00", currency: "CNY", txn_type: "charge", status: "settled", occurred_at: new Date("2026-03-10T00:00:00.000Z"), created_at: new Date("2026-03-10T00:00:00.000Z"), updated_at: new Date("2026-03-10T00:00:00.000Z"), }), ...data, travel_order: state.order, })), }, budgetLedgerEntry: { findMany: vi.fn().mockResolvedValue(existingLedger), create: vi.fn().mockResolvedValue({ id: "ledger-1" }), }, budgetPool: { findUnique: vi.fn().mockResolvedValue(budgetPool), update: vi.fn().mockResolvedValue({ id: "pool-1" }), }, auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }), }, $transaction: vi.fn().mockImplementation(async (callback) => callback(prisma)), }; prisma.cardTransaction.findUnique.mockImplementation(async ({ where }) => { if (where?.id) { return { ...(state.transaction ?? { id: where.id, source: "corp-card", external_txn_id: "TXN-1", card_authorization_id: null, employee_id: "employee-1", department_id: "dept-1", travel_request_id: "travel-1", travel_order_id: state.order?.id ?? null, amount: "500.00", currency: "CNY", txn_type: "charge", status: "settled", occurred_at: new Date("2026-03-10T00:00:00.000Z"), freeze_suggested_at: null, freeze_suggested_by: null, freeze_reason_code: null, freeze_reason_note: null, created_at: new Date("2026-03-10T00:00:00.000Z"), updated_at: new Date("2026-03-10T00:00:00.000Z"), }), travel_order: state.order, }; } return existing; }); return { prisma, state }; } async function buildApp({ prisma, permissionCodes = ["integration.manage", "travel.payment.manage"] }) { const app = Fastify(); app.decorate("prisma", prisma); app.decorate("config", { travelSyncEnabled: true }); app.decorate("authenticate", async (request) => { if (!request.headers.authorization) { throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 }); } request.employee = { id: "actor-1", permissionCodes }; }); app.decorate("hasPermission", (employee, permissionCode) => (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(travelTransactionsRoutes, { prefix: "/api" }); return app; } describe("travelTransactions routes", () => { it("confirms travel order when settled charge exactly matches", async () => { const { prisma, state } = buildPrisma(); const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); const response = await app.inject({ method: "POST", url: "/api/travel/transactions/import", headers: { authorization: "Bearer test" }, payload: { transactions: [{ source: "corp-card", externalTxnId: "TXN-001", employeeId: "employee-1", travelRequestId: "travel-1", travelOrderId: "order-1", amount: "500.00", currency: "CNY", txnType: "charge", status: "settled", }], }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual(expect.objectContaining({ confirmedCount: 1, imported: 1 })); expect(state.order.status).toBe("confirmed"); await app.close(); }); it("writes reverse ledger for refund transactions", async () => { const { prisma } = buildPrisma({ travelOrders: [], }); const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); const response = await app.inject({ method: "POST", url: "/api/travel/transactions/import", headers: { authorization: "Bearer test" }, payload: { transactions: [{ source: "corp-card", externalTxnId: "TXN-REFUND-1", employeeId: "employee-1", travelRequestId: "travel-1", amount: "120.00", currency: "CNY", txnType: "refund", status: "settled", }], }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual(expect.objectContaining({ reversedCount: 1, imported: 1 })); expect(prisma.budgetPool.update).toHaveBeenCalled(); await app.close(); }); it("skips rows when referenced authorization is missing", async () => { const { prisma } = buildPrisma(); const app = await buildApp({ prisma, permissionCodes: ["integration.manage"] }); const response = await app.inject({ method: "POST", url: "/api/travel/transactions/import", headers: { authorization: "Bearer test" }, payload: { transactions: [{ source: "corp-card", externalTxnId: "TXN-MISS-AUTH", employeeId: "employee-1", travelRequestId: "travel-1", cardAuthorizationId: "missing-auth", amount: "500.00", currency: "CNY", txnType: "charge", status: "settled", }], }, }); expect(response.statusCode).toBe(200); expect(response.json().data.skipped).toEqual([ expect.objectContaining({ reason: "card_authorization_not_found" }), ]); await app.close(); }); it("stores freeze suggestion metadata", async () => { const { prisma } = buildPrisma(); const app = await buildApp({ prisma }); const response = await app.inject({ method: "POST", url: "/api/travel/transactions/33333333-3333-4333-8333-333333333333/freeze-suggestion", headers: { authorization: "Bearer test" }, payload: { reasonCode: "manual_review", note: "请人工复核", }, }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual( expect.objectContaining({ freezeReasonCode: "manual_review", freezeReasonNote: "请人工复核", }), ); await app.close(); }); });