Implement phase 1 completion and phase 2 dynamic QR

This commit is contained in:
2026-05-26 08:06:48 +07:00
parent a152c99cce
commit 5624b92872
36 changed files with 3104 additions and 71 deletions

View File

@ -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 14 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 13 (dalam flow kamu, yaitu smoke point 13) 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.1B.3` + `C.1C.3` + `D.1D.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.

View File

@ -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

View File

@ -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.1C.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.

20
dist/app.js vendored
View File

@ -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) {

339
dist/routes/admin.js vendored
View File

@ -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;

219
dist/routes/device.js vendored
View File

@ -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;

View File

@ -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));

View File

@ -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;
`;

View File

@ -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();
};

View File

@ -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")
};
}

View File

@ -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
};
}

View File

@ -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);
}

84
dist/shared/store/auditLogStore.js vendored Normal file
View File

@ -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 };
}

89
dist/shared/store/deviceConfigStore.js vendored Normal file
View File

@ -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 };
}

74
dist/shared/store/ledgerStore.js vendored Normal file
View File

@ -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 };
}

View File

@ -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) {

74
dist/shared/store/mqttMessageStore.js vendored Normal file
View File

@ -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 };
}

View File

@ -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]);

View File

@ -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) {

View File

@ -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}`);
})();

View File

@ -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);

View File

@ -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<string, unknown>;
};
type DeviceConfigInput = {
settings?: Record<string, unknown>;
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;

View File

@ -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<string, unknown>;
};
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<string, unknown>;
};
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;

View File

@ -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,

View File

@ -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;
`;

View File

@ -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;

View File

@ -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")
};
}

View File

@ -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<DynamicQrCreateResult> {
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
};
}

View File

@ -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<string, unknown>;
};
export type MqttPublishResult<TPayload = PaymentSuccessPayload> = {
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<MqttPublishResult> {
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<TPayload>(
deviceId: string,
topic: string,
payload: TPayload
): Promise<MqttPublishResult<TPayload>> {
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<MqttPublishResult> {
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);
}

View File

@ -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<string, unknown> | null;
after_json?: Record<string, unknown> | 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<AuditLogEntity> {
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<AuditLogEntity[]> {
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 };
}

View File

@ -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<string, unknown>;
updated_at: string;
}
export interface DeviceConfigAckEntity {
id: string;
device_id: string;
config_version: number;
status: "applied" | "failed";
reason?: string;
payload_json: Record<string, unknown>;
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<DeviceConfigEntity | null> {
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<DeviceConfigEntity> {
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<string, unknown>;
config_version?: number;
}): Promise<DeviceConfigEntity> {
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<string, unknown>;
}): Promise<DeviceConfigAckEntity> {
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<DeviceConfigAckEntity[]> {
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 };
}

View File

@ -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<string, unknown>;
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<LedgerEntryEntity> {
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<LedgerEntryEntity[]> {
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 };
}

View File

@ -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<string, unknown>;
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<string, unknown>;
publish_status?: MqttMessageEntity["publish_status"];
reason?: string;
}): Promise<MqttMessageEntity> {
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<MqttMessageEntity[]> {
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 };
}

View File

@ -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<TransactionEntity | null> {

View File

@ -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);
}
</style>
<script id="tailwind-config">
@ -135,10 +136,10 @@
<form action="#" class="space-y-6" onsubmit="return false;">
<!-- Email Field -->
<div class="space-y-2">
<label class="block font-label-md text-label-md text-slate-700" for="email">Alamat Email</label>
<label class="block font-label-md text-label-md text-slate-700" for="email">Username Admin</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-[20px]">mail</span>
<input class="w-full pl-10 pr-4 py-3 rounded-xl border border-slate-200 bg-white focus:ring-2 focus:ring-primary focus:border-transparent transition-all outline-none font-body-md text-body-md" id="email" name="email" placeholder="admin@soundboxops.id" required="" type="email"/>
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-[20px]">person</span>
<input class="w-full pl-10 pr-4 py-3 rounded-xl border border-slate-200 bg-white focus:ring-2 focus:ring-primary focus:border-transparent transition-all outline-none font-body-md text-body-md" id="email" name="username" placeholder="admin" required="" type="text" autocomplete="username"/>
</div>
</div>
<!-- Password Field -->
@ -266,4 +267,4 @@
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
</div>
'
</body></html>
</body></html>

View File

@ -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);
}
</style>
<script id="tailwind-config">
@ -135,10 +136,10 @@
<form action="#" class="space-y-6" onsubmit="return false;">
<!-- Email Field -->
<div class="space-y-2">
<label class="block font-label-md text-label-md text-slate-700" for="email">Alamat Email</label>
<label class="block font-label-md text-label-md text-slate-700" for="email">Username Admin</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-[20px]">mail</span>
<input class="w-full pl-10 pr-4 py-3 rounded-xl border border-slate-200 bg-white focus:ring-2 focus:ring-primary focus:border-transparent transition-all outline-none font-body-md text-body-md" id="email" name="email" placeholder="admin@soundboxops.id" required="" type="email"/>
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-[20px]">person</span>
<input class="w-full pl-10 pr-4 py-3 rounded-xl border border-slate-200 bg-white focus:ring-2 focus:ring-primary focus:border-transparent transition-all outline-none font-body-md text-body-md" id="email" name="username" placeholder="admin" required="" type="text" autocomplete="username"/>
</div>
</div>
<!-- Password Field -->