import { Router } from "express"; import { ApiError } from "../shared/errors"; import { requireDeviceToken } from "../shared/middleware/auth"; import { successResponse } from "../shared/middleware/errorMiddleware"; import { getDeviceById, patchDevice } from "../shared/store/deviceStore"; import { createDeviceHeartbeat } from "../shared/store/heartbeatStore"; import { acknowledgeDeviceCommand } from "../shared/store/deviceCommandStore"; import { getActiveBindingByDevice } from "../shared/store/bindingStore"; import { getTerminalById } from "../shared/store/locationStore"; import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempotencyStore"; import { env } from "../config/env"; import { supportsDynamicQrFlow } from "../shared/services/deviceCapabilityResolver"; import { createDynamicQrTransaction } from "../shared/services/dynamicQrOrchestrator"; import { createMqttMessage } from "../shared/store/mqttMessageStore"; import { publishDynamicQrResponse } from "../shared/services/mqttPublisher"; import { createDeviceConfigAck, getOrCreateDeviceConfig, toDeviceConfigAckPayload, toDeviceConfigPayload } from "../shared/store/deviceConfigStore"; const router = Router(); function normalizeNumberOrNull(value) { if (typeof value === "string") { const parsed = Number(value); if (!Number.isNaN(parsed) && Number.isFinite(parsed)) { return parsed; } return null; } if (typeof value === "number" && Number.isFinite(value)) { return value; } return null; } function normalizePositiveAmount(value) { const normalized = normalizeNumberOrNull(value); if (normalized === null || normalized <= 0) { return null; } return normalized; } function normalizeTtl(value) { if (value === undefined || value === null || value === "") { return undefined; } const normalized = normalizeNumberOrNull(value); if (normalized === null || normalized <= 0) { return undefined; } return normalized; } function normalizePositiveInteger(value) { const normalized = normalizeNumberOrNull(value); if (normalized === null || normalized <= 0 || !Number.isInteger(normalized)) { return null; } return normalized; } function normalizeSignalStrength(value) { const normalized = normalizeNumberOrNull(value); if (normalized === null) { return null; } if (normalized < 0 || normalized > 100) { throw new Error("NETWORK_STRENGTH_OUT_OF_RANGE"); } return normalized; } function normalizeBatteryLevel(value) { const normalized = normalizeNumberOrNull(value); if (normalized === null) { return null; } if (normalized < 0 || normalized > 100) { throw new Error("BATTERY_LEVEL_OUT_OF_RANGE"); } return normalized; } router.post("/heartbeat", requireDeviceToken, async (req, res, next) => { const payload = req.body; if (!payload || !payload.device_id) { return next(new ApiError("BAD_REQUEST", "device_id is required", 400)); } const device = await getDeviceById(payload.device_id); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const eventTs = payload.timestamp ? new Date(payload.timestamp) : new Date(); if (Number.isNaN(eventTs.getTime())) { return next(new ApiError("BAD_REQUEST", "timestamp must be valid ISO datetime", 400)); } let payloadNetworkStrength; let payloadBattery; try { payloadNetworkStrength = normalizeSignalStrength(payload.network_strength); payloadBattery = normalizeBatteryLevel(payload.battery_level); } catch (error) { if (error instanceof Error && error.message === "NETWORK_STRENGTH_OUT_OF_RANGE") { return next(new ApiError("BAD_REQUEST", "network_strength must be between 0 and 100", 400)); } if (error instanceof Error && error.message === "BATTERY_LEVEL_OUT_OF_RANGE") { return next(new ApiError("BAD_REQUEST", "battery_level must be between 0 and 100", 400)); } return next(error); } const heartbeat = await createDeviceHeartbeat({ device_id: payload.device_id, timestamp: eventTs.toISOString(), firmware_version: payload.firmware_version, network_strength: payloadNetworkStrength, battery_level: payloadBattery, state: payload.state, payload_json: { network_strength_raw: payload.network_strength, battery_level_raw: payload.battery_level, state: payload.state, firmware_version: payload.firmware_version, timestamp: payload.timestamp, request_id: req.requestId } }); await patchDevice(payload.device_id, { last_seen_at: heartbeat.timestamp, firmware_version: payload.firmware_version || device.firmware_version }); res.json(successResponse(req, { heartbeat_id: heartbeat.id, device_id: heartbeat.device_id, request_id: req.requestId, server_time: heartbeat.received_at })); }); router.post("/commands/ack", requireDeviceToken, async (req, res, next) => { const payload = req.body; if (!payload || !payload.command_id || !payload.device_id || !payload.status) { return next(new ApiError("BAD_REQUEST", "command_id, device_id, status are required", 400)); } if (!["delivered", "failed", "timeout"].includes(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be delivered, failed, or timeout", 400)); } const device = await getDeviceById(payload.device_id); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const updated = await acknowledgeDeviceCommand({ device_id: device.id, command_id: payload.command_id, status: payload.status, reason: payload.reason, result_payload: payload.result_payload }); if (!updated) { return next(new ApiError("NOT_FOUND", "command not found", 404)); } res.json(successResponse(req, { command_id: updated.id, device_id: updated.device_id, status: updated.status, acknowledged_at: updated.acknowledged_at })); }); router.post("/transactions/dynamic-qr", requireDeviceToken, async (req, res, next) => { const payload = req.body; if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) { return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400)); } const amount = normalizePositiveAmount(payload.amount); if (amount === null) { return next(new ApiError("INVALID_AMOUNT", "amount must be a positive number", 400)); } const currency = payload.currency && payload.currency.trim() ? payload.currency.trim().toUpperCase() : "IDR"; if (currency !== "IDR") { return next(new ApiError("BAD_REQUEST", "currency must be IDR for QRIS dynamic MVP", 400)); } const idempotencyKey = req.header("Idempotency-Key") || payload.request_id; const cached = readIdempotency("device.dynamic_qr.create", idempotencyKey); if (cached) { return res.json(successResponse(req, cached.data ?? cached)); } const device = await getDeviceById(payload.device_id); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } if (device.status !== "active") { return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "inactive device cannot create dynamic QR", 400)); } if (!supportsDynamicQrFlow(device, "api_direct")) { return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "device does not support API-direct dynamic QR", 400)); } const terminal = await getTerminalById(payload.terminal_id); if (!terminal) { return next(new ApiError("NOT_FOUND", "terminal not found", 404)); } if (terminal.qr_mode !== "dynamic_api") { return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "terminal is not configured for API dynamic QR", 400)); } const binding = await getActiveBindingByDevice(device.id); if (!binding || binding.terminal_id !== terminal.id) { return next(new ApiError("DEVICE_NOT_BOUND", "device is not actively bound to requested terminal", 400)); } const created = await createDynamicQrTransaction({ request_id: payload.request_id, device_id: device.id, merchant_id: binding.merchant_id, outlet_id: binding.outlet_id, terminal_id: binding.terminal_id, amount, currency, expires_in_seconds: normalizeTtl(payload.expires_in_seconds) }); const responseData = { ...created, request_id: payload.request_id }; writeIdempotency("device.dynamic_qr.create", idempotencyKey, { data: responseData }, env.IDEMPOTENCY_TTL_MS); res.status(201).json(successResponse(req, responseData)); }); router.post("/mqtt/uplink/dynamic-qr/request", requireDeviceToken, async (req, res, next) => { const payload = req.body; if (!payload || !payload.device_id || !payload.terminal_id || !payload.request_id) { return next(new ApiError("BAD_REQUEST", "device_id, terminal_id, request_id are required", 400)); } if (payload.message_type && payload.message_type !== "dynamic_qr_request") { return next(new ApiError("BAD_REQUEST", "message_type must be dynamic_qr_request", 400)); } const amount = normalizePositiveAmount(payload.amount); if (amount === null) { return next(new ApiError("INVALID_AMOUNT", "amount must be a positive number", 400)); } const currency = payload.currency && payload.currency.trim() ? payload.currency.trim().toUpperCase() : "IDR"; if (currency !== "IDR") { return next(new ApiError("BAD_REQUEST", "currency must be IDR for QRIS dynamic MVP", 400)); } const cached = readIdempotency("device.dynamic_qr.mqtt", payload.request_id); if (cached) { return res.json(successResponse(req, cached.data ?? cached)); } const device = await getDeviceById(payload.device_id); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } if (device.status !== "active" || !supportsDynamicQrFlow(device, "mqtt")) { return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "device does not support MQTT dynamic QR", 400)); } const terminal = await getTerminalById(payload.terminal_id); if (!terminal) { return next(new ApiError("NOT_FOUND", "terminal not found", 404)); } if (terminal.qr_mode !== "dynamic_mqtt") { return next(new ApiError("DEVICE_CAPABILITY_NOT_SUPPORTED", "terminal is not configured for MQTT dynamic QR", 400)); } const binding = await getActiveBindingByDevice(device.id); if (!binding || binding.terminal_id !== terminal.id) { return next(new ApiError("DEVICE_NOT_BOUND", "device is not actively bound to requested terminal", 400)); } await createMqttMessage({ direction: "uplink", device_id: device.id, topic: `devices/${device.id}/uplink/dynamic-qr/request`, message_type: "dynamic_qr_request", correlation_id: payload.request_id, payload_json: { ...payload, amount, currency } }); const created = await createDynamicQrTransaction({ request_id: payload.request_id, device_id: device.id, merchant_id: binding.merchant_id, outlet_id: binding.outlet_id, terminal_id: binding.terminal_id, amount, currency, expires_in_seconds: normalizeTtl(payload.expires_in_seconds), initiation_mode: "dynamic_mqtt" }); const mqttPayload = { message_type: "dynamic_qr_response", correlation_id: payload.request_id, transaction_id: created.transaction_id, status: "success", qr_payload: created.qr_payload, expires_at: created.expires_at }; const publishResult = await publishDynamicQrResponse(device.id, mqttPayload); const outbox = await createMqttMessage({ direction: "downlink", device_id: device.id, topic: publishResult.topic, message_type: "dynamic_qr_response", correlation_id: payload.request_id, payload_json: mqttPayload, publish_status: publishResult.ok ? "sent" : "failed", reason: publishResult.reason }); const responseData = { correlation_id: payload.request_id, transaction_id: created.transaction_id, status: "success", qr_payload: created.qr_payload, expires_at: created.expires_at, downlink_message_id: outbox.id, publish_status: outbox.publish_status, partner_reference: created.partner_reference }; writeIdempotency("device.dynamic_qr.mqtt", payload.request_id, { data: responseData }, env.IDEMPOTENCY_TTL_MS); res.status(201).json(successResponse(req, responseData)); }); router.get("/config", requireDeviceToken, async (req, res, next) => { const deviceId = req.query.device_id || req.body?.device_id; if (!deviceId) { return next(new ApiError("BAD_REQUEST", "device_id is required", 400)); } const device = await getDeviceById(deviceId); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const config = await getOrCreateDeviceConfig(device.id); res.json(successResponse(req, toDeviceConfigPayload(config))); }); router.post("/config/ack", requireDeviceToken, async (req, res, next) => { const payload = req.body; if (!payload || !payload.device_id || !payload.status) { return next(new ApiError("BAD_REQUEST", "device_id, status are required", 400)); } if (!["applied", "failed"].includes(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be applied or failed", 400)); } const configVersion = normalizePositiveInteger(payload.config_version); if (configVersion === null) { return next(new ApiError("BAD_REQUEST", "config_version must be a positive integer", 400)); } const device = await getDeviceById(payload.device_id); if (!device) { return next(new ApiError("NOT_FOUND", "device not found", 404)); } const ack = await createDeviceConfigAck({ device_id: device.id, config_version: configVersion, status: payload.status, reason: payload.reason, payload_json: payload.result_payload || {} }); await createMqttMessage({ direction: "uplink", device_id: device.id, topic: `devices/${device.id}/uplink/config/ack`, message_type: "config_ack", correlation_id: `config:${configVersion}`, payload_json: { message_type: "config_ack", device_id: device.id, config_version: configVersion, status: payload.status, reason: payload.reason, result_payload: payload.result_payload || {} } }); res.json(successResponse(req, toDeviceConfigAckPayload(ack))); }); export default router;