Implement phase 1 completion and phase 2 dynamic QR
This commit is contained in:
@ -1,11 +1,22 @@
|
||||
# CODEx Handoff — QRIS Soundbox Platform
|
||||
|
||||
## Current status
|
||||
- Fokus terakhir: sinkronisasi UI dan smoke test pada stack yang sudah aktif.
|
||||
- Fokus terakhir: penyelesaian gap Fase 1 backend + smoke e2e skenario wajib.
|
||||
- Implementasi backend dan UI sudah mulai dikerjakan di repository (tidak lagi hanya dokumentasi).
|
||||
- Tambahan terbaru:
|
||||
- audit log untuk aksi admin/webhook penting
|
||||
- ledger placeholder `gross_income` saat transaksi menjadi `paid`
|
||||
- endpoint admin `GET /admin/audit-logs` dan `GET /admin/ledger-entries`
|
||||
- awal Fase 2: capability resolver + `POST /device/transactions/dynamic-qr` API-direct
|
||||
- lanjutan Fase 2: MQTT dynamic QR simulator/outbox + device config push/ack
|
||||
- smoke e2e mencakup duplicate callback, invalid signature, ledger, audit, terminal tanpa binding, dynamic QR API-direct, dynamic QR MQTT, dan device config
|
||||
- fix UI lokal:
|
||||
- CSP Helmet dilonggarkan untuk Tailwind CDN, Google Fonts/Material Symbols, dan image Googleusercontent agar desain render normal
|
||||
- panel kanan login admin dibuat dark glass supaya teks putih terbaca
|
||||
- input login admin diubah dari email ke username text agar credential dev `admin/admin` bisa dipakai
|
||||
- Smoke test Fase1 jalannya:
|
||||
- `smoke:cleanup` ✅
|
||||
- `smoke:flow` ❌ saat dijalankan langsung (karena server belum jalan di `localhost:3100`)
|
||||
- `smoke:flow` jalan jika server aktif di `localhost:3100`
|
||||
- `smoke:e2e` ✅ setelah server auto-start di port 3100 (cleanup + full flow berhasil).
|
||||
|
||||
## Files baru/terbaru yang sudah dibuat
|
||||
@ -20,9 +31,27 @@
|
||||
- [UI: transaction-history-monitoring](/home/wira/work/codex/qris-soundbox-platform/ui/transaction-history-monitoring/index.html)
|
||||
- Search/filter outlet-terminal dan path transaksi sudah memakai endpoint API admin.
|
||||
- [README](/home/wira/work/codex/qris-soundbox-platform/README.md)
|
||||
- Sudah ada script dan langkah smoke test (`smoke:cleanup`, `smoke:flow`, `smoke:e2e`) siap dipakai.
|
||||
- Sudah ada script dan langkah smoke test (`smoke:cleanup`, `smoke:flow`, `smoke:e2e`) siap dipakai dan mencakup skenario Fase 1 tambahan.
|
||||
- [DECISIONS_LOG.md](/home/wira/work/codex/qris-soundbox-platform/DECISIONS_LOG.md)
|
||||
- Sudah memuat keputusan merchant bank account: kini arah keputusan ke rekening milik merchant (bukan escrow/terpusat) agar menghindari kebutuhan izin tambahan di awal.
|
||||
- Sudah memuat keputusan merchant bank account dan keputusan audit log + ledger placeholder Fase 1.
|
||||
- [Backend: auditLogStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/auditLogStore.ts)
|
||||
- Store audit log untuk aksi admin/webhook penting.
|
||||
- [Backend: ledgerStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/ledgerStore.ts)
|
||||
- Store ledger placeholder untuk transaksi paid Fase 1.
|
||||
- [Backend: deviceCapabilityResolver](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/deviceCapabilityResolver.ts)
|
||||
- Resolver capability untuk flow dynamic QR API/MQTT.
|
||||
- [Backend: dynamicQrOrchestrator](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/dynamicQrOrchestrator.ts)
|
||||
- Membuat transaksi dynamic `awaiting_payment` dan mock QR payload.
|
||||
- [Backend: mqttMessageStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/mqttMessageStore.ts)
|
||||
- Outbox/trace MQTT uplink dan downlink.
|
||||
- [Backend: deviceConfigStore](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/deviceConfigStore.ts)
|
||||
- Config versioned dan ACK device.
|
||||
- [App CSP](/home/wira/work/codex/qris-soundbox-platform/src/app.ts)
|
||||
- Helmet CSP disesuaikan agar asset desain eksternal dapat dimuat di lokal.
|
||||
- [UI: admin-login](/home/wira/work/codex/qris-soundbox-platform/ui/admin-login/index.html)
|
||||
- Login admin API-wired, input username dev, dan kontras panel kanan diperbaiki.
|
||||
- [UI: admin-login-portal](/home/wira/work/codex/qris-soundbox-platform/ui/admin-login-portal/index.html)
|
||||
- Baseline portal login ikut diselaraskan untuk username dan kontras.
|
||||
|
||||
## Keputusan penting yang harus diikuti saat lanjut
|
||||
1. Fase 1 Step 1–4 harus tetap jalan berurutan sebelum pengembangan Fase 2.
|
||||
@ -30,21 +59,25 @@
|
||||
3. Jalankan smoke dari kondisi bersih (`smoke:cleanup`) untuk hasil yang konsisten.
|
||||
4. Untuk sementara, pencairan dana mengikuti pola rekening merchant sendiri (sesuai permintaan terakhir), bukan rekening terpusat.
|
||||
5. Pertahankan format error API yang konsisten: `code`, `message`, `details`, `request_id`, `timestamp`.
|
||||
6. Ledger Fase 1 masih placeholder `gross_income`; jangan perluas fee/payable sebelum Fase 3 kecuali diminta eksplisit.
|
||||
7. Dynamic QR Fase 2 saat ini memakai mock QRIS payload lokal; integrasi partner sungguhan belum dipasang.
|
||||
8. MQTT Fase 2 saat ini memakai simulator HTTP + `mqtt_messages` outbox; broker sungguhan belum dipasang.
|
||||
9. Untuk cek UI lokal, gunakan `http://127.0.0.1:3100/ui/admin-login`; credential dev adalah username `admin`, password `admin`.
|
||||
|
||||
## Urutan kerja selanjutnya (disarankan)
|
||||
1. Backend/backend sanity lanjut dari titik terakhir:
|
||||
- Pastikan endpoint untuk sinkronisasi screen sudah stabil (terutama filter/search transaksi dan heartbeat/ events).
|
||||
- Lengkapi pemeriksaan 1–3 (dalam flow kamu, yaitu smoke point 1–3) yang belum dites manual via UI.
|
||||
2. Ambil data smoke yang sudah tercipta di e2e (`merchant`, `device`, `transaction`) lalu smoke-test:
|
||||
1. UI/manual sanity lanjut dari titik terakhir:
|
||||
- Merchant detail page
|
||||
- Merchant list/filter
|
||||
- Device technical detail
|
||||
- Device list + heartbeat view
|
||||
- Transaction history + outlet/terminal filter
|
||||
2. Jalankan lagi `npm run smoke:e2e` sebelum lanjut Fase 2 atau sebelum commit besar.
|
||||
3. Jika ada regresi, cek log server di `/tmp/qris-smoke-e2e-server.log`.
|
||||
4. Setelah UI flow stabil, lanjut fitur ops:
|
||||
- `A.6 Migration + Seed` (jika ada gap)
|
||||
- `B.1–B.3` + `C.1–C.3` + `D.1–D.4` untuk full DoD Fase 1.
|
||||
4. Lanjut Fase 2 berikutnya:
|
||||
- health score/filter heartbeat yang lebih akurat
|
||||
- adapter broker MQTT sungguhan dari `mqtt_messages` outbox
|
||||
- config drift/retry policy untuk device yang belum ACK
|
||||
5. Sebelum wiring UI baru, pastikan halaman tetap mengikuti desain `design/*/code.html` dan cek kontras teks pada panel transparan/overlay.
|
||||
|
||||
## Note kalau meneruskan sesi berikutnya
|
||||
- Kode dan screen yang sudah dimodifikasi tidak perlu diulang dari nol; lanjut dari state saat ini.
|
||||
|
||||
@ -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
|
||||
|
||||
13
README.md
13
README.md
@ -57,12 +57,21 @@ Dokumen ini dibuat supaya tim bisa langsung mulai:
|
||||
- `GET /admin/devices/{id}/commands`
|
||||
- `GET /admin/devices/{id}/commands/{commandId}`
|
||||
- `GET /admin/devices/{id}/notifications`
|
||||
- `GET /admin/devices/{id}/config`
|
||||
- `PATCH /admin/devices/{id}/config`
|
||||
- `GET /admin/devices/{id}/mqtt-messages`
|
||||
- `GET /admin/audit-logs`
|
||||
- `GET /admin/ledger-entries`
|
||||
- `GET /admin/transactions`
|
||||
- `GET /admin/transactions/{transactionId}`
|
||||
- `POST /admin/transactions`
|
||||
- `GET /admin/transactions/{transactionId}/events`
|
||||
- `POST /admin/transactions/{transactionId}/retry-notification`
|
||||
- `POST /admin/seed`
|
||||
- `POST /device/transactions/dynamic-qr`
|
||||
- `POST /device/mqtt/uplink/dynamic-qr/request`
|
||||
- `GET /device/config`
|
||||
- `POST /device/config/ack`
|
||||
|
||||
### Menjalankan lokal
|
||||
|
||||
@ -85,7 +94,7 @@ Cleanup hanya menarget entitas smoke (`Smoke Merchant`, `PR-`, `DEV-`) agar data
|
||||
PORT=3100 ADMIN_TOKEN=admin-dev-token DEVICE_TOKEN=device-dev-token INTEGRATION_WEBHOOK_SECRET=dev-callback-secret PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform npm run smoke:flow
|
||||
```
|
||||
|
||||
Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification.
|
||||
Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification, duplicate callback, invalid signature, audit log, ledger placeholder, skenario terminal tanpa binding, dynamic QR API-direct, dynamic QR MQTT, dan device config push/ack.
|
||||
|
||||
### Smoke test end-to-end (bootstrap + flow + cleanup)
|
||||
|
||||
@ -109,4 +118,4 @@ Perintah ini menjalankan:
|
||||
- `GET /ui` => katalog halaman UI dari seluruh `design/*`.
|
||||
- `GET /ui/:page` => buka halaman berdasarkan slug (contoh: `/ui/admin-login`, `/ui/admin-dashboard-overview`, `/ui/merchant-login`).
|
||||
|
||||
Langkah berikutnya sesuai handoff: lanjut ke Task Pack B.1 (transaction core), lalu C.1–C.3.
|
||||
Status lanjutan: Fase 1 core flow sudah tercakup smoke e2e. Fase 2 sudah aktif untuk capability resolver, dynamic QR API-direct, dynamic QR MQTT via outbox, dan device config push/ack.
|
||||
|
||||
20
dist/app.js
vendored
20
dist/app.js
vendored
@ -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
339
dist/routes/admin.js
vendored
@ -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
219
dist/routes/device.js
vendored
@ -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;
|
||||
|
||||
33
dist/routes/integrations.js
vendored
33
dist/routes/integrations.js
vendored
@ -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));
|
||||
|
||||
95
dist/shared/db/pool.js
vendored
95
dist/shared/db/pool.js
vendored
@ -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;
|
||||
`;
|
||||
|
||||
27
dist/shared/middleware/idempotency.js
vendored
27
dist/shared/middleware/idempotency.js
vendored
@ -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();
|
||||
};
|
||||
|
||||
26
dist/shared/services/deviceCapabilityResolver.js
vendored
Normal file
26
dist/shared/services/deviceCapabilityResolver.js
vendored
Normal 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")
|
||||
};
|
||||
}
|
||||
67
dist/shared/services/dynamicQrOrchestrator.js
vendored
Normal file
67
dist/shared/services/dynamicQrOrchestrator.js
vendored
Normal 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
|
||||
};
|
||||
}
|
||||
20
dist/shared/services/mqttPublisher.js
vendored
20
dist/shared/services/mqttPublisher.js
vendored
@ -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
84
dist/shared/store/auditLogStore.js
vendored
Normal 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
89
dist/shared/store/deviceConfigStore.js
vendored
Normal 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
74
dist/shared/store/ledgerStore.js
vendored
Normal 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 };
|
||||
}
|
||||
38
dist/shared/store/locationStore.js
vendored
38
dist/shared/store/locationStore.js
vendored
@ -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
74
dist/shared/store/mqttMessageStore.js
vendored
Normal 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 };
|
||||
}
|
||||
7
dist/shared/store/transactionStore.js
vendored
7
dist/shared/store/transactionStore.js
vendored
@ -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]);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}`);
|
||||
})();
|
||||
|
||||
21
src/app.ts
21
src/app.ts
@ -15,7 +15,26 @@ import fs from "node:fs";
|
||||
const app = express();
|
||||
startNotificationOrchestrator();
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginResourcePolicy: {
|
||||
policy: "cross-origin"
|
||||
},
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.tailwindcss.com"],
|
||||
scriptSrcAttr: ["'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
imgSrc: ["'self'", "data:", "https://lh3.googleusercontent.com", "https://*.googleusercontent.com"],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
app.use(express.json());
|
||||
app.use(morgan("dev"));
|
||||
app.use(requestContext);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
`;
|
||||
|
||||
@ -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;
|
||||
|
||||
45
src/shared/services/deviceCapabilityResolver.ts
Normal file
45
src/shared/services/deviceCapabilityResolver.ts
Normal 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")
|
||||
};
|
||||
}
|
||||
104
src/shared/services/dynamicQrOrchestrator.ts
Normal file
104
src/shared/services/dynamicQrOrchestrator.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
136
src/shared/store/auditLogStore.ts
Normal file
136
src/shared/store/auditLogStore.ts
Normal 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 };
|
||||
}
|
||||
140
src/shared/store/deviceConfigStore.ts
Normal file
140
src/shared/store/deviceConfigStore.ts
Normal 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 };
|
||||
}
|
||||
108
src/shared/store/ledgerStore.ts
Normal file
108
src/shared/store/ledgerStore.ts
Normal 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 };
|
||||
}
|
||||
120
src/shared/store/mqttMessageStore.ts
Normal file
120
src/shared/store/mqttMessageStore.ts
Normal 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 };
|
||||
}
|
||||
@ -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> {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user