diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md index 23431bb..f21e2c1 100644 --- a/CODEX_HANDOFF.md +++ b/CODEX_HANDOFF.md @@ -1,11 +1,22 @@ # CODEx Handoff — QRIS Soundbox Platform ## Current status -- Fokus terakhir: sinkronisasi UI dan smoke test pada stack yang sudah aktif. +- Fokus terakhir: penyelesaian gap Fase 1 backend + smoke e2e skenario wajib. - Implementasi backend dan UI sudah mulai dikerjakan di repository (tidak lagi hanya dokumentasi). +- Tambahan terbaru: + - audit log untuk aksi admin/webhook penting + - ledger placeholder `gross_income` saat transaksi menjadi `paid` + - endpoint admin `GET /admin/audit-logs` dan `GET /admin/ledger-entries` + - awal Fase 2: capability resolver + `POST /device/transactions/dynamic-qr` API-direct + - lanjutan Fase 2: MQTT dynamic QR simulator/outbox + device config push/ack + - smoke e2e mencakup duplicate callback, invalid signature, ledger, audit, terminal tanpa binding, dynamic QR API-direct, dynamic QR MQTT, dan device config + - fix UI lokal: + - CSP Helmet dilonggarkan untuk Tailwind CDN, Google Fonts/Material Symbols, dan image Googleusercontent agar desain render normal + - panel kanan login admin dibuat dark glass supaya teks putih terbaca + - input login admin diubah dari email ke username text agar credential dev `admin/admin` bisa dipakai - Smoke test Fase1 jalannya: - `smoke:cleanup` ✅ - - `smoke:flow` ❌ saat dijalankan langsung (karena server belum jalan di `localhost:3100`) + - `smoke:flow` jalan jika server aktif di `localhost:3100` - `smoke:e2e` ✅ setelah server auto-start di port 3100 (cleanup + full flow berhasil). ## Files baru/terbaru yang sudah dibuat @@ -20,9 +31,27 @@ - [UI: transaction-history-monitoring](/home/wira/work/codex/qris-soundbox-platform/ui/transaction-history-monitoring/index.html) - Search/filter outlet-terminal dan path transaksi sudah memakai endpoint API admin. - [README](/home/wira/work/codex/qris-soundbox-platform/README.md) - - Sudah ada script dan langkah smoke test (`smoke:cleanup`, `smoke:flow`, `smoke:e2e`) siap dipakai. + - Sudah ada script dan langkah smoke test (`smoke:cleanup`, `smoke:flow`, `smoke:e2e`) siap dipakai dan mencakup skenario Fase 1 tambahan. - [DECISIONS_LOG.md](/home/wira/work/codex/qris-soundbox-platform/DECISIONS_LOG.md) - - Sudah memuat keputusan merchant bank account: kini arah keputusan ke rekening milik merchant (bukan escrow/terpusat) agar menghindari kebutuhan izin tambahan di awal. + - Sudah memuat keputusan merchant bank account dan keputusan audit log + ledger placeholder Fase 1. +- [Backend: auditLogStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/auditLogStore.ts) + - Store audit log untuk aksi admin/webhook penting. +- [Backend: ledgerStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/ledgerStore.ts) + - Store ledger placeholder untuk transaksi paid Fase 1. +- [Backend: deviceCapabilityResolver](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/deviceCapabilityResolver.ts) + - Resolver capability untuk flow dynamic QR API/MQTT. +- [Backend: dynamicQrOrchestrator](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/dynamicQrOrchestrator.ts) + - Membuat transaksi dynamic `awaiting_payment` dan mock QR payload. +- [Backend: mqttMessageStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/mqttMessageStore.ts) + - Outbox/trace MQTT uplink dan downlink. +- [Backend: deviceConfigStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/deviceConfigStore.ts) + - Config versioned dan ACK device. +- [App CSP](/home/wira/work/codex/qris-soundbox-platform/src/app.ts) + - Helmet CSP disesuaikan agar asset desain eksternal dapat dimuat di lokal. +- [UI: admin-login](/home/wira/work/codex/qris-soundbox-platform/ui/admin-login/index.html) + - Login admin API-wired, input username dev, dan kontras panel kanan diperbaiki. +- [UI: admin-login-portal](/home/wira/work/codex/qris-soundbox-platform/ui/admin-login-portal/index.html) + - Baseline portal login ikut diselaraskan untuk username dan kontras. ## Keputusan penting yang harus diikuti saat lanjut 1. Fase 1 Step 1–4 harus tetap jalan berurutan sebelum pengembangan Fase 2. @@ -30,21 +59,25 @@ 3. Jalankan smoke dari kondisi bersih (`smoke:cleanup`) untuk hasil yang konsisten. 4. Untuk sementara, pencairan dana mengikuti pola rekening merchant sendiri (sesuai permintaan terakhir), bukan rekening terpusat. 5. Pertahankan format error API yang konsisten: `code`, `message`, `details`, `request_id`, `timestamp`. +6. Ledger Fase 1 masih placeholder `gross_income`; jangan perluas fee/payable sebelum Fase 3 kecuali diminta eksplisit. +7. Dynamic QR Fase 2 saat ini memakai mock QRIS payload lokal; integrasi partner sungguhan belum dipasang. +8. MQTT Fase 2 saat ini memakai simulator HTTP + `mqtt_messages` outbox; broker sungguhan belum dipasang. +9. Untuk cek UI lokal, gunakan `http://127.0.0.1:3100/ui/admin-login`; credential dev adalah username `admin`, password `admin`. ## Urutan kerja selanjutnya (disarankan) -1. Backend/backend sanity lanjut dari titik terakhir: - - Pastikan endpoint untuk sinkronisasi screen sudah stabil (terutama filter/search transaksi dan heartbeat/ events). - - Lengkapi pemeriksaan 1–3 (dalam flow kamu, yaitu smoke point 1–3) yang belum dites manual via UI. -2. Ambil data smoke yang sudah tercipta di e2e (`merchant`, `device`, `transaction`) lalu smoke-test: +1. UI/manual sanity lanjut dari titik terakhir: - Merchant detail page - Merchant list/filter - Device technical detail - Device list + heartbeat view - Transaction history + outlet/terminal filter +2. Jalankan lagi `npm run smoke:e2e` sebelum lanjut Fase 2 atau sebelum commit besar. 3. Jika ada regresi, cek log server di `/tmp/qris-smoke-e2e-server.log`. -4. Setelah UI flow stabil, lanjut fitur ops: - - `A.6 Migration + Seed` (jika ada gap) - - `B.1–B.3` + `C.1–C.3` + `D.1–D.4` untuk full DoD Fase 1. +4. Lanjut Fase 2 berikutnya: + - health score/filter heartbeat yang lebih akurat + - adapter broker MQTT sungguhan dari `mqtt_messages` outbox + - config drift/retry policy untuk device yang belum ACK +5. Sebelum wiring UI baru, pastikan halaman tetap mengikuti desain `design/*/code.html` dan cek kontras teks pada panel transparan/overlay. ## Note kalau meneruskan sesi berikutnya - Kode dan screen yang sudah dimodifikasi tidak perlu diulang dari nol; lanjut dari state saat ini. diff --git a/DECISIONS_LOG.md b/DECISIONS_LOG.md index 7ecc64e..e285273 100644 --- a/DECISIONS_LOG.md +++ b/DECISIONS_LOG.md @@ -192,3 +192,44 @@ Log keputusan arsitektur dan implementasi yang harus dijadikan acuan eksekusi. - Modul settlement platform difokuskan ke rekonsiliasi, status payout, dan visibility, bukan pengelolaan rekening pusat. - Callback payout dan payout execution dianggap partner-oriented (tergantung integrasi penyedia), bukan core di fase awal. - Status: Active + +## D-019 — Fase 1 Audit Log dan Ledger Placeholder +- Tanggal: 2026-05-26 +- Keputusan: + - Fase 1 mencatat audit log untuk aksi admin/webhook penting dan membuat ledger placeholder `gross_income` saat transaksi berubah ke `paid`. +- Alasan: + - Acceptance Fase 1 membutuhkan audit aksi CRUD penting, callback state changes, dan placeholder ledger untuk transaksi sukses tanpa menunggu modul finance penuh. +- Dampak / implikasi: + - `audit_logs` menjadi sumber trace operasional awal untuk entity penting. + - `ledger_entries` Fase 1 hanya placeholder gross income; fee/platform payable detail tetap masuk Fase 3. + - Duplicate paid callback tetap idempotent dan tidak membuat ledger duplikat karena unique key per `transaction_id + entry_type`. +- Status: Active + +## D-020 — Awal Fase 2 Dynamic QR API-Direct +- Tanggal: 2026-05-26 +- Keputusan: + - Fase 2 dimulai dari capability resolver dan endpoint `POST /device/transactions/dynamic-qr` untuk device `communication_mode=api`. + - Dynamic QR API-direct memakai mock QRIS payload lokal sampai integrasi partner QRIS tersedia. +- Alasan: + - Capability/routing harus stabil sebelum MQTT dynamic dan config push dibangun. + - Mock partner memungkinkan transaksi dynamic tersimpan dan callback Fase 1 tetap diuji sebagai source of truth. +- Dampak / implikasi: + - Device static/MQTT yang tidak memiliki capability `dynamic_qr.api_direct` ditolak dengan `DEVICE_CAPABILITY_NOT_SUPPORTED`. + - Device wajib punya binding aktif ke terminal `qr_mode=dynamic_api`; jika tidak, API mengembalikan `DEVICE_NOT_BOUND`. + - Response dynamic QR idempotent memakai `Idempotency-Key` atau `request_id`, dan transaksi dibuat `awaiting_payment`. + - Callback paid tetap memakai endpoint webhook yang sama untuk mengubah transaksi menjadi `paid` dan memicu notifikasi. +- Status: Active + +## D-021 — MQTT Dynamic QR dan Device Config Fase 2 +- Tanggal: 2026-05-26 +- Keputusan: + - MQTT dynamic QR Fase 2 diimplementasikan sebagai HTTP simulator endpoint `POST /device/mqtt/uplink/dynamic-qr/request` yang mencatat uplink/downlink ke `mqtt_messages`. + - Config push memakai `PATCH /admin/devices/{deviceId}/config`, disimpan di `device_configs`, dipublish ke MQTT outbox, lalu device mengirim `POST /device/config/ack`. +- Alasan: + - Belum ada broker MQTT sungguhan di stack lokal, tapi kontrak topic/payload dan idempotency perlu bisa diuji end-to-end. + - Outbox membuat downlink response/config push observable lewat admin sebelum integrasi broker asli. +- Dampak / implikasi: + - Saat broker MQTT dipasang, `mqtt_messages` bisa menjadi outbox/trace awal untuk adapter broker. + - Dynamic MQTT request memakai `request_id` sebagai `correlation_id` dan idempotency key. + - Device config selalu versioned; ACK dicatat terpisah di `device_config_acks`. +- Status: Active diff --git a/README.md b/README.md index 9d5c312..d246530 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,21 @@ Dokumen ini dibuat supaya tim bisa langsung mulai: - `GET /admin/devices/{id}/commands` - `GET /admin/devices/{id}/commands/{commandId}` - `GET /admin/devices/{id}/notifications` + - `GET /admin/devices/{id}/config` + - `PATCH /admin/devices/{id}/config` + - `GET /admin/devices/{id}/mqtt-messages` + - `GET /admin/audit-logs` + - `GET /admin/ledger-entries` - `GET /admin/transactions` - `GET /admin/transactions/{transactionId}` - `POST /admin/transactions` - `GET /admin/transactions/{transactionId}/events` - `POST /admin/transactions/{transactionId}/retry-notification` - `POST /admin/seed` + - `POST /device/transactions/dynamic-qr` + - `POST /device/mqtt/uplink/dynamic-qr/request` + - `GET /device/config` + - `POST /device/config/ack` ### Menjalankan lokal @@ -85,7 +94,7 @@ Cleanup hanya menarget entitas smoke (`Smoke Merchant`, `PR-`, `DEV-`) agar data PORT=3100 ADMIN_TOKEN=admin-dev-token DEVICE_TOKEN=device-dev-token INTEGRATION_WEBHOOK_SECRET=dev-callback-secret PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform npm run smoke:flow ``` -Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification. +Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification, duplicate callback, invalid signature, audit log, ledger placeholder, skenario terminal tanpa binding, dynamic QR API-direct, dynamic QR MQTT, dan device config push/ack. ### Smoke test end-to-end (bootstrap + flow + cleanup) @@ -109,4 +118,4 @@ Perintah ini menjalankan: - `GET /ui` => katalog halaman UI dari seluruh `design/*`. - `GET /ui/:page` => buka halaman berdasarkan slug (contoh: `/ui/admin-login`, `/ui/admin-dashboard-overview`, `/ui/merchant-login`). -Langkah berikutnya sesuai handoff: lanjut ke Task Pack B.1 (transaction core), lalu C.1–C.3. +Status lanjutan: Fase 1 core flow sudah tercakup smoke e2e. Fase 2 sudah aktif untuk capability resolver, dynamic QR API-direct, dynamic QR MQTT via outbox, dan device config push/ack. diff --git a/dist/app.js b/dist/app.js index 40a4fd7..30d7ff8 100644 --- a/dist/app.js +++ b/dist/app.js @@ -11,7 +11,24 @@ import path from "node:path"; import fs from "node:fs"; const app = express(); startNotificationOrchestrator(); -app.use(helmet()); +app.use(helmet({ + crossOriginResourcePolicy: { + policy: "cross-origin" + }, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.tailwindcss.com"], + scriptSrcAttr: ["'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], + fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], + imgSrc: ["'self'", "data:", "https://lh3.googleusercontent.com", "https://*.googleusercontent.com"], + connectSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"] + } + } +})); app.use(express.json()); app.use(morgan("dev")); app.use(requestContext); @@ -35,6 +52,7 @@ app.get("/ui/hub", (_req, res) => { const filePath = path.resolve(process.cwd(), "ui/hub.html"); res.sendFile(filePath); }); +app.use("/ui/shared", express.static(path.resolve(process.cwd(), "ui", "shared"))); app.get("/ui/:page", (req, res, next) => { const filePath = resolveUiPageFile(req.params.page); if (!filePath) { diff --git a/dist/routes/admin.js b/dist/routes/admin.js index e31bd16..e4125c2 100644 --- a/dist/routes/admin.js +++ b/dist/routes/admin.js @@ -14,6 +14,12 @@ import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDevice 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"; const router = Router(); function parseIdempotentReplay(req) { return req.body.__idempotentReplay; @@ -64,6 +70,12 @@ function parseDeviceStatusValue(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; @@ -114,6 +126,7 @@ async function buildDeviceAdminPayload(device) { const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); return { ...toDevicePayload(device), + capability_summary: resolveDeviceCapabilitySummary(device), derived_status: deriveDeviceStatus({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, @@ -170,6 +183,20 @@ function toStartEndDateFilter(from, to) { 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 + }); +} function validatePayoutConfig(payload) { const mode = normalizeMerchantMode(payload.payout_mode); if (mode === "merchant_direct") { @@ -223,6 +250,10 @@ router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.crea 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); } @@ -239,6 +270,12 @@ router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.crea 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) => { @@ -264,6 +301,9 @@ router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next) ...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; @@ -277,6 +317,13 @@ router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next) 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) => { @@ -290,6 +337,13 @@ router.post("/merchants/:merchantId/approve", requireAdminToken, async (req, res 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) => { @@ -305,6 +359,16 @@ router.post("/merchants/:merchantId/reject", requireAdminToken, async (req, res, 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 @@ -362,7 +426,11 @@ router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", requir device_code: "DEV_SEED_A", vendor: "seed-maker", model: "v1", - communication_mode: "mqtt", + communication_mode: "static", + capability_profile_json: { + dynamic_qr: false, + flows: ["static_payment_notification"] + }, status: "active" }); const deviceB = await createDevice({ @@ -370,14 +438,28 @@ router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", requir 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: "mqtt", - status: "inactive" + 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, @@ -484,12 +566,26 @@ router.post("/merchants/:merchantId/outlets", requireAdminToken, idempotency({ s 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) => { - const merchantId = req.query.merchant_id; +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 + merchant_id: merchantId, + status, + q: q || undefined })).map(toOutletPayload))); }); router.get("/outlets/:outletId", requireAdminToken, async (req, res, next) => { @@ -508,7 +604,15 @@ router.patch("/outlets/:outletId", requireAdminToken, async (req, res, next) => 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) { @@ -543,12 +647,26 @@ router.post("/outlets/:outletId/terminals", requireAdminToken, idempotency({ sco 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) => { - const outletId = req.query.outlet_id; +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 + outlet_id: outletId, + status, + q: q || undefined })).map(toTerminalPayload))); }); router.get("/terminals/:terminalId", requireAdminToken, async (req, res, next) => { @@ -570,7 +688,15 @@ router.patch("/terminals/:terminalId", requireAdminToken, async (req, res, next) 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) { @@ -595,6 +721,12 @@ router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", 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) => { @@ -663,6 +795,7 @@ router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => { .map(toNotificationPayload); res.json(successResponse(req, { ...toDevicePayload(device), + capability_summary: resolveDeviceCapabilitySummary(device), derived_status: deriveDeviceStatus({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, @@ -720,7 +853,15 @@ router.patch("/devices/:deviceId", requireAdminToken, async (req, res, next) => 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) { @@ -760,6 +901,12 @@ router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: " 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) => { @@ -771,6 +918,13 @@ router.post("/devices/:deviceId/unbind", requireAdminToken, async (req, res, nex 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) => { @@ -833,6 +987,85 @@ router.get("/devices/:deviceId/notifications", requireAdminToken, async (req, re .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); + res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks })); +}); +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 mqttPayload = { + message_type: "config_push", + config_version: config.config_version, + settings: config.settings_json + }; + const publishResult = await publishConfigPush(device.id, mqttPayload); + const outbox = await createMqttMessage({ + direction: "downlink", + device_id: device.id, + 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 + }); + 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.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)); @@ -885,15 +1118,25 @@ router.post("/transactions", requireAdminToken, idempotency({ scope: "transactio initiation_mode: payload.initiation_mode || "static", status: payload.status || "initiated" }); + 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 status = req.query.status; - const merchantId = req.query.merchant_id; + 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 normalizedStatus = typeof status === "string" ? parseTransactionStatusFilter(status) : undefined; + const q = req.query.q?.trim(); if (from && !isIsoDate(from)) { return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); } @@ -901,12 +1144,21 @@ router.get("/transactions", requireAdminToken, async (req, res, next) => { 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: normalizedStatus, + 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.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => { @@ -915,6 +1167,7 @@ router.get("/transactions/:transactionId", requireAdminToken, async (req, res, n 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 @@ -932,6 +1185,7 @@ router.get("/transactions/:transactionId", requireAdminToken, async (req, res, n res.json(successResponse(req, { transaction: toTransactionPayload(tx), events, + ledger_entries, heartbeat_device_id: heartbeatDeviceId, heartbeat_history: heartbeatHistory })); @@ -1037,7 +1291,15 @@ router.post("/transactions/:transactionId/retry-notification", requireAdminToken return next(error); } }); -router.get("/dashboard/summary", requireAdminToken, async (req, res, next) => { +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(); @@ -1056,18 +1318,19 @@ router.get("/dashboard/summary", requireAdminToken, async (req, res, next) => { const pendingNotifications = (await listNotifications()).filter((notification) => { return notification.delivery_status === "queued" || notification.delivery_status === "retrying"; }).length; - res.json(successResponse(req, { + 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) { - return next(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; @@ -1104,4 +1367,46 @@ router.get("/notifications/failed", requireAdminToken, async (req, res, next) => })); 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; diff --git a/dist/routes/device.js b/dist/routes/device.js index dad8330..6ae7b12 100644 --- a/dist/routes/device.js +++ b/dist/routes/device.js @@ -5,6 +5,15 @@ 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") { @@ -19,6 +28,30 @@ function normalizeNumberOrNull(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) { @@ -123,4 +156,190 @@ router.post("/commands/ack", requireDeviceToken, async (req, res, next) => { 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 || {} + }); + res.json(successResponse(req, toDeviceConfigAckPayload(ack))); +}); export default router; diff --git a/dist/routes/integrations.js b/dist/routes/integrations.js index 858afde..18c0d90 100644 --- a/dist/routes/integrations.js +++ b/dist/routes/integrations.js @@ -6,6 +6,7 @@ import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempot import { addTransactionEvent, findTransactionByPartnerReference, getTransactionEvents, updateTransactionStatus } from "../shared/store/transactionStore"; import { emitTransactionPaid } from "../shared/events/transactionEvents"; import { env } from "../config/env"; +import { createAuditLog } from "../shared/store/auditLogStore"; const router = Router(); function parsePaymentStatus(rawStatus) { const normalized = String(rawStatus || "").toLowerCase(); @@ -79,6 +80,20 @@ function buildCallbackResponse(req, transactionId, eventId, note, reason) { function writeCallbackResult(idempotencyKey, response, transactionId) { writeIdempotency("callback.processing", idempotencyKey, { response, transaction_id: transactionId }, env.IDEMPOTENCY_TTL_MS); } +async function auditWebhookAction(req, payload) { + await createAuditLog({ + actor_type: "webhook", + actor_id: "qris_partner", + action: payload.action, + entity_type: "transaction", + 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 makeResponseEventId(txId, fallbackTag) { const events = await getTransactionEvents(txId); return events.at(-1)?.id || `${fallbackTag}_${Date.now()}`; @@ -172,6 +187,12 @@ router.post("/qris/callback", async (req, res, next) => { throw error; } if (!wasPaid) { + await auditWebhookAction(req, { + action: "transaction.mark_paid", + entity_id: updated.id, + before_json: tx, + after_json: updated + }); emitTransactionPaid({ transaction_id: updated.id, merchant_id: updated.merchant_id, @@ -216,6 +237,12 @@ router.post("/qris/callback", async (req, res, next) => { } throw error; } + await auditWebhookAction(req, { + action: "transaction.mark_expired", + entity_id: updated.id, + before_json: tx, + after_json: updated + }); const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event")); writeCallbackResult(idempotencyKey, response, updated.id); return res.json(successResponse(req, response)); @@ -235,6 +262,12 @@ router.post("/qris/callback", async (req, res, next) => { } throw error; } + await auditWebhookAction(req, { + action: "transaction.mark_failed", + entity_id: updated.id, + before_json: tx, + after_json: updated + }); const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event"), undefined, parsed.status.reason); writeCallbackResult(idempotencyKey, response, updated.id); return res.json(successResponse(req, response)); diff --git a/dist/shared/db/pool.js b/dist/shared/db/pool.js index 34fa81c..09db81c 100644 --- a/dist/shared/db/pool.js +++ b/dist/shared/db/pool.js @@ -134,6 +134,41 @@ CREATE TABLE IF NOT EXISTS device_commands ( CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC); +CREATE TABLE IF NOT EXISTS mqtt_messages ( + id TEXT PRIMARY KEY, + direction TEXT NOT NULL CHECK (direction IN ('uplink', 'downlink')), + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + topic TEXT NOT NULL, + message_type TEXT NOT NULL, + correlation_id TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + publish_status TEXT NOT NULL DEFAULT 'recorded' CHECK (publish_status IN ('recorded', 'sent', 'failed')), + reason TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_mqtt_messages_device_created ON mqtt_messages (device_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_mqtt_messages_correlation ON mqtt_messages (correlation_id); + +CREATE TABLE IF NOT EXISTS device_configs ( + device_id TEXT PRIMARY KEY REFERENCES devices (id) ON DELETE CASCADE, + config_version INT NOT NULL, + settings_json JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS device_config_acks ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + config_version INT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('applied', 'failed')), + reason TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + acked_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_device_config_acks_device ON device_config_acks (device_id, acked_at DESC); + CREATE TABLE IF NOT EXISTS transactions ( id TEXT PRIMARY KEY, transaction_code TEXT NOT NULL UNIQUE, @@ -191,5 +226,65 @@ CREATE TABLE IF NOT EXISTS notifications ( CREATE INDEX IF NOT EXISTS idx_notifications_device_status ON notifications (device_id, delivery_status); CREATE INDEX IF NOT EXISTS idx_notifications_status_created ON notifications (delivery_status, created_at DESC); +CREATE TABLE IF NOT EXISTS ledger_entries ( + id TEXT PRIMARY KEY, + transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + entry_type TEXT NOT NULL CHECK (entry_type IN ('gross_income', 'platform_fee', 'merchant_payable')), + amount NUMERIC(20,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'IDR', + direction TEXT NOT NULL CHECK (direction IN ('credit', 'debit')), + status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'voided')), + metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT ledger_entries_unique_tx_type UNIQUE (transaction_id, entry_type) +); + +CREATE INDEX IF NOT EXISTS idx_ledger_entries_merchant_created ON ledger_entries (merchant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ledger_entries_tx ON ledger_entries (transaction_id); + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + permissions_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role_id TEXT NOT NULL REFERENCES roles (id), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL, + actor_id TEXT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + before_json JSONB, + after_json JSONB, + source_ip TEXT, + request_id TEXT, + trace_id TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs (entity_type, entity_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs (action, created_at DESC); + +INSERT INTO roles (id, name, permissions_json, created_at) +VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO users (id, name, email, password_hash, role_id, status, created_at) +VALUES ('user_admin_seed', 'Admin Seed', 'admin@example.local', 'dev-only-admin-password', 'role_admin', 'active', NOW()) +ON CONFLICT (id) DO NOTHING; + COMMIT; `; diff --git a/dist/shared/middleware/idempotency.js b/dist/shared/middleware/idempotency.js index cdb7b8f..ecb0a64 100644 --- a/dist/shared/middleware/idempotency.js +++ b/dist/shared/middleware/idempotency.js @@ -1,6 +1,7 @@ import { ApiError } from "../errors"; import { readIdempotency, writeIdempotency } from "../idempotency/idempotencyStore"; import { env } from "../../config/env"; +import { successResponse } from "./errorMiddleware"; export function idempotency(options) { return function idempotencyMiddleware(req, _res, next) { const idempotencyKey = req.header("idempotency-key"); @@ -14,7 +15,20 @@ export function idempotency(options) { if (cached) { const cachedPayload = cached.response ?? cached; const cachedStatus = cached.statusCode || 200; - return _res.status(cachedStatus).json(cachedPayload); + const payload = (() => { + if (cachedPayload && + typeof cachedPayload === "object" && + "data" in cachedPayload && + "request_id" in cachedPayload && + "timestamp" in cachedPayload) { + const typed = cachedPayload; + typed.request_id = req.requestId; + typed.timestamp = new Date().toISOString(); + return typed; + } + return cachedPayload; + })(); + return _res.status(cachedStatus).json(payload); } req.body = { ...(req.body || {}), __idempotencyKey: idempotencyKey }; const originalJson = _res.json.bind(_res); @@ -25,12 +39,19 @@ export function idempotency(options) { return originalStatus(code); }; _res.json = function jsonWithStore(payload) { + const responsePayload = payload && + typeof payload === "object" && + "data" in payload && + "request_id" in payload && + "timestamp" in payload + ? successResponse(req, payload.data) + : payload; writeIdempotency(options.scope, idempotencyKey, { - response: payload, + response: responsePayload, statusCode, at: Date.now() }, options.ttlMs || env.IDEMPOTENCY_TTL_MS); - return originalJson(payload); + return originalJson(responsePayload); }; next(); }; diff --git a/dist/shared/services/deviceCapabilityResolver.js b/dist/shared/services/deviceCapabilityResolver.js new file mode 100644 index 0000000..3df9f57 --- /dev/null +++ b/dist/shared/services/deviceCapabilityResolver.js @@ -0,0 +1,26 @@ +function getProfile(device) { + return (device.capability_profile_json || {}); +} +export function supportsDynamicQrFlow(device, flow) { + const profile = getProfile(device); + const flows = Array.isArray(profile.flows) ? profile.flows : []; + if (flow === "api_direct" && device.communication_mode !== "api") { + return false; + } + if (flow === "mqtt" && device.communication_mode !== "mqtt") { + return false; + } + if (typeof profile.dynamic_qr === "boolean") { + return profile.dynamic_qr || flows.includes(`dynamic_qr:${flow}`); + } + if (profile.dynamic_qr && typeof profile.dynamic_qr === "object") { + return Boolean(profile.dynamic_qr[flow]) || flows.includes(`dynamic_qr:${flow}`); + } + return flows.includes(`dynamic_qr:${flow}`); +} +export function resolveDeviceCapabilitySummary(device) { + return { + dynamic_qr_api_direct: supportsDynamicQrFlow(device, "api_direct"), + dynamic_qr_mqtt: supportsDynamicQrFlow(device, "mqtt") + }; +} diff --git a/dist/shared/services/dynamicQrOrchestrator.js b/dist/shared/services/dynamicQrOrchestrator.js new file mode 100644 index 0000000..0011dba --- /dev/null +++ b/dist/shared/services/dynamicQrOrchestrator.js @@ -0,0 +1,67 @@ +import { randomUUID } from "node:crypto"; +import { createTransaction, addTransactionEvent, toTransactionPayload } from "../store/transactionStore"; +function makePartnerReference(requestId) { + const clean = requestId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40); + return `DYN-${clean || randomUUID().slice(0, 12)}`; +} +function makeDynamicQrPayload(input) { + const amountMinor = Math.round(input.amount * 100); + const encoded = Buffer.from(JSON.stringify({ + type: "QRIS_DYNAMIC_MOCK", + transaction_id: input.transactionId, + partner_reference: input.partnerReference, + amount_minor: amountMinor, + currency: input.currency, + expires_at: input.expiresAt + })).toString("base64url"); + return `QRIS-DYNAMIC-MOCK.${encoded}`; +} +export async function createDynamicQrTransaction(input) { + const ttlSeconds = Math.min(Math.max(input.expires_in_seconds || 300, 60), 1800); + const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString(); + const partnerReference = makePartnerReference(input.request_id); + const tx = await createTransaction({ + merchant_id: input.merchant_id, + outlet_id: input.outlet_id, + terminal_id: input.terminal_id, + device_id: input.device_id, + partner_reference: partnerReference, + amount: input.amount, + currency: input.currency || "IDR", + qr_mode: "dynamic", + initiation_mode: input.initiation_mode || "dynamic_api", + status: "awaiting_payment", + expired_at: expiresAt + }); + const qrPayload = makeDynamicQrPayload({ + transactionId: tx.id, + partnerReference, + amount: tx.amount, + currency: tx.currency, + expiresAt + }); + await addTransactionEvent({ + transaction_id: tx.id, + event_type: "DYNAMIC_QR_CREATED", + source: "device", + payload_json: { + request_id: input.request_id, + correlation_id: input.request_id, + device_id: input.device_id, + qr_payload: qrPayload, + expires_at: expiresAt, + transaction: toTransactionPayload(tx) + } + }); + return { + request_id: input.request_id, + correlation_id: input.request_id, + transaction_id: tx.id, + transaction_code: tx.transaction_code, + qr_type: "dynamic", + qr_payload: qrPayload, + expires_at: expiresAt, + status: "awaiting_payment", + partner_reference: partnerReference + }; +} diff --git a/dist/shared/services/mqttPublisher.js b/dist/shared/services/mqttPublisher.js index 77fedce..4d5833c 100644 --- a/dist/shared/services/mqttPublisher.js +++ b/dist/shared/services/mqttPublisher.js @@ -27,10 +27,15 @@ export function buildPaymentSuccessPayload(input) { export function makePaymentSuccessTopic(deviceId) { return `devices/${deviceId}/downlink/payment/success`; } -export async function publishPaymentSuccess(payload) { +export function makeDynamicQrResponseTopic(deviceId) { + return `devices/${deviceId}/downlink/dynamic-qr/response`; +} +export function makeConfigPushTopic(deviceId) { + return `devices/${deviceId}/downlink/config/push`; +} +async function publishMqttPayload(deviceId, topic, payload) { const publishedAt = new Date().toISOString(); - const topic = makePaymentSuccessTopic(payload.device_id); - if (shouldForceFail(payload.device_id)) { + if (shouldForceFail(deviceId)) { return { ok: false, topic, @@ -50,3 +55,12 @@ export async function publishPaymentSuccess(payload) { payload }; } +export async function publishPaymentSuccess(payload) { + return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload); +} +export async function publishDynamicQrResponse(deviceId, payload) { + return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload); +} +export async function publishConfigPush(deviceId, payload) { + return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload); +} diff --git a/dist/shared/store/auditLogStore.js b/dist/shared/store/auditLogStore.js new file mode 100644 index 0000000..db36fd9 --- /dev/null +++ b/dist/shared/store/auditLogStore.js @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function mapAuditLog(row) { + return { + id: row.id, + actor_type: row.actor_type, + actor_id: row.actor_id || undefined, + action: row.action, + entity_type: row.entity_type, + entity_id: row.entity_id, + before_json: row.before_json || null, + after_json: row.after_json || null, + source_ip: row.source_ip || undefined, + request_id: row.request_id || undefined, + trace_id: row.trace_id || undefined, + created_at: row.created_at + }; +} +export async function createAuditLog(payload) { + const { rows } = await getPool().query(`INSERT INTO audit_logs ( + id, + actor_type, + actor_id, + action, + entity_type, + entity_id, + before_json, + after_json, + source_ip, + request_id, + trace_id, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, [ + randomUUID(), + payload.actor_type, + payload.actor_id || null, + payload.action, + payload.entity_type, + payload.entity_id, + payload.before_json || null, + payload.after_json || null, + payload.source_ip || null, + payload.request_id || null, + payload.trace_id || null, + nowIso() + ]); + return mapAuditLog(rows[0]); +} +export async function listAuditLogs(filter) { + const clauses = []; + const params = []; + let i = 1; + if (filter?.entity_type) { + clauses.push(`entity_type = $${i++}`); + params.push(filter.entity_type); + } + if (filter?.entity_id) { + clauses.push(`entity_id = $${i++}`); + params.push(filter.entity_id); + } + if (filter?.action) { + clauses.push(`action = $${i++}`); + params.push(filter.action); + } + if (filter?.from) { + clauses.push(`created_at >= $${i++}`); + params.push(filter.from); + } + if (filter?.to) { + clauses.push(`created_at <= $${i++}`); + params.push(filter.to); + } + const limit = Math.min(Math.max(filter?.limit || 100, 1), 500); + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ${limit}`, params); + return rows.map(mapAuditLog); +} +export function toAuditLogPayload(auditLog) { + return { ...auditLog }; +} diff --git a/dist/shared/store/deviceConfigStore.js b/dist/shared/store/deviceConfigStore.js new file mode 100644 index 0000000..161363b --- /dev/null +++ b/dist/shared/store/deviceConfigStore.js @@ -0,0 +1,89 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +const DEFAULT_SETTINGS = { + volume: 80, + language: "id-ID", + heartbeat_interval_seconds: 60 +}; +function nowIso() { + return new Date().toISOString(); +} +function mapConfig(row) { + return { + device_id: row.device_id, + config_version: Number(row.config_version), + settings_json: row.settings_json || {}, + updated_at: row.updated_at + }; +} +function mapAck(row) { + return { + id: row.id, + device_id: row.device_id, + config_version: Number(row.config_version), + status: row.status, + reason: row.reason || undefined, + payload_json: row.payload_json || {}, + acked_at: row.acked_at + }; +} +export async function getDeviceConfig(deviceId) { + const { rows } = await getPool().query("SELECT * FROM device_configs WHERE device_id = $1", [deviceId]); + return rows[0] ? mapConfig(rows[0]) : null; +} +export async function getOrCreateDeviceConfig(deviceId) { + const existing = await getDeviceConfig(deviceId); + if (existing) { + return existing; + } + return upsertDeviceConfig({ + device_id: deviceId, + settings_json: DEFAULT_SETTINGS + }); +} +export async function upsertDeviceConfig(payload) { + const existing = await getDeviceConfig(payload.device_id); + const nextVersion = payload.config_version || (existing ? existing.config_version + 1 : 1); + const { rows } = await getPool().query(`INSERT INTO device_configs (device_id, config_version, settings_json, updated_at) + VALUES ($1,$2,$3,$4) + ON CONFLICT (device_id) DO UPDATE + SET config_version = EXCLUDED.config_version, + settings_json = EXCLUDED.settings_json, + updated_at = EXCLUDED.updated_at + RETURNING *`, [payload.device_id, nextVersion, payload.settings_json, nowIso()]); + return mapConfig(rows[0]); +} +export async function createDeviceConfigAck(payload) { + const { rows } = await getPool().query(`INSERT INTO device_config_acks ( + id, + device_id, + config_version, + status, + reason, + payload_json, + acked_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7) + RETURNING *`, [ + `cfgack_${randomUUID()}`, + payload.device_id, + payload.config_version, + payload.status, + payload.reason || null, + payload.payload_json || {}, + nowIso() + ]); + return mapAck(rows[0]); +} +export async function listDeviceConfigAcks(deviceId, limit = 50) { + const { rows } = await getPool().query(`SELECT * FROM device_config_acks + WHERE device_id = $1 + ORDER BY acked_at DESC + LIMIT $2`, [deviceId, Math.min(Math.max(limit, 1), 200)]); + return rows.map(mapAck); +} +export function toDeviceConfigPayload(config) { + return { ...config }; +} +export function toDeviceConfigAckPayload(ack) { + return { ...ack }; +} diff --git a/dist/shared/store/ledgerStore.js b/dist/shared/store/ledgerStore.js new file mode 100644 index 0000000..7167677 --- /dev/null +++ b/dist/shared/store/ledgerStore.js @@ -0,0 +1,74 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function mapLedgerEntry(row) { + return { + id: row.id, + transaction_id: row.transaction_id, + merchant_id: row.merchant_id, + entry_type: row.entry_type, + amount: Number(row.amount), + currency: row.currency, + direction: row.direction, + status: row.status, + metadata_json: row.metadata_json || {}, + created_at: row.created_at + }; +} +export async function createPaidLedgerPlaceholder(tx) { + const { rows } = await getPool().query(`INSERT INTO ledger_entries ( + id, + transaction_id, + merchant_id, + entry_type, + amount, + currency, + direction, + status, + metadata_json, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (transaction_id, entry_type) DO UPDATE + SET amount = EXCLUDED.amount, + currency = EXCLUDED.currency, + metadata_json = ledger_entries.metadata_json || EXCLUDED.metadata_json + RETURNING *`, [ + randomUUID(), + tx.id, + tx.merchant_id, + "gross_income", + tx.amount, + tx.currency, + "credit", + "posted", + { + placeholder: true, + source: "fase1_paid_transaction", + partner_reference: tx.partner_reference + }, + nowIso() + ]); + return mapLedgerEntry(rows[0]); +} +export async function listLedgerEntries(filter) { + const clauses = []; + const params = []; + let i = 1; + if (filter?.transaction_id) { + clauses.push(`transaction_id = $${i++}`); + params.push(filter.transaction_id); + } + if (filter?.merchant_id) { + clauses.push(`merchant_id = $${i++}`); + params.push(filter.merchant_id); + } + const limit = Math.min(Math.max(filter?.limit || 100, 1), 500); + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query(`SELECT * FROM ledger_entries ${where} ORDER BY created_at DESC LIMIT ${limit}`, params); + return rows.map(mapLedgerEntry); +} +export function toLedgerEntryPayload(entry) { + return { ...entry }; +} diff --git a/dist/shared/store/locationStore.js b/dist/shared/store/locationStore.js index 3c41ba3..8afb058 100644 --- a/dist/shared/store/locationStore.js +++ b/dist/shared/store/locationStore.js @@ -65,19 +65,45 @@ export async function createTerminal(payload) { return mapTerminal(rows[0]); } export async function listOutlets(filter) { + const clauses = []; + const params = []; + let i = 1; if (filter?.merchant_id) { - const { rows } = await getPool().query("SELECT * FROM outlets WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id]); - return rows.map(mapOutlet); + clauses.push(`merchant_id = $${i++}`); + params.push(filter.merchant_id); } - const { rows } = await getPool().query("SELECT * FROM outlets ORDER BY created_at DESC"); + if (filter?.status) { + clauses.push(`status = $${i++}`); + params.push(filter.status); + } + if (filter?.q) { + const value = `%${filter.q.toLowerCase()}%`; + clauses.push(`(LOWER(name) LIKE $${i++} OR LOWER(outlet_code) LIKE $${i++})`); + params.push(value, value); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query(`SELECT * FROM outlets ${where} ORDER BY created_at DESC`, params); return rows.map(mapOutlet); } export async function listTerminals(filter) { + const clauses = []; + const params = []; + let i = 1; if (filter?.outlet_id) { - const { rows } = await getPool().query("SELECT * FROM terminals WHERE outlet_id = $1 ORDER BY created_at DESC", [filter.outlet_id]); - return rows.map(mapTerminal); + clauses.push(`outlet_id = $${i++}`); + params.push(filter.outlet_id); } - const { rows } = await getPool().query("SELECT * FROM terminals ORDER BY created_at DESC"); + if (filter?.status) { + clauses.push(`status = $${i++}`); + params.push(filter.status); + } + if (filter?.q) { + const value = `%${filter.q.toLowerCase()}%`; + clauses.push(`(LOWER(terminal_code) LIKE $${i++} OR LOWER(COALESCE(partner_reference, '')) LIKE $${i++})`); + params.push(value, value); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query(`SELECT * FROM terminals ${where} ORDER BY created_at DESC`, params); return rows.map(mapTerminal); } export async function getOutletById(id) { diff --git a/dist/shared/store/mqttMessageStore.js b/dist/shared/store/mqttMessageStore.js new file mode 100644 index 0000000..c9cd3c1 --- /dev/null +++ b/dist/shared/store/mqttMessageStore.js @@ -0,0 +1,74 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function mapMessage(row) { + return { + id: row.id, + direction: row.direction, + device_id: row.device_id, + topic: row.topic, + message_type: row.message_type, + correlation_id: row.correlation_id || undefined, + payload_json: row.payload_json || {}, + publish_status: row.publish_status, + reason: row.reason || undefined, + created_at: row.created_at + }; +} +export async function createMqttMessage(payload) { + const { rows } = await getPool().query(`INSERT INTO mqtt_messages ( + id, + direction, + device_id, + topic, + message_type, + correlation_id, + payload_json, + publish_status, + reason, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, [ + `mqtt_${randomUUID()}`, + payload.direction, + payload.device_id, + payload.topic, + payload.message_type, + payload.correlation_id || null, + payload.payload_json || {}, + payload.publish_status || "recorded", + payload.reason || null, + nowIso() + ]); + return mapMessage(rows[0]); +} +export async function listMqttMessages(filter) { + const clauses = []; + const params = []; + let i = 1; + if (filter?.device_id) { + clauses.push(`device_id = $${i++}`); + params.push(filter.device_id); + } + if (filter?.direction) { + clauses.push(`direction = $${i++}`); + params.push(filter.direction); + } + if (filter?.message_type) { + clauses.push(`message_type = $${i++}`); + params.push(filter.message_type); + } + if (filter?.correlation_id) { + clauses.push(`correlation_id = $${i++}`); + params.push(filter.correlation_id); + } + const limit = Math.min(Math.max(filter?.limit || 100, 1), 500); + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query(`SELECT * FROM mqtt_messages ${where} ORDER BY created_at DESC LIMIT ${limit}`, params); + return rows.map(mapMessage); +} +export function toMqttMessagePayload(message) { + return { ...message }; +} diff --git a/dist/shared/store/transactionStore.js b/dist/shared/store/transactionStore.js index 45735ec..42eeaa7 100644 --- a/dist/shared/store/transactionStore.js +++ b/dist/shared/store/transactionStore.js @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { getPool } from "../db/pool"; +import { createPaidLedgerPlaceholder } from "./ledgerStore"; function nowIso() { return new Date().toISOString(); } @@ -161,7 +162,11 @@ export async function updateTransactionStatus(id, to, options) { ...options.eventContext } }); - return mapTransaction(rows[0]); + const updated = mapTransaction(rows[0]); + if (to === "paid") { + await createPaidLedgerPlaceholder(updated); + } + return updated; } export async function getTransactionById(id) { const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]); diff --git a/scripts/smoke-cleanup.mjs b/scripts/smoke-cleanup.mjs index 25215fa..efd4455 100644 --- a/scripts/smoke-cleanup.mjs +++ b/scripts/smoke-cleanup.mjs @@ -20,7 +20,35 @@ async function main() { try { await client.query("BEGIN"); - const txResult = await client.query("DELETE FROM transactions WHERE partner_reference LIKE 'PR-%' RETURNING id"); + const auditTable = await client.query("SELECT to_regclass('public.audit_logs') AS name"); + const idsResult = await client.query(` + SELECT id FROM transactions WHERE partner_reference LIKE 'PR-%' OR partner_reference LIKE 'DYN-%' + UNION + SELECT id FROM devices WHERE device_code LIKE 'DEV-%' + UNION + SELECT id FROM merchants WHERE legal_name LIKE 'Smoke Merchant %' + UNION + SELECT outlets.id + FROM outlets + JOIN merchants ON merchants.id = outlets.merchant_id + WHERE merchants.legal_name LIKE 'Smoke Merchant %' + UNION + SELECT terminals.id + FROM terminals + JOIN outlets ON outlets.id = terminals.outlet_id + JOIN merchants ON merchants.id = outlets.merchant_id + WHERE merchants.legal_name LIKE 'Smoke Merchant %' + UNION + SELECT device_bindings.id + FROM device_bindings + JOIN devices ON devices.id = device_bindings.device_id + WHERE devices.device_code LIKE 'DEV-%' + `); + const auditIds = idsResult.rows.map((row) => row.id); + const auditResult = auditTable.rows[0]?.name && auditIds.length + ? await client.query("DELETE FROM audit_logs WHERE entity_id = ANY($1::text[])", [auditIds]) + : { rowCount: 0 }; + const txResult = await client.query("DELETE FROM transactions WHERE partner_reference LIKE 'PR-%' OR partner_reference LIKE 'DYN-%' RETURNING id"); const devResult = await client.query("DELETE FROM devices WHERE device_code LIKE 'DEV-%' RETURNING id"); const merchantResult = await client.query("DELETE FROM merchants WHERE legal_name LIKE 'Smoke Merchant %' RETURNING id"); @@ -30,6 +58,7 @@ async function main() { transactions_deleted: txResult.rowCount, devices_deleted: devResult.rowCount, merchants_deleted: merchantResult.rowCount, + audit_logs_deleted: auditResult.rowCount, note: "outlets/terminals are removed via merchant cascade" })); } catch (error) { diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index cb02a20..9e4cc67 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -37,6 +37,32 @@ async function req(path, options = {}) { return body; } +async function reqExpect(path, expectedStatus, options = {}) { + const response = await fetch(`${BASE}${path}`, { + method: options.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}) + }, + body: Object.prototype.hasOwnProperty.call(options, 'body') ? JSON.stringify(options.body) : undefined + }); + + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + + if (response.status !== expectedStatus) { + throw new Error(`${options._label || path} expected ${expectedStatus}, got ${response.status}`); + } + + console.log(`${options._label || `${options.method || 'GET'} ${path}`} => ${response.status} ${short(body)}`); + return body; +} + async function reqAdmin(path, opts = {}) { return req(path, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${ADMIN_TOKEN}` } }); } @@ -151,8 +177,24 @@ async function reqDevice(path, opts = {}) { _label: 'POST /integrations/qris/callback' }); + await req('/integrations/qris/callback', { + method: 'POST', + headers: { 'X-Partner-Signature': signature }, + body: { ...callback, signature }, + _label: 'POST /integrations/qris/callback duplicate' + }); + + await reqExpect('/integrations/qris/callback', 401, { + method: 'POST', + headers: { 'X-Partner-Signature': 'bad-signature' }, + body: { ...callback, signature: 'bad-signature' }, + _label: 'POST /integrations/qris/callback invalid signature' + }); + await reqAdmin(`/admin/transactions/${txId}`, { _label: 'GET /admin/transactions/:id' }); await reqAdmin(`/admin/transactions/${txId}/events`, { _label: 'GET /admin/transactions/:id/events' }); + await reqAdmin(`/admin/ledger-entries?transaction_id=${txId}`, { _label: 'GET /admin/ledger-entries' }); + await reqAdmin(`/admin/audit-logs?entity_id=${txId}`, { _label: 'GET /admin/audit-logs' }); await reqAdmin(`/admin/transactions/${txId}/heartbeats`, { _label: 'GET /admin/transactions/:id/heartbeats' }); await reqAdmin(`/admin/devices/${deviceId}/heartbeats`, { _label: 'GET /admin/devices/:id/heartbeats' }); await reqAdmin('/admin/notifications/failed', { _label: 'GET /admin/notifications/failed' }); @@ -163,5 +205,272 @@ async function reqDevice(path, opts = {}) { }); await reqAdmin('/admin/dashboard/summary', { _label: 'GET /admin/dashboard/summary' }); + const noBindingOutlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, { + method: 'POST', + body: { name: `No Binding Outlet ${ts}` }, + _label: 'POST /admin/merchants/:id/outlets no-binding' + }); + const noBindingOutletId = noBindingOutlet?.data?.id; + const noBindingTerminal = await reqAdmin(`/admin/outlets/${noBindingOutletId}/terminals`, { + method: 'POST', + body: { terminal_code: `TERM-NB-${ts}`, qr_mode: 'static' }, + _label: 'POST /admin/outlets/:id/terminals no-binding' + }); + const noBindingTerminalId = noBindingTerminal?.data?.id; + const noBindingTx = await reqAdmin('/admin/transactions', { + method: 'POST', + body: { + partner_reference: `PR-NB-${ts}`, + merchant_id: merchantId, + outlet_id: noBindingOutletId, + terminal_id: noBindingTerminalId, + amount: 9900, + currency: 'IDR', + qr_mode: 'static', + initiation_mode: 'static', + status: 'initiated' + }, + _label: 'POST /admin/transactions no-binding' + }); + const noBindingCallback = { + partner_reference: `PR-NB-${ts}`, + partner_txn_id: `PTX-NB-${ts}`, + amount: 9900, + currency: 'IDR', + payment_status: 'paid', + status: 'paid', + paid_at: new Date().toISOString() + }; + const noBindingSignature = createHmac('sha256', SECRET).update(JSON.stringify(noBindingCallback)).digest('hex'); + await req('/integrations/qris/callback', { + method: 'POST', + headers: { 'X-Partner-Signature': noBindingSignature }, + body: { ...noBindingCallback, signature: noBindingSignature }, + _label: 'POST /integrations/qris/callback no-binding' + }); + await reqAdmin(`/admin/ledger-entries?transaction_id=${noBindingTx?.data?.id}`, { + _label: 'GET /admin/ledger-entries no-binding' + }); + await reqAdmin('/admin/notifications/failed', { _label: 'GET /admin/notifications/failed no-binding' }); + + const dynamicOutlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, { + method: 'POST', + body: { name: `Dynamic API Outlet ${ts}` }, + _label: 'POST /admin/merchants/:id/outlets dynamic-api' + }); + const dynamicOutletId = dynamicOutlet?.data?.id; + const dynamicTerminal = await reqAdmin(`/admin/outlets/${dynamicOutletId}/terminals`, { + method: 'POST', + body: { terminal_code: `TERM-DYN-${ts}`, qr_mode: 'dynamic_api' }, + _label: 'POST /admin/outlets/:id/terminals dynamic-api' + }); + const dynamicTerminalId = dynamicTerminal?.data?.id; + const dynamicDevice = await reqAdmin('/admin/devices', { + method: 'POST', + body: { + device_code: `DEV-API-${ts}`, + vendor: 'acme', + model: 'api-v1', + communication_mode: 'api', + capability_profile_json: { + dynamic_qr: { api_direct: true, mqtt: false }, + flows: ['dynamic_qr:api_direct', 'static_payment_notification'] + }, + status: 'active' + }, + _label: 'POST /admin/devices dynamic-api' + }); + const dynamicDeviceId = dynamicDevice?.data?.id; + await reqAdmin(`/admin/devices/${dynamicDeviceId}/bind`, { + method: 'POST', + body: { + merchant_id: merchantId, + outlet_id: dynamicOutletId, + terminal_id: dynamicTerminalId + }, + _label: 'POST /admin/devices/:id/bind dynamic-api' + }); + await reqExpect('/device/transactions/dynamic-qr', 400, { + method: 'POST', + headers: { Authorization: `Bearer ${DEVICE_TOKEN}` }, + body: { + device_id: deviceId, + terminal_id: dynamicTerminalId, + amount: 15000, + currency: 'IDR', + request_id: `DYN-STATIC-${ts}` + }, + _label: 'POST /device/transactions/dynamic-qr unsupported device' + }); + const dynamicRequestId = `DYN-REQ-${ts}`; + const dynamicQr = await reqDevice('/device/transactions/dynamic-qr', { + method: 'POST', + headers: { 'Idempotency-Key': dynamicRequestId }, + body: { + device_id: dynamicDeviceId, + terminal_id: dynamicTerminalId, + amount: 32100, + currency: 'IDR', + request_id: dynamicRequestId + }, + _label: 'POST /device/transactions/dynamic-qr' + }); + const dynamicQrReplay = await reqDevice('/device/transactions/dynamic-qr', { + method: 'POST', + headers: { 'Idempotency-Key': dynamicRequestId }, + body: { + device_id: dynamicDeviceId, + terminal_id: dynamicTerminalId, + amount: 32100, + currency: 'IDR', + request_id: dynamicRequestId + }, + _label: 'POST /device/transactions/dynamic-qr duplicate' + }); + if (dynamicQr?.data?.transaction_id !== dynamicQrReplay?.data?.transaction_id) { + throw new Error('dynamic QR idempotency returned a different transaction'); + } + const dynamicCallback = { + partner_reference: dynamicQr?.data?.partner_reference, + partner_txn_id: `PTX-DYN-${ts}`, + amount: 32100, + currency: 'IDR', + payment_status: 'paid', + status: 'paid', + paid_at: new Date().toISOString() + }; + const dynamicSignature = createHmac('sha256', SECRET).update(JSON.stringify(dynamicCallback)).digest('hex'); + await req('/integrations/qris/callback', { + method: 'POST', + headers: { 'X-Partner-Signature': dynamicSignature }, + body: { ...dynamicCallback, signature: dynamicSignature }, + _label: 'POST /integrations/qris/callback dynamic-api' + }); + await reqAdmin(`/admin/transactions/${dynamicQr?.data?.transaction_id}`, { + _label: 'GET /admin/transactions/:id dynamic-api' + }); + + const mqttOutlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, { + method: 'POST', + body: { name: `Dynamic MQTT Outlet ${ts}` }, + _label: 'POST /admin/merchants/:id/outlets dynamic-mqtt' + }); + const mqttOutletId = mqttOutlet?.data?.id; + const mqttTerminal = await reqAdmin(`/admin/outlets/${mqttOutletId}/terminals`, { + method: 'POST', + body: { terminal_code: `TERM-MQTT-${ts}`, qr_mode: 'dynamic_mqtt' }, + _label: 'POST /admin/outlets/:id/terminals dynamic-mqtt' + }); + const mqttTerminalId = mqttTerminal?.data?.id; + const mqttDevice = await reqAdmin('/admin/devices', { + method: 'POST', + body: { + device_code: `DEV-MQTT-${ts}`, + vendor: 'acme', + model: 'mqtt-v1', + communication_mode: 'mqtt', + capability_profile_json: { + dynamic_qr: { api_direct: false, mqtt: true }, + flows: ['dynamic_qr:mqtt', 'static_payment_notification'] + }, + status: 'active' + }, + _label: 'POST /admin/devices dynamic-mqtt' + }); + const mqttDeviceId = mqttDevice?.data?.id; + await reqAdmin(`/admin/devices/${mqttDeviceId}/bind`, { + method: 'POST', + body: { + merchant_id: merchantId, + outlet_id: mqttOutletId, + terminal_id: mqttTerminalId + }, + _label: 'POST /admin/devices/:id/bind dynamic-mqtt' + }); + const mqttRequestId = `MQTT-DYN-${ts}`; + const mqttQr = await reqDevice('/device/mqtt/uplink/dynamic-qr/request', { + method: 'POST', + body: { + message_type: 'dynamic_qr_request', + request_id: mqttRequestId, + device_id: mqttDeviceId, + terminal_id: mqttTerminalId, + amount: 43200, + currency: 'IDR', + created_at: new Date().toISOString() + }, + _label: 'POST /device/mqtt/uplink/dynamic-qr/request' + }); + const mqttQrReplay = await reqDevice('/device/mqtt/uplink/dynamic-qr/request', { + method: 'POST', + body: { + message_type: 'dynamic_qr_request', + request_id: mqttRequestId, + device_id: mqttDeviceId, + terminal_id: mqttTerminalId, + amount: 43200, + currency: 'IDR', + created_at: new Date().toISOString() + }, + _label: 'POST /device/mqtt/uplink/dynamic-qr/request duplicate' + }); + if (mqttQr?.data?.transaction_id !== mqttQrReplay?.data?.transaction_id) { + throw new Error('MQTT dynamic QR idempotency returned a different transaction'); + } + await reqAdmin(`/admin/devices/${mqttDeviceId}/mqtt-messages?correlation_id=${mqttRequestId}`, { + _label: 'GET /admin/devices/:id/mqtt-messages dynamic-mqtt' + }); + const mqttCallback = { + partner_reference: mqttQr?.data?.partner_reference, + partner_txn_id: `PTX-MQTT-${ts}`, + amount: 43200, + currency: 'IDR', + payment_status: 'paid', + status: 'paid', + paid_at: new Date().toISOString() + }; + const mqttSignature = createHmac('sha256', SECRET).update(JSON.stringify(mqttCallback)).digest('hex'); + await req('/integrations/qris/callback', { + method: 'POST', + headers: { 'X-Partner-Signature': mqttSignature }, + body: { ...mqttCallback, signature: mqttSignature }, + _label: 'POST /integrations/qris/callback dynamic-mqtt' + }); + await reqAdmin(`/admin/transactions/${mqttQr?.data?.transaction_id}`, { + _label: 'GET /admin/transactions/:id dynamic-mqtt' + }); + + const pushedConfig = await reqAdmin(`/admin/devices/${dynamicDeviceId}/config`, { + method: 'PATCH', + body: { + settings: { + volume: 65, + language: 'id-ID', + heartbeat_interval_seconds: 45 + } + }, + _label: 'PATCH /admin/devices/:id/config' + }); + const configVersion = pushedConfig?.data?.config?.config_version; + await reqDevice(`/device/config?device_id=${dynamicDeviceId}`, { + _label: 'GET /device/config' + }); + await reqDevice('/device/config/ack', { + method: 'POST', + body: { + device_id: dynamicDeviceId, + config_version: configVersion, + status: 'applied', + result_payload: { applied_at: new Date().toISOString() } + }, + _label: 'POST /device/config/ack' + }); + await reqAdmin(`/admin/devices/${dynamicDeviceId}/config`, { + _label: 'GET /admin/devices/:id/config' + }); + await reqAdmin(`/admin/devices/${dynamicDeviceId}/mqtt-messages?message_type=config_push`, { + _label: 'GET /admin/devices/:id/mqtt-messages config' + }); + console.log(`Smoke point 4 flow done. tx=${txId} device=${deviceId}`); })(); diff --git a/src/app.ts b/src/app.ts index 4c8000f..e5f27c5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,7 +15,26 @@ import fs from "node:fs"; const app = express(); startNotificationOrchestrator(); -app.use(helmet()); +app.use( + helmet({ + crossOriginResourcePolicy: { + policy: "cross-origin" + }, + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.tailwindcss.com"], + scriptSrcAttr: ["'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], + fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], + imgSrc: ["'self'", "data:", "https://lh3.googleusercontent.com", "https://*.googleusercontent.com"], + connectSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"] + } + } + }) +); app.use(express.json()); app.use(morgan("dev")); app.use(requestContext); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 6869f36..23ee8ec 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -56,6 +56,18 @@ import { 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"; const router = Router(); @@ -104,6 +116,11 @@ type DeviceCommandInput = { payload?: Record; }; +type DeviceConfigInput = { + settings?: Record; + config_version?: number; +}; + type BindingInput = { merchant_id?: string; outlet_id?: string; @@ -262,6 +279,7 @@ async function buildDeviceAdminPayload(device: DeviceEntity) { const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); return { ...toDevicePayload(device), + capability_summary: resolveDeviceCapabilitySummary(device), derived_status: deriveDeviceStatus({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, @@ -327,6 +345,30 @@ function normalizeMerchantMode(payloadMode: MerchantCreateInput["payout_mode"]): return payloadMode || "merchant_direct"; } +async function auditAdminAction( + req: Request, + payload: { + action: string; + entity_type: string; + entity_id: string; + before_json?: unknown; + after_json?: unknown; + } +) { + 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 + }); +} + function validatePayoutConfig(payload: MerchantCreateInput) { const mode = normalizeMerchantMode(payload.payout_mode); if (mode === "merchant_direct") { @@ -430,6 +472,13 @@ router.post( 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))); } ); @@ -481,6 +530,13 @@ router.patch("/merchants/:merchantId", requireAdminToken, async (req: Request, r } 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))); }); @@ -498,6 +554,14 @@ router.post("/merchants/:merchantId/approve", requireAdminToken, async (req: Req 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))); }); @@ -520,6 +584,17 @@ router.post( 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), @@ -591,7 +666,11 @@ router.post( device_code: "DEV_SEED_A", vendor: "seed-maker", model: "v1", - communication_mode: "mqtt", + communication_mode: "static", + capability_profile_json: { + dynamic_qr: false, + flows: ["static_payment_notification"] + }, status: "active" }); const deviceB = await createDevice({ @@ -599,14 +678,28 @@ router.post( 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: "mqtt", - status: "inactive" + 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({ @@ -732,6 +825,13 @@ router.post( 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)); } ); @@ -775,7 +875,15 @@ router.patch("/outlets/:outletId", requireAdminToken, async (req: Request, res: } 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") { @@ -819,6 +927,13 @@ router.post( 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))); } ); @@ -865,7 +980,15 @@ router.patch("/terminals/:terminalId", requireAdminToken, async (req: Request, r } 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") { @@ -894,6 +1017,12 @@ router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", } 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))); }); @@ -969,7 +1098,7 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re return next(new ApiError("NOT_FOUND", "device not found", 404)); } - const activeBinding = await getActiveBindingByDevice(device.id); + const activeBinding = await getActiveBindingByDevice(device.id); const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id); const notifications = (await listNotificationsByDevice(device.id)) @@ -980,6 +1109,7 @@ router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Re res.json( successResponse(req, { ...toDevicePayload(device), + capability_summary: resolveDeviceCapabilitySummary(device), derived_status: deriveDeviceStatus({ last_seen_at: device.last_seen_at, network_strength: latestHeartbeat?.network_strength ?? null, @@ -1047,7 +1177,15 @@ router.patch("/devices/:deviceId", requireAdminToken, async (req: Request, res: } 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") { @@ -1094,6 +1232,13 @@ router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: " 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))); }); @@ -1108,6 +1253,14 @@ router.post("/devices/:deviceId/unbind", requireAdminToken, async (req: Request, 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))); }); @@ -1199,6 +1352,99 @@ router.get("/devices/:deviceId/notifications", requireAdminToken, async (req: Re res.json(successResponse(req, { device_id: device.id, notifications })); }); +router.get("/devices/:deviceId/config", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + 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); + res.json(successResponse(req, { ...toDeviceConfigPayload(config), latest_acks: acks })); +}); + +router.patch("/devices/:deviceId/config", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const payload = req.body as DeviceConfigInput; + 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 mqttPayload = { + message_type: "config_push" as const, + config_version: config.config_version, + settings: config.settings_json + }; + const publishResult = await publishConfigPush(device.id, mqttPayload); + const outbox = await createMqttMessage({ + direction: "downlink", + device_id: device.id, + 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 + }); + + 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.get("/devices/:deviceId/mqtt-messages", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const directionRaw = (req.query.direction as string | undefined)?.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 as string | undefined)?.trim(); + const correlationId = (req.query.correlation_id as string | undefined)?.trim(); + const limitRaw = req.query.limit as string | undefined; + 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, @@ -1268,6 +1514,13 @@ router.post( status: payload.status || "initiated" }); + 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))); } ); @@ -1331,6 +1584,7 @@ router.get( } 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 @@ -1350,6 +1604,7 @@ router.get( successResponse(req, { transaction: toTransactionPayload(tx), events, + ledger_entries, heartbeat_device_id: heartbeatDeviceId, heartbeat_history: heartbeatHistory }) @@ -1588,4 +1843,56 @@ router.get("/notifications/failed", requireAdminToken, async (req: Request, res: res.json(successResponse(req, filtered)); }); +router.get("/audit-logs", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const entityType = (req.query.entity_type as string | undefined)?.trim(); + const entityId = (req.query.entity_id as string | undefined)?.trim(); + const action = (req.query.action as string | undefined)?.trim(); + const from = req.query.from as string | undefined; + const to = req.query.to as string | undefined; + const limitRaw = req.query.limit as string | undefined; + 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: Request, res: Response, next: NextFunction) => { + const transactionId = (req.query.transaction_id as string | undefined)?.trim(); + const merchantId = (req.query.merchant_id as string | undefined)?.trim(); + const limitRaw = req.query.limit as string | undefined; + 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; diff --git a/src/routes/device.ts b/src/routes/device.ts index a2aa369..c95afa1 100644 --- a/src/routes/device.ts +++ b/src/routes/device.ts @@ -5,6 +5,20 @@ 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(); @@ -25,6 +39,28 @@ type CommandAckInput = { result_payload?: Record; }; +type DynamicQrInput = { + device_id?: string; + terminal_id?: string; + amount?: unknown; + currency?: string; + request_id?: string; + expires_in_seconds?: unknown; +}; + +type MqttDynamicQrInput = DynamicQrInput & { + message_type?: string; + created_at?: string; +}; + +type ConfigAckInput = { + device_id?: string; + config_version?: unknown; + status?: "applied" | "failed"; + reason?: string; + result_payload?: Record; +}; + function normalizeNumberOrNull(value: unknown): number | null { if (typeof value === "string") { const parsed = Number(value); @@ -40,6 +76,35 @@ function normalizeNumberOrNull(value: unknown): number | null { return null; } +function normalizePositiveAmount(value: unknown): number | null { + const normalized = normalizeNumberOrNull(value); + if (normalized === null || normalized <= 0) { + return null; + } + return normalized; +} + +function normalizeTtl(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + + const normalized = normalizeNumberOrNull(value); + if (normalized === null || normalized <= 0) { + return undefined; + } + + return normalized; +} + +function normalizePositiveInteger(value: unknown): number | null { + const normalized = normalizeNumberOrNull(value); + if (normalized === null || normalized <= 0 || !Number.isInteger(normalized)) { + return null; + } + return normalized; +} + function normalizeSignalStrength(value: unknown): number | null { const normalized = normalizeNumberOrNull(value); if (normalized === null) { @@ -165,4 +230,233 @@ router.post("/commands/ack", requireDeviceToken, async (req: Request, res: Respo ); }); +router.post("/transactions/dynamic-qr", requireDeviceToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as DynamicQrInput; + 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 as { data: unknown }).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: Request, res: Response, next: NextFunction) => { + const payload = req.body as MqttDynamicQrInput; + 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 as { data: unknown }).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" as const, + correlation_id: payload.request_id, + transaction_id: created.transaction_id, + status: "success" as const, + 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: Request, res: Response, next: NextFunction) => { + const deviceId = (req.query.device_id as string | undefined) || (req.body as { device_id?: string } | undefined)?.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: Request, res: Response, next: NextFunction) => { + const payload = req.body as ConfigAckInput; + 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 || {} + }); + + res.json(successResponse(req, toDeviceConfigAckPayload(ack))); +}); + export default router; diff --git a/src/routes/integrations.ts b/src/routes/integrations.ts index 1fc4e38..82afcc6 100644 --- a/src/routes/integrations.ts +++ b/src/routes/integrations.ts @@ -11,6 +11,7 @@ import { } from "../shared/store/transactionStore"; import { emitTransactionPaid } from "../shared/events/transactionEvents"; import { env } from "../config/env"; +import { createAuditLog } from "../shared/store/auditLogStore"; const router = Router(); @@ -159,6 +160,29 @@ function writeCallbackResult(idempotencyKey: string, response: CallbackResponse, ); } +async function auditWebhookAction( + req: Request, + payload: { + action: string; + entity_id: string; + before_json?: unknown; + after_json?: unknown; + } +) { + await createAuditLog({ + actor_type: "webhook", + actor_id: "qris_partner", + action: payload.action, + entity_type: "transaction", + 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 makeResponseEventId(txId: string, fallbackTag: string) { const events = await getTransactionEvents(txId); return events.at(-1)?.id || `${fallbackTag}_${Date.now()}`; @@ -281,6 +305,12 @@ router.post("/qris/callback", async (req: Request, res: Response, next: NextFunc } if (!wasPaid) { + await auditWebhookAction(req, { + action: "transaction.mark_paid", + entity_id: updated.id, + before_json: tx, + after_json: updated + }); emitTransactionPaid({ transaction_id: updated.id, merchant_id: updated.merchant_id, @@ -333,6 +363,13 @@ router.post("/qris/callback", async (req: Request, res: Response, next: NextFunc throw error; } + await auditWebhookAction(req, { + action: "transaction.mark_expired", + entity_id: updated.id, + before_json: tx, + after_json: updated + }); + const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event")); writeCallbackResult(idempotencyKey, response, updated.id); return res.json(successResponse(req, response)); @@ -359,6 +396,13 @@ router.post("/qris/callback", async (req: Request, res: Response, next: NextFunc throw error; } + await auditWebhookAction(req, { + action: "transaction.mark_failed", + entity_id: updated.id, + before_json: tx, + after_json: updated + }); + const response = buildCallbackResponse( req, updated.id, diff --git a/src/shared/db/pool.ts b/src/shared/db/pool.ts index de33f1a..f369f99 100644 --- a/src/shared/db/pool.ts +++ b/src/shared/db/pool.ts @@ -154,6 +154,41 @@ CREATE TABLE IF NOT EXISTS device_commands ( CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC); +CREATE TABLE IF NOT EXISTS mqtt_messages ( + id TEXT PRIMARY KEY, + direction TEXT NOT NULL CHECK (direction IN ('uplink', 'downlink')), + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + topic TEXT NOT NULL, + message_type TEXT NOT NULL, + correlation_id TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + publish_status TEXT NOT NULL DEFAULT 'recorded' CHECK (publish_status IN ('recorded', 'sent', 'failed')), + reason TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_mqtt_messages_device_created ON mqtt_messages (device_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_mqtt_messages_correlation ON mqtt_messages (correlation_id); + +CREATE TABLE IF NOT EXISTS device_configs ( + device_id TEXT PRIMARY KEY REFERENCES devices (id) ON DELETE CASCADE, + config_version INT NOT NULL, + settings_json JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS device_config_acks ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + config_version INT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('applied', 'failed')), + reason TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + acked_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_device_config_acks_device ON device_config_acks (device_id, acked_at DESC); + CREATE TABLE IF NOT EXISTS transactions ( id TEXT PRIMARY KEY, transaction_code TEXT NOT NULL UNIQUE, @@ -211,5 +246,65 @@ CREATE TABLE IF NOT EXISTS notifications ( CREATE INDEX IF NOT EXISTS idx_notifications_device_status ON notifications (device_id, delivery_status); CREATE INDEX IF NOT EXISTS idx_notifications_status_created ON notifications (delivery_status, created_at DESC); +CREATE TABLE IF NOT EXISTS ledger_entries ( + id TEXT PRIMARY KEY, + transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + entry_type TEXT NOT NULL CHECK (entry_type IN ('gross_income', 'platform_fee', 'merchant_payable')), + amount NUMERIC(20,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'IDR', + direction TEXT NOT NULL CHECK (direction IN ('credit', 'debit')), + status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'voided')), + metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT ledger_entries_unique_tx_type UNIQUE (transaction_id, entry_type) +); + +CREATE INDEX IF NOT EXISTS idx_ledger_entries_merchant_created ON ledger_entries (merchant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ledger_entries_tx ON ledger_entries (transaction_id); + +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + permissions_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role_id TEXT NOT NULL REFERENCES roles (id), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL, + actor_id TEXT, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + before_json JSONB, + after_json JSONB, + source_ip TEXT, + request_id TEXT, + trace_id TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs (entity_type, entity_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs (action, created_at DESC); + +INSERT INTO roles (id, name, permissions_json, created_at) +VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO users (id, name, email, password_hash, role_id, status, created_at) +VALUES ('user_admin_seed', 'Admin Seed', 'admin@example.local', 'dev-only-admin-password', 'role_admin', 'active', NOW()) +ON CONFLICT (id) DO NOTHING; + COMMIT; `; diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts index 7a243ed..3d26172 100644 --- a/src/shared/errors/index.ts +++ b/src/shared/errors/index.ts @@ -17,7 +17,10 @@ export type ErrorCode = | "NOTIFICATION_DEVICE_UNAVAILABLE" | "NOTIFICATION_NO_ACTIVE_BINDING" | "NOTIFICATION_PUBLISH_FAILED" - | "NOTIFICATION_RETRY_EXHAUSTED"; + | "NOTIFICATION_RETRY_EXHAUSTED" + | "INVALID_AMOUNT" + | "DEVICE_NOT_BOUND" + | "DEVICE_CAPABILITY_NOT_SUPPORTED"; export interface ApiErrorShape { code: ErrorCode; diff --git a/src/shared/services/deviceCapabilityResolver.ts b/src/shared/services/deviceCapabilityResolver.ts new file mode 100644 index 0000000..9738117 --- /dev/null +++ b/src/shared/services/deviceCapabilityResolver.ts @@ -0,0 +1,45 @@ +import type { DeviceEntity } from "../store/deviceStore"; + +type CapabilityProfile = { + dynamic_qr?: boolean | { + api_direct?: boolean; + mqtt?: boolean; + }; + flows?: string[]; +}; + +export type DynamicQrFlow = "api_direct" | "mqtt"; + +function getProfile(device: DeviceEntity): CapabilityProfile { + return (device.capability_profile_json || {}) as CapabilityProfile; +} + +export function supportsDynamicQrFlow(device: DeviceEntity, flow: DynamicQrFlow): boolean { + const profile = getProfile(device); + const flows = Array.isArray(profile.flows) ? profile.flows : []; + + if (flow === "api_direct" && device.communication_mode !== "api") { + return false; + } + + if (flow === "mqtt" && device.communication_mode !== "mqtt") { + return false; + } + + if (typeof profile.dynamic_qr === "boolean") { + return profile.dynamic_qr || flows.includes(`dynamic_qr:${flow}`); + } + + if (profile.dynamic_qr && typeof profile.dynamic_qr === "object") { + return Boolean(profile.dynamic_qr[flow]) || flows.includes(`dynamic_qr:${flow}`); + } + + return flows.includes(`dynamic_qr:${flow}`); +} + +export function resolveDeviceCapabilitySummary(device: DeviceEntity) { + return { + dynamic_qr_api_direct: supportsDynamicQrFlow(device, "api_direct"), + dynamic_qr_mqtt: supportsDynamicQrFlow(device, "mqtt") + }; +} diff --git a/src/shared/services/dynamicQrOrchestrator.ts b/src/shared/services/dynamicQrOrchestrator.ts new file mode 100644 index 0000000..b539093 --- /dev/null +++ b/src/shared/services/dynamicQrOrchestrator.ts @@ -0,0 +1,104 @@ +import { randomUUID } from "node:crypto"; +import { createTransaction, addTransactionEvent, toTransactionPayload } from "../store/transactionStore"; + +export type DynamicQrCreateResult = { + request_id: string; + correlation_id: string; + transaction_id: string; + transaction_code: string; + qr_type: "dynamic"; + qr_payload: string; + expires_at: string; + status: "awaiting_payment"; + partner_reference: string; +}; + +function makePartnerReference(requestId: string) { + const clean = requestId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 40); + return `DYN-${clean || randomUUID().slice(0, 12)}`; +} + +function makeDynamicQrPayload(input: { + transactionId: string; + partnerReference: string; + amount: number; + currency: string; + expiresAt: string; +}) { + const amountMinor = Math.round(input.amount * 100); + const encoded = Buffer.from( + JSON.stringify({ + type: "QRIS_DYNAMIC_MOCK", + transaction_id: input.transactionId, + partner_reference: input.partnerReference, + amount_minor: amountMinor, + currency: input.currency, + expires_at: input.expiresAt + }) + ).toString("base64url"); + + return `QRIS-DYNAMIC-MOCK.${encoded}`; +} + +export async function createDynamicQrTransaction(input: { + request_id: string; + device_id: string; + merchant_id: string; + outlet_id: string; + terminal_id: string; + amount: number; + currency?: string; + expires_in_seconds?: number; + initiation_mode?: "dynamic_api" | "dynamic_mqtt"; +}): Promise { + const ttlSeconds = Math.min(Math.max(input.expires_in_seconds || 300, 60), 1800); + const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString(); + const partnerReference = makePartnerReference(input.request_id); + const tx = await createTransaction({ + merchant_id: input.merchant_id, + outlet_id: input.outlet_id, + terminal_id: input.terminal_id, + device_id: input.device_id, + partner_reference: partnerReference, + amount: input.amount, + currency: input.currency || "IDR", + qr_mode: "dynamic", + initiation_mode: input.initiation_mode || "dynamic_api", + status: "awaiting_payment", + expired_at: expiresAt + }); + + const qrPayload = makeDynamicQrPayload({ + transactionId: tx.id, + partnerReference, + amount: tx.amount, + currency: tx.currency, + expiresAt + }); + + await addTransactionEvent({ + transaction_id: tx.id, + event_type: "DYNAMIC_QR_CREATED", + source: "device", + payload_json: { + request_id: input.request_id, + correlation_id: input.request_id, + device_id: input.device_id, + qr_payload: qrPayload, + expires_at: expiresAt, + transaction: toTransactionPayload(tx) + } + }); + + return { + request_id: input.request_id, + correlation_id: input.request_id, + transaction_id: tx.id, + transaction_code: tx.transaction_code, + qr_type: "dynamic", + qr_payload: qrPayload, + expires_at: expiresAt, + status: "awaiting_payment", + partner_reference: partnerReference + }; +} diff --git a/src/shared/services/mqttPublisher.ts b/src/shared/services/mqttPublisher.ts index 4cd733a..43983f0 100644 --- a/src/shared/services/mqttPublisher.ts +++ b/src/shared/services/mqttPublisher.ts @@ -15,14 +15,29 @@ type PaymentSuccessPayload = { display_text: string; }; -export type MqttPublishResult = { +type DynamicQrResponsePayload = { + message_type: "dynamic_qr_response"; + correlation_id: string; + transaction_id: string; + status: "success"; + qr_payload: string; + expires_at: string; +}; + +type ConfigPushPayload = { + message_type: "config_push"; + config_version: number; + settings: Record; +}; + +export type MqttPublishResult = { ok: boolean; topic: string; qos: 1; retained: false; publishedAt: string; reason?: string; - payload: PaymentSuccessPayload; + payload: TPayload; }; const forcedFailAll = String(env.MQTT_PUBLISH_FORCE_FAIL_ALL).toLowerCase() === "true"; @@ -72,11 +87,22 @@ export function makePaymentSuccessTopic(deviceId: string) { return `devices/${deviceId}/downlink/payment/success`; } -export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Promise { - const publishedAt = new Date().toISOString(); - const topic = makePaymentSuccessTopic(payload.device_id); +export function makeDynamicQrResponseTopic(deviceId: string) { + return `devices/${deviceId}/downlink/dynamic-qr/response`; +} - if (shouldForceFail(payload.device_id)) { +export function makeConfigPushTopic(deviceId: string) { + return `devices/${deviceId}/downlink/config/push`; +} + +async function publishMqttPayload( + deviceId: string, + topic: string, + payload: TPayload +): Promise> { + const publishedAt = new Date().toISOString(); + + if (shouldForceFail(deviceId)) { return { ok: false, topic, @@ -97,3 +123,15 @@ export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Pro payload }; } + +export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Promise { + return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload); +} + +export async function publishDynamicQrResponse(deviceId: string, payload: DynamicQrResponsePayload) { + return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload); +} + +export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) { + return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload); +} diff --git a/src/shared/store/auditLogStore.ts b/src/shared/store/auditLogStore.ts new file mode 100644 index 0000000..5455e09 --- /dev/null +++ b/src/shared/store/auditLogStore.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export interface AuditLogEntity { + id: string; + actor_type: "admin" | "device" | "webhook" | "system"; + actor_id?: string; + action: string; + entity_type: string; + entity_id: string; + before_json?: Record | null; + after_json?: Record | null; + source_ip?: string; + request_id?: string; + trace_id?: string; + created_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function mapAuditLog(row: any): AuditLogEntity { + return { + id: row.id, + actor_type: row.actor_type, + actor_id: row.actor_id || undefined, + action: row.action, + entity_type: row.entity_type, + entity_id: row.entity_id, + before_json: row.before_json || null, + after_json: row.after_json || null, + source_ip: row.source_ip || undefined, + request_id: row.request_id || undefined, + trace_id: row.trace_id || undefined, + created_at: row.created_at + }; +} + +export async function createAuditLog(payload: { + actor_type: AuditLogEntity["actor_type"]; + actor_id?: string; + action: string; + entity_type: string; + entity_id: string; + before_json?: unknown; + after_json?: unknown; + source_ip?: string; + request_id?: string; + trace_id?: string; +}): Promise { + const { rows } = await getPool().query( + `INSERT INTO audit_logs ( + id, + actor_type, + actor_id, + action, + entity_type, + entity_id, + before_json, + after_json, + source_ip, + request_id, + trace_id, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, + [ + randomUUID(), + payload.actor_type, + payload.actor_id || null, + payload.action, + payload.entity_type, + payload.entity_id, + payload.before_json || null, + payload.after_json || null, + payload.source_ip || null, + payload.request_id || null, + payload.trace_id || null, + nowIso() + ] + ); + + return mapAuditLog(rows[0]); +} + +export async function listAuditLogs(filter?: { + entity_type?: string; + entity_id?: string; + action?: string; + from?: string; + to?: string; + limit?: number; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + + if (filter?.entity_type) { + clauses.push(`entity_type = $${i++}`); + params.push(filter.entity_type); + } + + if (filter?.entity_id) { + clauses.push(`entity_id = $${i++}`); + params.push(filter.entity_id); + } + + if (filter?.action) { + clauses.push(`action = $${i++}`); + params.push(filter.action); + } + + if (filter?.from) { + clauses.push(`created_at >= $${i++}`); + params.push(filter.from); + } + + if (filter?.to) { + clauses.push(`created_at <= $${i++}`); + params.push(filter.to); + } + + const limit = Math.min(Math.max(filter?.limit || 100, 1), 500); + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query( + `SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ${limit}`, + params + ); + + return rows.map(mapAuditLog); +} + +export function toAuditLogPayload(auditLog: AuditLogEntity) { + return { ...auditLog }; +} diff --git a/src/shared/store/deviceConfigStore.ts b/src/shared/store/deviceConfigStore.ts new file mode 100644 index 0000000..be1f7f8 --- /dev/null +++ b/src/shared/store/deviceConfigStore.ts @@ -0,0 +1,140 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export interface DeviceConfigEntity { + device_id: string; + config_version: number; + settings_json: Record; + updated_at: string; +} + +export interface DeviceConfigAckEntity { + id: string; + device_id: string; + config_version: number; + status: "applied" | "failed"; + reason?: string; + payload_json: Record; + acked_at: string; +} + +const DEFAULT_SETTINGS = { + volume: 80, + language: "id-ID", + heartbeat_interval_seconds: 60 +}; + +function nowIso() { + return new Date().toISOString(); +} + +function mapConfig(row: any): DeviceConfigEntity { + return { + device_id: row.device_id, + config_version: Number(row.config_version), + settings_json: row.settings_json || {}, + updated_at: row.updated_at + }; +} + +function mapAck(row: any): DeviceConfigAckEntity { + return { + id: row.id, + device_id: row.device_id, + config_version: Number(row.config_version), + status: row.status, + reason: row.reason || undefined, + payload_json: row.payload_json || {}, + acked_at: row.acked_at + }; +} + +export async function getDeviceConfig(deviceId: string): Promise { + const { rows } = await getPool().query("SELECT * FROM device_configs WHERE device_id = $1", [deviceId]); + return rows[0] ? mapConfig(rows[0]) : null; +} + +export async function getOrCreateDeviceConfig(deviceId: string): Promise { + const existing = await getDeviceConfig(deviceId); + if (existing) { + return existing; + } + + return upsertDeviceConfig({ + device_id: deviceId, + settings_json: DEFAULT_SETTINGS + }); +} + +export async function upsertDeviceConfig(payload: { + device_id: string; + settings_json: Record; + config_version?: number; +}): Promise { + const existing = await getDeviceConfig(payload.device_id); + const nextVersion = payload.config_version || (existing ? existing.config_version + 1 : 1); + const { rows } = await getPool().query( + `INSERT INTO device_configs (device_id, config_version, settings_json, updated_at) + VALUES ($1,$2,$3,$4) + ON CONFLICT (device_id) DO UPDATE + SET config_version = EXCLUDED.config_version, + settings_json = EXCLUDED.settings_json, + updated_at = EXCLUDED.updated_at + RETURNING *`, + [payload.device_id, nextVersion, payload.settings_json, nowIso()] + ); + + return mapConfig(rows[0]); +} + +export async function createDeviceConfigAck(payload: { + device_id: string; + config_version: number; + status: "applied" | "failed"; + reason?: string; + payload_json?: Record; +}): Promise { + const { rows } = await getPool().query( + `INSERT INTO device_config_acks ( + id, + device_id, + config_version, + status, + reason, + payload_json, + acked_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7) + RETURNING *`, + [ + `cfgack_${randomUUID()}`, + payload.device_id, + payload.config_version, + payload.status, + payload.reason || null, + payload.payload_json || {}, + nowIso() + ] + ); + + return mapAck(rows[0]); +} + +export async function listDeviceConfigAcks(deviceId: string, limit = 50): Promise { + const { rows } = await getPool().query( + `SELECT * FROM device_config_acks + WHERE device_id = $1 + ORDER BY acked_at DESC + LIMIT $2`, + [deviceId, Math.min(Math.max(limit, 1), 200)] + ); + + return rows.map(mapAck); +} + +export function toDeviceConfigPayload(config: DeviceConfigEntity) { + return { ...config }; +} + +export function toDeviceConfigAckPayload(ack: DeviceConfigAckEntity) { + return { ...ack }; +} diff --git a/src/shared/store/ledgerStore.ts b/src/shared/store/ledgerStore.ts new file mode 100644 index 0000000..895ecd7 --- /dev/null +++ b/src/shared/store/ledgerStore.ts @@ -0,0 +1,108 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +import type { TransactionEntity } from "./transactionStore"; + +export interface LedgerEntryEntity { + id: string; + transaction_id: string; + merchant_id: string; + entry_type: "gross_income" | "platform_fee" | "merchant_payable"; + amount: number; + currency: string; + direction: "credit" | "debit"; + status: "posted" | "voided"; + metadata_json: Record; + created_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function mapLedgerEntry(row: any): LedgerEntryEntity { + return { + id: row.id, + transaction_id: row.transaction_id, + merchant_id: row.merchant_id, + entry_type: row.entry_type, + amount: Number(row.amount), + currency: row.currency, + direction: row.direction, + status: row.status, + metadata_json: row.metadata_json || {}, + created_at: row.created_at + }; +} + +export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promise { + const { rows } = await getPool().query( + `INSERT INTO ledger_entries ( + id, + transaction_id, + merchant_id, + entry_type, + amount, + currency, + direction, + status, + metadata_json, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (transaction_id, entry_type) DO UPDATE + SET amount = EXCLUDED.amount, + currency = EXCLUDED.currency, + metadata_json = ledger_entries.metadata_json || EXCLUDED.metadata_json + RETURNING *`, + [ + randomUUID(), + tx.id, + tx.merchant_id, + "gross_income", + tx.amount, + tx.currency, + "credit", + "posted", + { + placeholder: true, + source: "fase1_paid_transaction", + partner_reference: tx.partner_reference + }, + nowIso() + ] + ); + + return mapLedgerEntry(rows[0]); +} + +export async function listLedgerEntries(filter?: { + transaction_id?: string; + merchant_id?: string; + limit?: number; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + + if (filter?.transaction_id) { + clauses.push(`transaction_id = $${i++}`); + params.push(filter.transaction_id); + } + + if (filter?.merchant_id) { + clauses.push(`merchant_id = $${i++}`); + params.push(filter.merchant_id); + } + + const limit = Math.min(Math.max(filter?.limit || 100, 1), 500); + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query( + `SELECT * FROM ledger_entries ${where} ORDER BY created_at DESC LIMIT ${limit}`, + params + ); + + return rows.map(mapLedgerEntry); +} + +export function toLedgerEntryPayload(entry: LedgerEntryEntity) { + return { ...entry }; +} diff --git a/src/shared/store/mqttMessageStore.ts b/src/shared/store/mqttMessageStore.ts new file mode 100644 index 0000000..bbaaa9f --- /dev/null +++ b/src/shared/store/mqttMessageStore.ts @@ -0,0 +1,120 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export interface MqttMessageEntity { + id: string; + direction: "uplink" | "downlink"; + device_id: string; + topic: string; + message_type: string; + correlation_id?: string; + payload_json: Record; + publish_status: "recorded" | "sent" | "failed"; + reason?: string; + created_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function mapMessage(row: any): MqttMessageEntity { + return { + id: row.id, + direction: row.direction, + device_id: row.device_id, + topic: row.topic, + message_type: row.message_type, + correlation_id: row.correlation_id || undefined, + payload_json: row.payload_json || {}, + publish_status: row.publish_status, + reason: row.reason || undefined, + created_at: row.created_at + }; +} + +export async function createMqttMessage(payload: { + direction: MqttMessageEntity["direction"]; + device_id: string; + topic: string; + message_type: string; + correlation_id?: string; + payload_json?: Record; + publish_status?: MqttMessageEntity["publish_status"]; + reason?: string; +}): Promise { + const { rows } = await getPool().query( + `INSERT INTO mqtt_messages ( + id, + direction, + device_id, + topic, + message_type, + correlation_id, + payload_json, + publish_status, + reason, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING *`, + [ + `mqtt_${randomUUID()}`, + payload.direction, + payload.device_id, + payload.topic, + payload.message_type, + payload.correlation_id || null, + payload.payload_json || {}, + payload.publish_status || "recorded", + payload.reason || null, + nowIso() + ] + ); + + return mapMessage(rows[0]); +} + +export async function listMqttMessages(filter?: { + device_id?: string; + direction?: MqttMessageEntity["direction"]; + message_type?: string; + correlation_id?: string; + limit?: number; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + + if (filter?.device_id) { + clauses.push(`device_id = $${i++}`); + params.push(filter.device_id); + } + + if (filter?.direction) { + clauses.push(`direction = $${i++}`); + params.push(filter.direction); + } + + if (filter?.message_type) { + clauses.push(`message_type = $${i++}`); + params.push(filter.message_type); + } + + if (filter?.correlation_id) { + clauses.push(`correlation_id = $${i++}`); + params.push(filter.correlation_id); + } + + const limit = Math.min(Math.max(filter?.limit || 100, 1), 500); + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query( + `SELECT * FROM mqtt_messages ${where} ORDER BY created_at DESC LIMIT ${limit}`, + params + ); + + return rows.map(mapMessage); +} + +export function toMqttMessagePayload(message: MqttMessageEntity) { + return { ...message }; +} diff --git a/src/shared/store/transactionStore.ts b/src/shared/store/transactionStore.ts index b1fd41b..00141c5 100644 --- a/src/shared/store/transactionStore.ts +++ b/src/shared/store/transactionStore.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { getPool } from "../db/pool"; +import { createPaidLedgerPlaceholder } from "./ledgerStore"; export type TransactionStatus = | "initiated" @@ -15,9 +16,10 @@ export type TransactionEventType = | "CALLBACK_RECEIVED" | "CALLBACK_REJECTED" | "CALLBACK_DUPLICATE" - | "PUSH_QUEUED"; + | "PUSH_QUEUED" + | "DYNAMIC_QR_CREATED"; -export type TransactionEventSource = "webhook" | "system" | "admin"; +export type TransactionEventSource = "webhook" | "system" | "admin" | "device"; export interface TransactionEntity { id: string; @@ -265,7 +267,12 @@ export async function updateTransactionStatus( } }); - return mapTransaction(rows[0]); + const updated = mapTransaction(rows[0]); + if (to === "paid") { + await createPaidLedgerPlaceholder(updated); + } + + return updated; } export async function getTransactionById(id: string): Promise { diff --git a/ui/admin-login-portal/index.html b/ui/admin-login-portal/index.html index 4610dc4..c3f631d 100644 --- a/ui/admin-login-portal/index.html +++ b/ui/admin-login-portal/index.html @@ -16,9 +16,10 @@ background: radial-gradient(circle at 50% 50%, rgba(37, 99, 235, 0.05) 0%, transparent 100%); } .glass-panel { - background: rgba(255, 255, 255, 0.8); + background: rgba(15, 23, 42, 0.74); backdrop-filter: blur(12px); - border: 1px solid rgba(226, 232, 240, 0.8); + border: 1px solid rgba(255, 255, 255, 0.16); + box-shadow: 0 16px 40px rgba(2, 6, 23, 0.28); }