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 { deriveDeviceStatus, 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"; 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 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); return { ...toDevicePayload(device), derived_status: deriveDeviceStatus({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, battery_level: latestHeartbeat?.battery_level ?? null }), 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); return { device, status: deriveDeviceStatus({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, battery_level: latestHeartbeat?.battery_level ?? null }) }; })); } 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"; } 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)); } 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 }); 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 === "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); 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" }); 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" }); 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: "mqtt", status: "active" }); const deviceB = await createDevice({ device_code: "DEV_SEED_B", vendor: "seed-maker", model: "v1", communication_mode: "mqtt", status: "active" }); const deviceC = await createDevice({ device_code: "DEV_SEED_C", vendor: "seed-maker", model: "v1", communication_mode: "mqtt", status: "inactive" }); 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 }); res.status(201).json(successResponse(req, outlet)); }); router.get("/outlets", requireAdminToken, async (req, res) => { const merchantId = req.query.merchant_id; res.json(successResponse(req, (await listOutlets({ merchant_id: merchantId })).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 updated = await patchOutlet(req.params.outletId, payload); 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 }); res.status(201).json(successResponse(req, toTerminalPayload(terminal))); }); router.get("/terminals", requireAdminToken, async (req, res) => { const outletId = req.query.outlet_id; res.json(successResponse(req, (await listTerminals({ outlet_id: outletId })).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 updated = await patchTerminal(req.params.terminalId, payload); 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); 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; return { device, latestHeartbeat, binding, derivedStatus: deriveDeviceStatus({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, battery_level: latestHeartbeat?.battery_level ?? null }) }; })); 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 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), derived_status: deriveDeviceStatus({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, battery_level: latestHeartbeat?.battery_level ?? null }), 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 updated = await patchDevice(req.params.deviceId, payload); 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 }); 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)); } 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.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)); } 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" }); res.status(201).json(successResponse(req, toTransactionPayload(created))); }); router.get("/transactions", requireAdminToken, async (req, res, next) => { const status = req.query.status; const merchantId = req.query.merchant_id; const from = req.query.from; const to = req.query.to; const partnerReference = req.query.partner_reference; const normalizedStatus = typeof status === "string" ? parseTransactionStatusFilter(status) : undefined; 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(); res.json(successResponse(req, (await listTransactions({ status: normalizedStatus, merchant_id: merchantId })) .filter((tx) => isTxInDateRange(tx, from, to)) .filter((tx) => !normalizedPartnerRef || tx.partner_reference === normalizedPartnerRef) .map(toTransactionPayload))); }); 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 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, 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, next) => { 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; res.json(successResponse(req, { transactions_today: transactionsToday, success_rate_today: Number(successRateToday.toFixed(2)), active_devices: activeDevices, pending_notifications: pendingNotifications, devices_stale: devicesStale, devices_offline: devicesOffline })); } catch (error) { return next(error); } }); 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)); }); export default router;