const { z } = require("zod"); const { AppError } = require("../utils/AppError"); const { sendData } = require("../utils/reply"); const { buildAuditLogData } = require("../utils/audit"); const { normalizeTravelTransactionRows } = require("../services/integrations/travel"); const { settleBudgetForEntity, reverseBudgetForEntity, isOrderAmountAndCurrencyMatched, } = require("../services/travelPayments"); const UUID_SCHEMA = z.string().uuid(); const LIST_QUERY_SCHEMA = z.object({ travelRequestId: UUID_SCHEMA.optional(), travelOrderId: UUID_SCHEMA.optional(), status: z.string().trim().min(1).max(32).optional(), txnType: z.string().trim().min(1).max(32).optional(), page: z.coerce.number().int().min(1).optional(), pageSize: z.coerce.number().int().min(1).max(100).optional(), }); const IMPORT_SCHEMA = z.object({ transactions: z.array(z.record(z.string(), z.unknown())).min(1).max(100), }); const FREEZE_SUGGESTION_SCHEMA = z.object({ reasonCode: z.enum([ "amount_mismatch", "currency_mismatch", "missing_order", "duplicate", "manual_review", ]), note: z.string().trim().max(500).nullable().optional(), }); const TRANSACTION_STATUS_LIST = new Set(["pending", "settled", "reversed", "failed"]); const TRANSACTION_TYPE_LIST = new Set(["charge", "refund", "reversal"]); function buildValidationError(details, statusCode = 400) { return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details, }); } function normalizeTransactionStatus(value) { const normalized = String(value ?? "pending").trim().toLowerCase(); return TRANSACTION_STATUS_LIST.has(normalized) ? normalized : "pending"; } function normalizeTransactionType(value) { const normalized = String(value ?? "charge").trim().toLowerCase(); return TRANSACTION_TYPE_LIST.has(normalized) ? normalized : "charge"; } function parseListQuery(query) { const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } return { travelRequestId: parsed.data.travelRequestId ?? null, travelOrderId: parsed.data.travelOrderId ?? null, status: parsed.data.status ? normalizeTransactionStatus(parsed.data.status) : null, txnType: parsed.data.txnType ? normalizeTransactionType(parsed.data.txnType) : null, page: parsed.data.page ?? 1, pageSize: parsed.data.pageSize ?? 20, }; } function toMoneyFixed(value) { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric)) { return "0.00"; } return numeric.toFixed(2); } function mapMatchedOrder(order, transaction) { if (!order) { return null; } return { id: order.id, externalOrderNo: order.external_order_no, status: order.status, amount: toMoneyFixed(order.amount), currency: order.currency, exactMatch: isOrderAmountAndCurrencyMatched(order, transaction), }; } function mapTransaction(item) { return { id: item.id, source: item.source, externalTxnId: item.external_txn_id, cardAuthorizationId: item.card_authorization_id ?? null, employeeId: item.employee_id, departmentId: item.department_id ?? null, travelRequestId: item.travel_request_id, travelOrderId: item.travel_order_id ?? null, amount: toMoneyFixed(item.amount), currency: item.currency, txnType: item.txn_type, status: item.status, occurredAt: item.occurred_at?.toISOString?.() ?? null, freezeSuggestedAt: item.freeze_suggested_at?.toISOString?.() ?? null, freezeSuggestedBy: item.freeze_suggested_by ?? null, freezeReasonCode: item.freeze_reason_code ?? null, freezeReasonNote: item.freeze_reason_note ?? null, matchedOrder: mapMatchedOrder(item.travel_order ?? null, item), createdAt: item.created_at?.toISOString?.() ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, }; } function hasTransactionConflict(existing, row) { if ( existing.employee_id !== row.employeeId || existing.travel_request_id !== row.travelRequestId || String(existing.amount ?? "0.00") !== String(row.amount ?? "0.00") || String(existing.currency ?? "CNY") !== String(row.currency ?? "CNY") || String(existing.txn_type ?? "charge") !== String(row.txnType ?? "charge") ) { return true; } if ( row.hasCardAuthorizationId && (existing.card_authorization_id ?? null) !== (row.cardAuthorizationId ?? null) ) { return true; } if (row.hasTravelOrderId && (existing.travel_order_id ?? null) !== (row.travelOrderId ?? null)) { return true; } return false; } async function syncAuthorizationSnapshot(tx, authorizationId, actorId) { const authorization = await tx.cardAuthorization.findUnique({ where: { id: authorizationId }, select: { id: true, authorized_amount: true, card_transactions: { select: { amount: true, txn_type: true, status: true, }, }, }, }); if (!authorization) { return null; } const settledAmount = authorization.card_transactions.reduce((total, item) => { if (item.txn_type === "charge" && item.status === "settled") { return total + Number(item.amount ?? 0); } return total; }, 0); const reversedAmount = authorization.card_transactions.reduce((total, item) => { if (["refund", "reversal"].includes(item.txn_type) || item.status === "reversed") { return total + Number(item.amount ?? 0); } return total; }, 0); const authorizedAmount = Number(authorization.authorized_amount ?? 0); let nextStatus = "authorized"; if (reversedAmount > 0 && reversedAmount >= authorizedAmount && authorizedAmount > 0) { nextStatus = "reversed"; } else if (settledAmount > 0 && settledAmount >= authorizedAmount && authorizedAmount > 0) { nextStatus = "settled"; } else if (settledAmount > 0) { nextStatus = "partially_settled"; } return tx.cardAuthorization.update({ where: { id: authorizationId }, data: { settled_amount: settledAmount.toFixed(2), reversed_amount: reversedAmount.toFixed(2), status: nextStatus, updated_by: actorId, }, }); } async function loadOrderById(tx, orderId) { return tx.travelOrder.findUnique({ where: { id: orderId }, select: { id: true, external_order_no: true, employee_id: true, travel_request_id: true, amount: true, currency: true, status: true, }, }); } async function travelTransactionsRoutes(fastify) { fastify.get( "/travel/transactions", { preHandler: fastify.requirePermission("travel.payment.manage"), }, async (request, reply) => { const query = parseListQuery(request.query); const skip = (query.page - 1) * query.pageSize; const where = { ...(query.travelRequestId ? { travel_request_id: query.travelRequestId } : {}), ...(query.travelOrderId ? { travel_order_id: query.travelOrderId } : {}), ...(query.status ? { status: query.status } : {}), ...(query.txnType ? { txn_type: query.txnType } : {}), }; const [items, total] = await Promise.all([ fastify.prisma.cardTransaction.findMany({ where, orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }], skip, take: query.pageSize, include: { travel_order: { select: { id: true, external_order_no: true, status: true, amount: true, currency: true, }, }, }, }), fastify.prisma.cardTransaction.count({ where }), ]); return sendData(reply, request, { page: query.page, pageSize: query.pageSize, total, items: items.map(mapTransaction), }); }, ); fastify.post( "/travel/transactions/import", { preHandler: fastify.requirePermission("integration.manage"), }, async (request, reply) => { if (!fastify.config.travelSyncEnabled) { throw buildValidationError({ integration: "travel_sync_disabled" }, 409); } const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {}); if (!parsed.success) { throw buildValidationError(parsed.error.flatten()); } const rows = normalizeTravelTransactionRows(parsed.data.transactions); if (!rows.length) { throw buildValidationError({ transactions: "empty_after_normalization" }); } const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId))); const travelRequestIds = Array.from(new Set(rows.map((item) => item.travelRequestId))); const authorizationIds = Array.from( new Set(rows.map((item) => item.cardAuthorizationId).filter(Boolean)), ); const travelOrderIds = Array.from(new Set(rows.map((item) => item.travelOrderId).filter(Boolean))); const [employees, travelRequests, authorizations, travelOrders] = await Promise.all([ fastify.prisma.employee.findMany({ where: { id: { in: employeeIds }, status: "active" }, select: { id: true, department_id: true }, }), fastify.prisma.travelRequest.findMany({ where: { id: { in: travelRequestIds } }, select: { id: true, applicant_employee_id: true, budget_pool_id: true, }, }), authorizationIds.length ? fastify.prisma.cardAuthorization.findMany({ where: { id: { in: authorizationIds } }, select: { id: true, employee_id: true, travel_request_id: true, budget_pool_id: true, }, }) : Promise.resolve([]), travelOrderIds.length ? fastify.prisma.travelOrder.findMany({ where: { id: { in: travelOrderIds } }, select: { id: true, employee_id: true, travel_request_id: true, external_order_no: true, amount: true, currency: true, status: true, }, }) : Promise.resolve([]), ]); const employeeMap = new Map(employees.map((item) => [item.id, item])); const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item])); const authorizationMap = new Map(authorizations.map((item) => [item.id, item])); const orderMap = new Map(travelOrders.map((item) => [item.id, item])); const imported = []; const skipped = []; let confirmedCount = 0; let reversedCount = 0; for (const row of rows) { const employee = employeeMap.get(row.employeeId); if (!employee) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "employee_not_found", }); continue; } const travelRequest = travelRequestMap.get(row.travelRequestId); if (!travelRequest) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "travel_request_not_found", }); continue; } if (travelRequest.applicant_employee_id !== row.employeeId) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "travel_request_employee_mismatch", }); continue; } const authorization = row.cardAuthorizationId ? authorizationMap.get(row.cardAuthorizationId) ?? null : null; if (row.cardAuthorizationId && !authorization) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "card_authorization_not_found", }); continue; } if ( authorization && (authorization.employee_id !== row.employeeId || authorization.travel_request_id !== row.travelRequestId) ) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "card_authorization_mismatch", }); continue; } const travelOrder = row.travelOrderId ? orderMap.get(row.travelOrderId) ?? null : null; if (row.travelOrderId && !travelOrder) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "travel_order_not_found", }); continue; } if (travelOrder && travelOrder.employee_id !== row.employeeId) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "travel_order_employee_mismatch", }); continue; } if ( travelOrder && travelOrder.travel_request_id && travelOrder.travel_request_id !== row.travelRequestId ) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "travel_order_request_mismatch", }); continue; } const existing = await fastify.prisma.cardTransaction.findUnique({ where: { source_external_txn_id: { source: row.source, external_txn_id: row.externalTxnId, }, }, }); if (existing && hasTransactionConflict(existing, row)) { skipped.push({ source: row.source, externalTxnId: row.externalTxnId, reason: "transaction_conflict", }); continue; } const txnType = normalizeTransactionType(row.txnType); const status = normalizeTransactionStatus(row.status); const record = await fastify.prisma.$transaction(async (tx) => { const nextCardAuthorizationId = row.hasCardAuthorizationId ? row.cardAuthorizationId ?? null : existing?.card_authorization_id; const nextTravelOrderId = row.hasTravelOrderId ? row.travelOrderId ?? null : existing?.travel_order_id; const upserted = await tx.cardTransaction.upsert({ where: { source_external_txn_id: { source: row.source, external_txn_id: row.externalTxnId, }, }, update: { employee_id: row.employeeId, department_id: employee.department_id, travel_request_id: row.travelRequestId, ...(row.hasCardAuthorizationId ? { card_authorization_id: nextCardAuthorizationId } : {}), ...(row.hasTravelOrderId ? { travel_order_id: nextTravelOrderId } : {}), amount: row.amount, currency: row.currency, txn_type: txnType, status, occurred_at: row.occurredAt, raw_payload: row.rawPayload, updated_by: request.employee.id, }, create: { source: row.source, external_txn_id: row.externalTxnId, card_authorization_id: nextCardAuthorizationId ?? null, employee_id: row.employeeId, department_id: employee.department_id, travel_request_id: row.travelRequestId, travel_order_id: nextTravelOrderId ?? null, amount: row.amount, currency: row.currency, txn_type: txnType, status, occurred_at: row.occurredAt, raw_payload: row.rawPayload, created_by: request.employee.id, updated_by: request.employee.id, }, }); const budgetPoolId = authorization?.budget_pool_id ?? travelRequest.budget_pool_id ?? null; if (txnType === "charge" && status === "settled") { await settleBudgetForEntity(tx, { budgetPoolId, entityId: upserted.id, amount: row.amount, actorId: request.employee.id, note: "travel_transaction_import_settle", }); } if (["refund", "reversal"].includes(txnType) || status === "reversed") { const reverseBudget = await reverseBudgetForEntity(tx, { budgetPoolId, entityId: upserted.id, amount: row.amount, actorId: request.employee.id, note: "travel_transaction_import_reverse", }); if (reverseBudget.applied) { reversedCount += 1; } } if (nextCardAuthorizationId) { await syncAuthorizationSnapshot(tx, nextCardAuthorizationId, request.employee.id); } const nextOrder = nextTravelOrderId ? await loadOrderById(tx, nextTravelOrderId) : null; if ( nextOrder && txnType === "charge" && status === "settled" && nextOrder.status === "imported" && isOrderAmountAndCurrencyMatched(nextOrder, upserted) ) { await tx.travelOrder.update({ where: { id: nextOrder.id }, data: { status: "confirmed", updated_by: request.employee.id, }, }); confirmedCount += 1; } return tx.cardTransaction.findUnique({ where: { id: upserted.id }, include: { travel_order: { select: { id: true, external_order_no: true, status: true, amount: true, currency: true, }, }, }, }); }); imported.push(record); } await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_TRANSACTIONS_IMPORTED", targetType: "card_transaction", extra: { requested: rows.length, createdOrUpdated: imported.length, confirmedCount, reversedCount, skipped, }, }), }); return sendData(reply, request, { requested: rows.length, imported: imported.length, confirmedCount, reversedCount, skipped, items: imported.filter(Boolean).map(mapTransaction), }); }, ); fastify.post( "/travel/transactions/:id/freeze-suggestion", { preHandler: fastify.requirePermission("travel.payment.manage"), }, async (request, reply) => { const parsedId = UUID_SCHEMA.safeParse(request.params?.id); if (!parsedId.success) { throw buildValidationError({ id: "invalid_uuid" }); } const parsedBody = FREEZE_SUGGESTION_SCHEMA.safeParse(request.body ?? {}); if (!parsedBody.success) { throw buildValidationError(parsedBody.error.flatten()); } const transaction = await fastify.prisma.$transaction(async (tx) => { const current = await tx.cardTransaction.findUnique({ where: { id: parsedId.data }, include: { travel_order: { select: { id: true, external_order_no: true, status: true, amount: true, currency: true, }, }, }, }); if (!current) { throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404, }); } const updated = await tx.cardTransaction.update({ where: { id: parsedId.data }, data: { freeze_suggested_at: new Date(), freeze_suggested_by: request.employee.id, freeze_reason_code: parsedBody.data.reasonCode, freeze_reason_note: parsedBody.data.note ?? null, updated_by: request.employee.id, }, include: { travel_order: { select: { id: true, external_order_no: true, status: true, amount: true, currency: true, }, }, }, }); await tx.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "TRAVEL_TRANSACTION_FREEZE_SUGGESTED", targetType: "card_transaction", targetId: updated.id, extra: { reasonCode: parsedBody.data.reasonCode, note: parsedBody.data.note ?? null, travelOrderId: updated.travel_order_id ?? null, }, }), }); return updated; }); return sendData(reply, request, mapTransaction(transaction)); }, ); } module.exports = travelTransactionsRoutes;