import { Router } from "express"; import { randomUUID } from "node:crypto"; import { ApiError } from "../shared/errors"; import { requireAdminToken } from "../shared/middleware/auth"; import { successResponse } from "../shared/middleware/errorMiddleware"; import { env } from "../config/env"; import { idempotency } from "../shared/middleware/idempotency"; import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMerchantPayload } from "../shared/store/merchantStore"; import { createOutlet, createTerminal, getOutletById, getTerminalById, listOutlets, listTerminals, patchOutlet, patchTerminal, toOutletPayload, toTerminalPayload } from "../shared/store/locationStore"; import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBindingPayload, unbindDevice } from "../shared/store/bindingStore"; import { createDevice, getDeviceById, listDevices, patchDevice, toDevicePayload } from "../shared/store/deviceStore"; import { deriveDeviceHealthSummary, getHeartbeatCountForDeviceLastHours, getLatestHeartbeatByDeviceId, listHeartbeats, createDeviceHeartbeat } from "../shared/store/heartbeatStore"; import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDeviceCommandPayload, toDeviceCommandPayloadBrief } from "../shared/store/deviceCommandStore"; import { createTransaction, getTransactionById, listTransactions, toTransactionEventPayload, toTransactionPayload, getTransactionEvents } from "../shared/store/transactionStore"; import { getNotificationByTransactionId, listNotifications, listNotificationsByDevice, toNotificationPayload } from "../shared/store/notificationStore"; import { retryNotificationByTransactionId } from "../shared/orchestrators/notificationOrchestrator"; import { createAuditLog, listAuditLogs, toAuditLogPayload } from "../shared/store/auditLogStore"; import { listLedgerEntries, toLedgerEntryPayload } from "../shared/store/ledgerStore"; import { resolveDeviceCapabilitySummary } from "../shared/services/deviceCapabilityResolver"; import { getOrCreateDeviceConfig, listDeviceConfigAcks, toDeviceConfigAckPayload, toDeviceConfigPayload, upsertDeviceConfig } from "../shared/store/deviceConfigStore"; import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore"; import { publishConfigPush } from "../shared/services/mqttPublisher"; import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus"; import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry"; const router = Router(); function parseIdempotentReplay(req) { return req.body.__idempotentReplay; } function getReplayResponse(req) { return req.body.__idempotentResponse; } function isIsoDate(value) { if (!value) { return false; } return Number.isFinite(Date.parse(value)); } function isTxInDateRange(tx, from, to) { const createdAt = Date.parse(tx.created_at); if (Number.isNaN(createdAt)) { return false; } if (from && createdAt < Date.parse(from)) { return false; } if (to && createdAt > Date.parse(to)) { return false; } return true; } function parseDeviceStatusFilter(value) { if (value === "online" || value === "offline" || value === "degraded" || value === "stale") { return value; } return undefined; } function parseCommunicationModeFilter(value) { if (value === "static" || value === "mqtt" || value === "api") { return value; } return undefined; } function parseDeviceCommunicationMode(value) { if (value === "static" || value === "mqtt" || value === "api") { return value; } return undefined; } function parseDeviceStatusValue(value) { if (value === "active" || value === "inactive") { return value; } return undefined; } function parsePayoutMode(value) { if (value === "merchant_direct" || value === "manual") { return value; } return undefined; } function parseOutletStatusFilter(value) { if (value === "active" || value === "inactive") { return value; } return undefined; } function parseTerminalStatusFilter(value) { if (value === "active" || value === "inactive") { return value; } return undefined; } function parseTerminalModeFilter(value) { if (value === "static" || value === "dynamic_mqtt" || value === "dynamic_api") { return value; } return undefined; } function parseTransactionStatusFilter(value) { if (value === "initiated" || value === "awaiting_payment" || value === "paid" || value === "failed" || value === "expired" || value === "reversed") { return value; } return undefined; } function parseCommandStatusFilter(value) { if (value === "accepted" || value === "delivered" || value === "failed" || value === "timeout") { return value; } return undefined; } function buildBindingSummary(binding) { if (!binding) { return null; } return { id: binding.id, merchant_id: binding.merchant_id, outlet_id: binding.outlet_id, terminal_id: binding.terminal_id }; } async function buildDeviceAdminPayload(device) { const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); const healthSummary = deriveDeviceHealthSummary({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, battery_level: latestHeartbeat?.battery_level ?? null }); return { ...toDevicePayload(device), capability_summary: resolveDeviceCapabilitySummary(device), derived_status: healthSummary.status, health_summary: healthSummary, heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id), binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)), latest_heartbeat: latestHeartbeat ? { id: latestHeartbeat.id, timestamp: latestHeartbeat.timestamp, received_at: latestHeartbeat.received_at, state: latestHeartbeat.state, network_strength: latestHeartbeat.network_strength, battery_level: latestHeartbeat.battery_level, firmware_version: latestHeartbeat.firmware_version } : null }; } async function deriveDeviceStatusesForDashboard() { const devices = await listDevices(); return Promise.all(devices.map(async (device) => { const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); const healthSummary = deriveDeviceHealthSummary({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, battery_level: latestHeartbeat?.battery_level ?? null }); return { device, status: healthSummary.status, healthSummary }; })); } function buildDashboardRange() { const start = new Date(); start.setUTCHours(0, 0, 0, 0); const end = new Date(start); end.setUTCDate(start.getUTCDate() + 1); return { start, end }; } function toStartEndDateFilter(from, to) { if (from && Number.isNaN(Date.parse(from))) { return null; } if (to && Number.isNaN(Date.parse(to))) { return null; } return { fromTs: from ? Date.parse(from) : null, toTs: to ? Date.parse(to) : null }; } function normalizeMerchantMode(payloadMode) { return payloadMode || "merchant_direct"; } async function auditAdminAction(req, payload) { await createAuditLog({ actor_type: "admin", actor_id: "admin", action: payload.action, entity_type: payload.entity_type, entity_id: payload.entity_id, before_json: payload.before_json, after_json: payload.after_json, source_ip: req.ip, request_id: req.requestId, trace_id: req.traceId }); } async function publishDeviceConfigPush(deviceId, config) { const mqttPayload = { message_type: "config_push", config_version: config.config_version, settings: config.settings_json }; const publishResult = await publishConfigPush(deviceId, mqttPayload); return createMqttMessage({ direction: "downlink", device_id: deviceId, topic: publishResult.topic, message_type: "config_push", correlation_id: `config:${config.config_version}`, payload_json: mqttPayload, publish_status: publishResult.ok ? "sent" : "failed", reason: publishResult.reason }); } function validatePayoutConfig(payload) { const mode = normalizeMerchantMode(payload.payout_mode); if (mode === "merchant_direct") { if (!payload.settlement_account_reference || !payload.settlement_account_type) { throw new ApiError("BAD_REQUEST", "settlement_account_reference and settlement_account_type required when payout_mode=merchant_direct", 400); } } } async function ensureMerchant(req, next) { const merchantId = req.params.merchantId; const merchant = await getMerchantById(merchantId); if (!merchant) { return next(new ApiError("NOT_FOUND", "merchant not found", 404)); } return merchant; } router.post("/login", async (req, res, next) => { const { username, password } = req.body; if (username !== "admin" || password !== "admin") { return next(new ApiError("UNAUTHORIZED", "Invalid credentials", 401)); } const token = env.ADMIN_TOKEN; res.json(successResponse(req, { token })); }); router.use(async (req, res, next) => { if (req.path === "/login") { return next(); } return requireAdminToken(req, res, next); }); router.get("/health", requireAdminToken, async (_req, res) => { res.json(successResponse(_req, { ok: true, now: new Date().toISOString() })); }); router.post("/sample-idempotent", requireAdminToken, idempotency({ scope: "admin.sample", required: false }), async (req, res) => { if (parseIdempotentReplay(req)) { return res.status(200).json(getReplayResponse(req)); } const id = randomUUID(); res.json(successResponse(req, { id, generated_at: new Date().toISOString() })); }); router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.create", required: false }), async (req, res, next) => { if (parseIdempotentReplay(req)) { return res.status(200).json(getReplayResponse(req)); } const payload = req.body; if (!payload?.legal_name) { return next(new ApiError("BAD_REQUEST", "legal_name is required", 400)); } const normalizedPayoutMode = parsePayoutMode(payload.payout_mode); if (payload.payout_mode && !normalizedPayoutMode) { return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400)); } try { validatePayoutConfig(payload); } catch (err) { return next(err); } const created = await createMerchant({ legal_name: payload.legal_name, brand_name: payload.brand_name, settlement_account_reference: payload.settlement_account_reference, settlement_account_type: payload.settlement_account_type, payout_mode: normalizeMerchantMode(payload.payout_mode), fee_profile_id: payload.fee_profile_id, status: payload.status, onboarding_status: payload.onboarding_status }); await auditAdminAction(req, { action: "merchant.create", entity_type: "merchant", entity_id: created.id, after_json: toMerchantPayload(created) }); res.status(201).json(successResponse(req, toMerchantPayload(created))); }); router.get("/merchants", requireAdminToken, async (_req, res) => { res.json(successResponse(_req, (await listMerchants()).map(toMerchantPayload))); }); router.get("/merchants/:merchantId", requireAdminToken, async (req, res, next) => { const merchant = await getMerchantById(req.params.merchantId); if (!merchant) { return next(new ApiError("NOT_FOUND", "merchant not found", 404)); } res.json(successResponse(req, toMerchantPayload(merchant))); }); router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next) => { const payload = req.body; if (!payload || Object.keys(payload).length === 0) { return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); } const existing = await getMerchantById(req.params.merchantId); if (!existing) { return next(new ApiError("NOT_FOUND", "merchant not found", 404)); } const normalized = { ...payload, payout_mode: payload.payout_mode ? payload.payout_mode : existing.payout_mode }; if (normalized.payout_mode && !parsePayoutMode(normalized.payout_mode)) { return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400)); } if (normalized.payout_mode === "merchant_direct") { normalized.settlement_account_reference = normalized.settlement_account_reference || existing.settlement_account_reference; normalized.settlement_account_type = normalized.settlement_account_type || existing.settlement_account_type; } try { validatePayoutConfig(normalized); } catch (err) { return next(err); } const updated = await patchMerchant(req.params.merchantId, normalized); await auditAdminAction(req, { action: "merchant.update", entity_type: "merchant", entity_id: updated.id, before_json: toMerchantPayload(existing), after_json: toMerchantPayload(updated) }); res.json(successResponse(req, toMerchantPayload(updated))); }); router.post("/merchants/:merchantId/approve", requireAdminToken, async (req, res, next) => { const existing = await getMerchantById(req.params.merchantId); if (!existing) { return next(new ApiError("NOT_FOUND", "merchant not found", 404)); } if (existing.onboarding_status === "approved") { return res.json(successResponse(req, toMerchantPayload(existing))); } const updated = await patchMerchant(req.params.merchantId, { onboarding_status: "approved" }); await auditAdminAction(req, { action: "merchant.approve", entity_type: "merchant", entity_id: updated.id, before_json: toMerchantPayload(existing), after_json: toMerchantPayload(updated) }); res.json(successResponse(req, toMerchantPayload(updated))); }); router.post("/merchants/:merchantId/reject", requireAdminToken, async (req, res, next) => { const payload = req.body; if (!payload?.reason || payload.reason.trim() === "") { return next(new ApiError("BAD_REQUEST", "reason is required", 400)); } const existing = await getMerchantById(req.params.merchantId); if (!existing) { return next(new ApiError("NOT_FOUND", "merchant not found", 404)); } const updated = await patchMerchant(req.params.merchantId, { onboarding_status: "rejected", status: "inactive" }); await auditAdminAction(req, { action: "merchant.reject", entity_type: "merchant", entity_id: updated.id, before_json: toMerchantPayload(existing), after_json: { ...toMerchantPayload(updated), rejection_reason: payload.reason } }); res.json(successResponse(req, { ...toMerchantPayload(updated), rejection_reason: payload.reason })); }); router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", required: false }), async (req, res, next) => { if (parseIdempotentReplay(req)) { return res.status(200).json(getReplayResponse(req)); } const payload = req.body || {}; const includeHeartbeat = payload.include_demo_heartbeat !== false; const includeTransactions = payload.include_demo_transactions !== false; if ((await listMerchants()).length > 0 || (await listDevices()).length > 0 || (await listOutlets()).length > 0 || (await listTerminals()).length > 0) { return next(new ApiError("BAD_REQUEST", "seed requires empty demo environment", 400)); } const merchantA = await createMerchant({ legal_name: "Seed Merchant A", brand_name: "Seed A", settlement_account_reference: "seed-bank:111111111", settlement_account_type: "merchant_bank_account", payout_mode: "merchant_direct", status: "active", onboarding_status: "approved" }); const merchantB = await createMerchant({ legal_name: "Seed Merchant B", brand_name: "Seed B", settlement_account_reference: "seed-bank:222222222", settlement_account_type: "merchant_bank_account", payout_mode: "manual", status: "active", onboarding_status: "pending" }); const outletA = await createOutlet({ merchant_id: merchantA.id, name: "Outlet Seed A", address: "Jl. Contoh Nomor 1" }); const outletB = await createOutlet({ merchant_id: merchantB.id, name: "Outlet Seed B", address: "Jl. Contoh Nomor 2" }); const terminalA = await createTerminal({ outlet_id: outletA.id, terminal_code: "TERM_SEED_A", qr_mode: "static" }); const terminalB = await createTerminal({ outlet_id: outletB.id, terminal_code: "TERM_SEED_B", qr_mode: "static" }); const deviceA = await createDevice({ device_code: "DEV_SEED_A", vendor: "seed-maker", model: "v1", communication_mode: "static", capability_profile_json: { dynamic_qr: false, flows: ["static_payment_notification"] }, status: "active" }); const deviceB = await createDevice({ device_code: "DEV_SEED_B", vendor: "seed-maker", model: "v1", communication_mode: "mqtt", capability_profile_json: { dynamic_qr: { mqtt: true, api_direct: false }, flows: ["dynamic_qr:mqtt", "static_payment_notification"] }, status: "active" }); const deviceC = await createDevice({ device_code: "DEV_SEED_C", vendor: "seed-maker", model: "v1", communication_mode: "api", capability_profile_json: { dynamic_qr: { api_direct: true, mqtt: false }, flows: ["dynamic_qr:api_direct", "static_payment_notification"] }, status: "active" }); await bindDevice({ device_id: deviceA.id, merchant_id: merchantA.id, outlet_id: outletA.id, terminal_id: terminalA.id }); await bindDevice({ device_id: deviceB.id, merchant_id: merchantB.id, outlet_id: outletB.id, terminal_id: terminalB.id }); if (includeHeartbeat) { await createDeviceHeartbeat({ device_id: deviceA.id, timestamp: new Date().toISOString(), firmware_version: "1.0.0", network_strength: 92, battery_level: 89, state: "idle" }); await createDeviceHeartbeat({ device_id: deviceB.id, timestamp: new Date().toISOString(), firmware_version: "1.0.0", network_strength: 83, battery_level: 76, state: "idle" }); } const transactions = includeTransactions ? [ await createTransaction({ merchant_id: merchantA.id, outlet_id: outletA.id, terminal_id: terminalA.id, device_id: deviceA.id, partner_reference: "seed-pr-001", amount: 25000, currency: "IDR", qr_mode: "static", initiation_mode: "static", status: "initiated" }), await createTransaction({ merchant_id: merchantB.id, outlet_id: outletB.id, terminal_id: terminalB.id, device_id: deviceB.id, partner_reference: "seed-pr-002", amount: 50000, currency: "IDR", qr_mode: "static", initiation_mode: "static", status: "awaiting_payment" }) ] : []; const seeded = { merchants: [toMerchantPayload(merchantA), toMerchantPayload(merchantB)], outlets: [outletA, outletB], terminals: [terminalA, terminalB], devices: [deviceA, deviceB, deviceC], transactions: transactions.map((tx) => toTransactionPayload(tx)), include_demo_heartbeat: includeHeartbeat, include_demo_transactions: includeTransactions }; res.status(201).json(successResponse(req, seeded)); }); router.get("/seed/status", requireAdminToken, async (_req, res) => { res.json(successResponse(_req, { merchants: (await listMerchants()).length, outlets: (await listOutlets()).length, terminals: (await listTerminals()).length, devices: (await listDevices()).length, transactions: (await listTransactions()).length, heartbeats: (await listHeartbeats()).length, notifications: (await listNotifications()).length, seed_eligible: (await listMerchants()).length === 0 && (await listDevices()).length === 0 && (await listOutlets()).length === 0 && (await listTerminals()).length === 0 })); }); router.post("/merchants/:merchantId/outlets", requireAdminToken, idempotency({ scope: "outlet.create", required: false }), async (req, res, next) => { if (parseIdempotentReplay(req)) { return res.status(200).json(getReplayResponse(req)); } const merchant = await ensureMerchant(req, next); if (!merchant) { return; } const payload = req.body; if (!payload?.name) { return next(new ApiError("BAD_REQUEST", "name is required", 400)); } if (payload.status) { if (!parseOutletStatusFilter(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } } const outlet = await createOutlet({ merchant_id: merchant.id, name: payload.name, address: payload.address, outlet_code: payload.outlet_code, status: payload.status }); await auditAdminAction(req, { action: "outlet.create", entity_type: "outlet", entity_id: outlet.id, after_json: toOutletPayload(outlet) }); res.status(201).json(successResponse(req, outlet)); }); router.get("/outlets", requireAdminToken, async (req, res, next) => { const merchantId = req.query.merchant_id?.trim(); const statusRaw = req.query.status?.trim(); const status = parseOutletStatusFilter(statusRaw); if (statusRaw && !status) { return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } const q = req.query.q?.trim(); res.json(successResponse(req, (await listOutlets({ merchant_id: merchantId, status, q: q || undefined })).map(toOutletPayload))); }); router.get("/outlets/:outletId", requireAdminToken, async (req, res, next) => { const outlet = await getOutletById(req.params.outletId); if (!outlet) { return next(new ApiError("NOT_FOUND", "outlet not found", 404)); } res.json(successResponse(req, toOutletPayload(outlet))); }); router.patch("/outlets/:outletId", requireAdminToken, async (req, res, next) => { const payload = req.body; if (!payload || Object.keys(payload).length === 0) { return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); } if (payload.status && !parseOutletStatusFilter(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } try { const existing = await getOutletById(req.params.outletId); const updated = await patchOutlet(req.params.outletId, payload); await auditAdminAction(req, { action: "outlet.update", entity_type: "outlet", entity_id: updated.id, before_json: existing ? toOutletPayload(existing) : null, after_json: toOutletPayload(updated) }); res.json(successResponse(req, toOutletPayload(updated))); } catch (err) { if (err instanceof Error && err.message === "OUTLET_NOT_FOUND") { return next(new ApiError("NOT_FOUND", "outlet not found", 404)); } return next(err); } }); router.post("/outlets/:outletId/terminals", requireAdminToken, idempotency({ scope: "terminal.create", required: false }), async (req, res, next) => { if (parseIdempotentReplay(req)) { return res.status(201).json(getReplayResponse(req)); } const outlet = await getOutletById(req.params.outletId); if (!outlet) { return next(new ApiError("NOT_FOUND", "outlet not found", 404)); } const payload = req.body; if (!payload || typeof payload !== "object") { return next(new ApiError("BAD_REQUEST", "terminal payload required", 400)); } if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) { return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400)); } if (payload.status && !parseTerminalStatusFilter(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } const terminal = await createTerminal({ outlet_id: outlet.id, terminal_code: payload.terminal_code, qr_mode: payload.qr_mode, partner_reference: payload.partner_reference, status: payload.status }); await auditAdminAction(req, { action: "terminal.create", entity_type: "terminal", entity_id: terminal.id, after_json: toTerminalPayload(terminal) }); res.status(201).json(successResponse(req, toTerminalPayload(terminal))); }); router.get("/terminals", requireAdminToken, async (req, res, next) => { const outletId = req.query.outlet_id?.trim(); const statusRaw = req.query.status?.trim(); const status = parseTerminalStatusFilter(statusRaw); if (statusRaw && !status) { return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } const q = req.query.q?.trim(); res.json(successResponse(req, (await listTerminals({ outlet_id: outletId, status, q: q || undefined })).map(toTerminalPayload))); }); router.get("/terminals/:terminalId", requireAdminToken, async (req, res, next) => { const terminal = await getTerminalById(req.params.terminalId); if (!terminal) { return next(new ApiError("NOT_FOUND", "terminal not found", 404)); } res.json(successResponse(req, toTerminalPayload(terminal))); }); router.patch("/terminals/:terminalId", requireAdminToken, async (req, res, next) => { const payload = req.body; if (!payload || Object.keys(payload).length === 0) { return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); } if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) { return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400)); } if (payload.status && !parseTerminalStatusFilter(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } try { const existing = await getTerminalById(req.params.terminalId); const updated = await patchTerminal(req.params.terminalId, payload); await auditAdminAction(req, { action: "terminal.update", entity_type: "terminal", entity_id: updated.id, before_json: existing ? toTerminalPayload(existing) : null, after_json: toTerminalPayload(updated) }); res.json(successResponse(req, toTerminalPayload(updated))); } catch (err) { if (err instanceof Error && err.message === "TERMINAL_NOT_FOUND") { return next(new ApiError("NOT_FOUND", "terminal not found", 404)); } return next(err); } }); router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", required: false }), async (req, res, next) => { if (parseIdempotentReplay(req)) { return res.status(201).json(getReplayResponse(req)); } const payload = req.body; if (!payload) { return next(new ApiError("BAD_REQUEST", "device payload required", 400)); } if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) { return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400)); } if (payload.status && !parseDeviceStatusValue(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } const created = await createDevice(payload); await auditAdminAction(req, { action: "device.create", entity_type: "device", entity_id: created.id, after_json: toDevicePayload(created) }); res.status(201).json(successResponse(req, toDevicePayload(created))); }); router.get("/devices", requireAdminToken, async (req, res) => { const status = parseDeviceStatusFilter(req.query.status); const vendor = req.query.vendor?.trim(); const communicationMode = parseCommunicationModeFilter(req.query.communication_mode); const merchantId = req.query.merchant_id?.trim(); const q = req.query.q?.trim(); const rawDevices = await listDevices(); const evaluated = await Promise.all(rawDevices.map(async (device) => { const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); const binding = merchantId ? await getActiveBindingByDevice(device.id) : null; const healthSummary = deriveDeviceHealthSummary({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, battery_level: latestHeartbeat?.battery_level ?? null }); return { device, latestHeartbeat, binding, derivedStatus: healthSummary.status }; })); const data = evaluated .filter((entry) => { const { device, derivedStatus, binding } = entry; if (status && derivedStatus !== status) { return false; } if (vendor && device.vendor !== vendor) { return false; } if (communicationMode && device.communication_mode !== communicationMode) { return false; } if (merchantId && (!binding || binding.merchant_id !== merchantId)) { return false; } if (q) { const text = q.toLowerCase(); const codeMatch = device.device_code.toLowerCase().includes(text); const serialMatch = device.serial_number?.toLowerCase().includes(text); if (!codeMatch && !serialMatch) { return false; } } return true; }) .map((entry) => entry.device); const payloads = await Promise.all(data .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .map((device) => buildDeviceAdminPayload(device))); res.json(successResponse(req, payloads)); }); router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const activeBinding = await getActiveBindingByDevice(device.id); const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id); const healthSummary = deriveDeviceHealthSummary({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, battery_level: latestHeartbeat?.battery_level ?? null }); const notifications = (await listNotificationsByDevice(device.id)) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .slice(0, 10) .map(toNotificationPayload); res.json(successResponse(req, { ...toDevicePayload(device), capability_summary: resolveDeviceCapabilitySummary(device), derived_status: healthSummary.status, health_summary: healthSummary, active_binding: activeBinding ? toBindingPayload(activeBinding) : null, latest_heartbeat: latestHeartbeat ? { id: latestHeartbeat.id, timestamp: latestHeartbeat.timestamp, received_at: latestHeartbeat.received_at, state: latestHeartbeat.state, network_strength: latestHeartbeat.network_strength, battery_level: latestHeartbeat.battery_level, firmware_version: latestHeartbeat.firmware_version } : null, heartbeat_count_24h: heartbeatCount24h, notifications_latest: notifications })); }); router.get("/devices/:deviceId/heartbeats", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const from = req.query.from; const to = req.query.to; const state = req.query.state; if (from && Number.isNaN(Date.parse(from))) { return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); } if (to && Number.isNaN(Date.parse(to))) { return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); } res.json(successResponse(req, { device_id: device.id, heartbeats: await listHeartbeats({ device_id: device.id, from, to, state }) })); }); router.patch("/devices/:deviceId", requireAdminToken, async (req, res, next) => { const payload = req.body; if (!payload || Object.keys(payload).length === 0) { return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); } if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) { return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400)); } if (payload.status && !parseDeviceStatusValue(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); } try { const existing = await getDeviceById(req.params.deviceId); const updated = await patchDevice(req.params.deviceId, payload); await auditAdminAction(req, { action: "device.update", entity_type: "device", entity_id: updated.id, before_json: existing ? toDevicePayload(existing) : null, after_json: toDevicePayload(updated) }); res.json(successResponse(req, toDevicePayload(updated))); } catch (err) { if (err instanceof Error && err.message === "DEVICE_NOT_FOUND") { return next(new ApiError("NOT_FOUND", "device not found", 404)); } return next(err); } }); router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "device.bind", required: false }), async (req, res, next) => { if (parseIdempotentReplay(req)) { return res.status(200).json(getReplayResponse(req)); } const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const payload = req.body; if (!payload?.merchant_id || !payload.outlet_id || !payload.terminal_id) { return next(new ApiError("BAD_REQUEST", "merchant_id, outlet_id, terminal_id required", 400)); } const merchant = await getMerchantById(payload.merchant_id); const outlet = await getOutletById(payload.outlet_id); const terminal = await getTerminalById(payload.terminal_id); if (!merchant || !outlet || !terminal) { return next(new ApiError("BAD_REQUEST", "merchant/outlet/terminal reference invalid", 400)); } if (outlet.merchant_id !== merchant.id) { return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400)); } if (terminal.outlet_id !== outlet.id) { return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400)); } const binding = await bindDevice({ device_id: device.id, merchant_id: merchant.id, outlet_id: outlet.id, terminal_id: terminal.id }); await auditAdminAction(req, { action: "device.bind", entity_type: "device_binding", entity_id: binding.id, after_json: toBindingPayload(binding) }); res.json(successResponse(req, toBindingPayload(binding))); }); router.post("/devices/:deviceId/unbind", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const binding = await unbindDevice(device.id); if (!binding) { return next(new ApiError("BAD_REQUEST", "device has no active binding", 400)); } await auditAdminAction(req, { action: "device.unbind", entity_type: "device_binding", entity_id: binding.id, before_json: toBindingPayload(binding), after_json: toBindingPayload(binding) }); res.json(successResponse(req, toBindingPayload(binding))); }); router.post("/devices/:deviceId/commands", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const payload = req.body; if (!payload || typeof payload.command !== "string" || payload.command.trim() === "") { return next(new ApiError("BAD_REQUEST", "command is required", 400)); } const command = await createDeviceCommand({ device_id: device.id, command: payload.command.trim(), payload: payload.payload || {} }); res.status(201).json(successResponse(req, toDeviceCommandPayload(command))); }); router.get("/devices/:deviceId/commands", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const statusFilter = parseCommandStatusFilter(req.query.status); const limitRaw = req.query.limit; const limit = limitRaw ? Number(limitRaw) : 50; if (!Number.isFinite(limit) || limit <= 0) { return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); } const commands = (await listDeviceCommands(device.id)) .filter((command) => !statusFilter || command.status === statusFilter) .slice(0, Math.min(limit, 200)) .map(toDeviceCommandPayloadBrief); res.json(successResponse(req, { device_id: device.id, commands })); }); router.get("/devices/:deviceId/commands/:commandId", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const command = await getDeviceCommandById(device.id, req.params.commandId); if (!command) { return next(new ApiError("NOT_FOUND", "command not found", 404)); } res.json(successResponse(req, toDeviceCommandPayload(command))); }); router.get("/devices/:deviceId/notifications", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const limitRaw = req.query.limit; const limit = limitRaw ? Number(limitRaw) : 50; if (!Number.isFinite(limit) || limit <= 0) { return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); } const notifications = (await listNotificationsByDevice(device.id)) .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) .slice(0, Math.min(limit, 200)) .map(toNotificationPayload); res.json(successResponse(req, { device_id: device.id, notifications })); }); router.get("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const config = await getOrCreateDeviceConfig(device.id); const acks = (await listDeviceConfigAcks(device.id, 10)).map(toDeviceConfigAckPayload); const status = await buildDeviceConfigStatus(config); res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks, config_status: status })); }); router.get("/devices/:deviceId/config/status", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const config = await getOrCreateDeviceConfig(device.id); res.json(successResponse(req, await buildDeviceConfigStatus(config))); }); router.patch("/devices/:deviceId/config", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const payload = req.body; if (!payload || !payload.settings || typeof payload.settings !== "object") { return next(new ApiError("BAD_REQUEST", "settings object is required", 400)); } const config = await upsertDeviceConfig({ device_id: device.id, settings_json: payload.settings, config_version: payload.config_version }); const outbox = await publishDeviceConfigPush(device.id, config); await auditAdminAction(req, { action: "device.config_push", entity_type: "device", entity_id: device.id, after_json: { config, downlink_message_id: outbox.id } }); res.json(successResponse(req, { config: toDeviceConfigPayload(config), downlink_message: toMqttMessagePayload(outbox) })); }); router.post("/devices/:deviceId/config/retry-push", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const payload = (req.body || {}); const config = await getOrCreateDeviceConfig(device.id); const beforeStatus = await buildDeviceConfigStatus(config); if (beforeStatus.drift_status === "applied" && !payload.force) { return next(new ApiError("CONFIG_ALREADY_APPLIED", "config already applied; set force=true to push again", 409)); } const outbox = await publishDeviceConfigPush(device.id, config); const afterStatus = await buildDeviceConfigStatus(config); await auditAdminAction(req, { action: "device.config_retry_push", entity_type: "device", entity_id: device.id, before_json: beforeStatus, after_json: { ...afterStatus, downlink_message_id: outbox.id, force: payload.force === true } }); res.json(successResponse(req, { config: toDeviceConfigPayload(config), config_status: afterStatus, downlink_message: toMqttMessagePayload(outbox) })); }); router.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req, res, next) => { const device = await getDeviceById(req.params.deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const directionRaw = req.query.direction?.trim(); const direction = directionRaw === "uplink" || directionRaw === "downlink" ? directionRaw : undefined; if (directionRaw && !direction) { return next(new ApiError("BAD_REQUEST", "direction must be uplink or downlink", 400)); } const messageType = req.query.message_type?.trim(); const correlationId = req.query.correlation_id?.trim(); const limitRaw = req.query.limit; const limit = limitRaw ? Number(limitRaw) : 100; if (!Number.isFinite(limit) || limit <= 0) { return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); } const messages = await listMqttMessages({ device_id: device.id, direction, message_type: messageType || undefined, correlation_id: correlationId || undefined, limit }); res.json(successResponse(req, { device_id: device.id, messages: messages.map(toMqttMessagePayload) })); }); router.post("/transactions", requireAdminToken, idempotency({ scope: "transaction.create", required: false }), async (req, res, next) => { if (parseIdempotentReplay(req)) { return res.status(201).json(getReplayResponse(req)); } const payload = req.body; if (!payload || !payload.partner_reference || !payload.merchant_id || !payload.outlet_id || !payload.terminal_id) { return next(new ApiError("BAD_REQUEST", "partner_reference, merchant_id, outlet_id, terminal_id required", 400)); } const merchant = await getMerchantById(payload.merchant_id); if (!merchant) { return next(new ApiError("BAD_REQUEST", "merchant not found", 400)); } const outlet = await getOutletById(payload.outlet_id); if (!outlet) { return next(new ApiError("BAD_REQUEST", "outlet not found", 400)); } if (outlet.merchant_id !== merchant.id) { return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400)); } const terminal = await getTerminalById(payload.terminal_id); if (!terminal) { return next(new ApiError("BAD_REQUEST", "terminal not found", 400)); } if (terminal.outlet_id !== outlet.id) { return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400)); } if (payload.device_id && !await getDeviceById(payload.device_id)) { return next(new ApiError("BAD_REQUEST", "device not found", 400)); } const amount = Number(payload.amount); if (!Number.isFinite(amount) || amount <= 0) { return next(new ApiError("BAD_REQUEST", "amount must be a positive number", 400)); } if (payload.status && !parseTransactionStatusFilter(payload.status)) { return next(new ApiError("BAD_REQUEST", "invalid status", 400)); } if (payload.expired_at && Number.isNaN(Date.parse(payload.expired_at))) { return next(new ApiError("BAD_REQUEST", "expired_at must be valid ISO datetime", 400)); } const created = await createTransaction({ merchant_id: merchant.id, outlet_id: outlet.id, terminal_id: terminal.id, device_id: payload.device_id, partner_reference: payload.partner_reference, amount, currency: payload.currency, qr_mode: payload.qr_mode || "static", initiation_mode: payload.initiation_mode || "static", status: payload.status || "initiated", expired_at: payload.expired_at }); await auditAdminAction(req, { action: "transaction.create", entity_type: "transaction", entity_id: created.id, after_json: toTransactionPayload(created) }); res.status(201).json(successResponse(req, toTransactionPayload(created))); }); router.get("/transactions", requireAdminToken, async (req, res, next) => { const statusRaw = req.query.status?.trim(); const status = parseTransactionStatusFilter(statusRaw); if (statusRaw && !status) { return next(new ApiError("BAD_REQUEST", "invalid status", 400)); } const merchantId = req.query.merchant_id?.trim(); const from = req.query.from; const to = req.query.to; const partnerReference = req.query.partner_reference; const q = req.query.q?.trim(); if (from && !isIsoDate(from)) { return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); } if (to && !isIsoDate(to)) { return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); } const normalizedPartnerRef = partnerReference?.trim(); const normalizedQ = q || ""; res.json(successResponse(req, (await listTransactions({ status, merchant_id: merchantId })) .filter((tx) => isTxInDateRange(tx, from, to)) .filter((tx) => !normalizedPartnerRef || tx.partner_reference === normalizedPartnerRef) .filter((tx) => { if (!normalizedQ) { return true; } const lower = normalizedQ.toLowerCase(); return (tx.partner_reference.toLowerCase().includes(lower) || tx.transaction_code.toLowerCase().includes(lower)); }) .map(toTransactionPayload))); }); router.post("/transactions/expire-due", requireAdminToken, async (req, res, next) => { const limitRaw = req.body?.limit ?? req.query.limit; const limit = limitRaw === undefined || limitRaw === "" ? 100 : Number(limitRaw); if (!Number.isFinite(limit) || limit <= 0) { return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); } const result = await expireDueDynamicQrTransactions({ limit, source: "admin", request_id: req.requestId }); await auditAdminAction(req, { action: "transaction.expire_due_dynamic_qr", entity_type: "transaction_batch", entity_id: `dynamic_qr_expiry_${result.swept_at}`, after_json: { scanned: result.scanned, expired_count: result.expired_count, skipped_count: result.skipped_count, expired_ids: result.expired.map((tx) => tx.id), skipped: result.skipped } }); res.json(successResponse(req, result)); }); router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => { const tx = await getTransactionById(req.params.transactionId); if (!tx) { return next(new ApiError("NOT_FOUND", "transaction not found", 404)); } const events = (await getTransactionEvents(tx.id)).map(toTransactionEventPayload); const ledger_entries = (await listLedgerEntries({ transaction_id: tx.id })).map(toLedgerEntryPayload); const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null; const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id; const heartbeatHistory = heartbeatDeviceId ? (await listHeartbeats({ device_id: heartbeatDeviceId })).map((heartbeat) => ({ id: heartbeat.id, device_id: heartbeat.device_id, timestamp: heartbeat.timestamp, state: heartbeat.state, network_strength: heartbeat.network_strength, battery_level: heartbeat.battery_level, firmware_version: heartbeat.firmware_version, received_at: heartbeat.received_at })) : []; res.json(successResponse(req, { transaction: toTransactionPayload(tx), events, ledger_entries, heartbeat_device_id: heartbeatDeviceId, heartbeat_history: heartbeatHistory })); }); router.get("/transactions/:transactionId/events", requireAdminToken, async (req, res, next) => { const tx = await getTransactionById(req.params.transactionId); if (!tx) { return next(new ApiError("NOT_FOUND", "transaction not found", 404)); } const events = (await getTransactionEvents(tx.id)) .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) .map(toTransactionEventPayload); res.json(successResponse(req, { transaction_id: tx.id, events })); }); router.get("/transactions/:transactionId/heartbeats", requireAdminToken, async (req, res, next) => { const tx = await getTransactionById(req.params.transactionId); if (!tx) { return next(new ApiError("NOT_FOUND", "transaction not found", 404)); } const from = req.query.from; const to = req.query.to; const state = req.query.state; const limitRaw = req.query.limit; const limit = limitRaw ? Number(limitRaw) : 100; if (from && Number.isNaN(Date.parse(from))) { return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); } if (to && Number.isNaN(Date.parse(to))) { return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); } if (!Number.isFinite(limit) || limit <= 0) { return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); } const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null; const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id; if (!heartbeatDeviceId) { return res.json(successResponse(req, { transaction_id: tx.id, heartbeat_device_id: null, heartbeats: [] })); } const heartbeats = (await listHeartbeats({ device_id: heartbeatDeviceId, from, to, state })) .slice(0, Math.min(limit, 500)) .map((heartbeat) => ({ id: heartbeat.id, device_id: heartbeat.device_id, timestamp: heartbeat.timestamp, received_at: heartbeat.received_at, state: heartbeat.state, network_strength: heartbeat.network_strength, battery_level: heartbeat.battery_level, firmware_version: heartbeat.firmware_version })); res.json(successResponse(req, { transaction_id: tx.id, heartbeat_device_id: heartbeatDeviceId, heartbeats })); }); router.post("/transactions/:transactionId/retry-notification", requireAdminToken, async (req, res, next) => { const transactionId = req.params.transactionId; try { const tx = await getTransactionById(transactionId); if (!tx) { return next(new ApiError("NOT_FOUND", "transaction not found", 404)); } if (tx.status !== "paid") { return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400)); } const before = await getNotificationByTransactionId(transactionId); if (!before) { return next(new ApiError("NOT_FOUND", "notification not found for this transaction", 404)); } if (before.delivery_status === "acknowledged") { return res.status(200).json(successResponse(req, { transaction_id: tx.id, notification_id: before.id, delivery_status: before.delivery_status, next_retry_at: before.next_retry_at || null })); } const updated = await retryNotificationByTransactionId(transactionId); if (!updated) { return next(new ApiError("NOTIFICATION_PUBLISH_FAILED", "notification retry could not be executed", 500)); } res.json(successResponse(req, { transaction_id: tx.id, notification_id: updated.id, delivery_status: updated.delivery_status, next_retry_at: updated.next_retry_at || null })); } catch (error) { if (error instanceof Error && error.message === "NOTIFICATION_PUBLISH_CONDITION") { return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400)); } return next(error); } }); router.get("/dashboard/summary", requireAdminToken, async (req, res) => { let dashboard = { transactions_today: 0, success_rate_today: 0, active_devices: 0, pending_notifications: 0, devices_stale: 0, devices_offline: 0 }; try { const { start, end } = buildDashboardRange(); const startTs = start.getTime(); const endTs = end.getTime(); const todayTransactions = (await listTransactions()).filter((tx) => { const createdTs = Date.parse(tx.created_at); return createdTs >= startTs && createdTs < endTs; }); const paidToday = todayTransactions.filter((tx) => tx.status === "paid").length; const transactionsToday = todayTransactions.length; const successRateToday = transactionsToday > 0 ? (paidToday / transactionsToday) * 100 : 0; const statuses = await deriveDeviceStatusesForDashboard(); const activeDevices = statuses.filter((row) => row.status !== "offline").length; const devicesStale = statuses.filter((row) => row.status === "stale").length; const devicesOffline = statuses.filter((row) => row.status === "offline").length; const pendingNotifications = (await listNotifications()).filter((notification) => { return notification.delivery_status === "queued" || notification.delivery_status === "retrying"; }).length; dashboard = { transactions_today: transactionsToday, success_rate_today: Number(successRateToday.toFixed(2)), active_devices: activeDevices, pending_notifications: pendingNotifications, devices_stale: devicesStale, devices_offline: devicesOffline }; } catch (error) { console.error("[dashboard/summary] fallback due calculation error", error instanceof Error ? error.message : error); } res.json(successResponse(req, dashboard)); }); router.get("/notifications/failed", requireAdminToken, async (req, res, next) => { const deviceId = req.query.device_id; const from = req.query.from; const to = req.query.to; const range = toStartEndDateFilter(from, to); if ((from || to) && range === null) { return next(new ApiError("BAD_REQUEST", "from/to must be valid ISO datetime", 400)); } const filtered = (await listNotifications()) .filter((notification) => notification.delivery_status === "failed") .filter((notification) => !deviceId || notification.device_id === deviceId) .filter((notification) => { if (!range) { return true; } const createdTs = Date.parse(notification.created_at); if (range.fromTs && createdTs < range.fromTs) { return false; } if (range.toTs && createdTs > range.toTs) { return false; } return true; }) .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) .map((notification) => ({ notification_id: notification.id, transaction_id: notification.transaction_id, device_id: notification.device_id, delivery_status: notification.delivery_status, retry_count: notification.retry_count, reason: notification.reason })); res.json(successResponse(req, filtered)); }); router.get("/audit-logs", requireAdminToken, async (req, res, next) => { const entityType = req.query.entity_type?.trim(); const entityId = req.query.entity_id?.trim(); const action = req.query.action?.trim(); const from = req.query.from; const to = req.query.to; const limitRaw = req.query.limit; const limit = limitRaw ? Number(limitRaw) : 100; if (from && Number.isNaN(Date.parse(from))) { return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); } if (to && Number.isNaN(Date.parse(to))) { return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); } if (!Number.isFinite(limit) || limit <= 0) { return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); } const logs = await listAuditLogs({ entity_type: entityType || undefined, entity_id: entityId || undefined, action: action || undefined, from, to, limit }); res.json(successResponse(req, logs.map(toAuditLogPayload))); }); router.get("/ledger-entries", requireAdminToken, async (req, res, next) => { const transactionId = req.query.transaction_id?.trim(); const merchantId = req.query.merchant_id?.trim(); const limitRaw = req.query.limit; const limit = limitRaw ? Number(limitRaw) : 100; if (!Number.isFinite(limit) || limit <= 0) { return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); } const entries = await listLedgerEntries({ transaction_id: transactionId || undefined, merchant_id: merchantId || undefined, limit }); res.json(successResponse(req, entries.map(toLedgerEntryPayload))); }); export default router;