From 648e77cee9648c34b010940d93610bec8007ae50 Mon Sep 17 00:00:00 2001 From: wirabasalamah Date: Fri, 29 May 2026 10:10:12 +0700 Subject: [PATCH] Production readiness hardening and ops tooling --- .env.example | 42 + 18-mqtt-broker-mosquitto-debian13.md | 325 ++++ CODEX_HANDOFF.md | 342 ++-- DECISIONS_LOG.md | 772 +++++++++ DEPLOYMENT_READINESS.md | 57 + EXPORT_STORAGE_READINESS.md | 55 + OPERATIONAL_RUNBOOK.md | 146 ++ PILOT_EXECUTION_CHECKLIST.md | 51 + README.md | 214 ++- migrations/001_current_schema_bootstrap.mts | 5 + migrations/002_export_jobs.sql | 16 + migrations/003_export_job_storage.sql | 6 + package-lock.json | 585 ++++++- package.json | 23 +- scripts/backup-production.mjs | 85 + scripts/check-mqtt-acl.mjs | 71 + scripts/check-production-env.mjs | 141 ++ scripts/create-admin-user.mjs | 138 ++ scripts/create-merchant-user.mjs | 143 ++ scripts/load-test.mjs | 413 +++++ scripts/migrate.mts | 112 ++ scripts/provision-mqtt-device.mjs | 108 ++ scripts/restore-drill-validate.mjs | 72 + scripts/restore-plan.mjs | 70 + scripts/run-staging-load-report.mjs | 32 + scripts/smoke-mqtt-acl.mjs | 128 ++ scripts/smoke-mqtt-real.mjs | 424 +++++ scripts/smoke.mjs | 356 ++++- scripts/ui-qa-check.mjs | 60 + src/app.ts | 84 +- src/config/env.ts | 44 + src/index.ts | 5 +- src/routes/admin.ts | 1013 +++++++++++- src/routes/device.ts | 26 +- src/routes/merchant.ts | 348 ++++ src/shared/db/pool.ts | 208 +++ src/shared/errors/index.ts | 1 + src/shared/middleware/auth.ts | 122 +- src/shared/middleware/errorMiddleware.ts | 13 + src/shared/middleware/rateLimit.ts | 66 + src/shared/middleware/requestLogging.ts | 34 + .../services/dynamicQrExpiryScheduler.ts | 73 + src/shared/services/exportJobWorker.ts | 277 ++++ src/shared/services/health.ts | 42 + src/shared/services/logger.ts | 76 + src/shared/services/mqttPublisher.ts | 136 ++ src/shared/services/mqttSubscriber.ts | 140 ++ src/shared/services/sessionToken.ts | 124 ++ src/shared/store/auditLogStore.ts | 8 +- src/shared/store/deviceStore.ts | 112 +- src/shared/store/exportJobStore.ts | 235 +++ src/shared/store/ledgerStore.ts | 99 +- src/shared/store/merchantStore.ts | 8 + src/shared/store/merchantUserStore.ts | 72 + src/shared/store/settlementStore.ts | 1417 +++++++++++++++++ src/shared/store/transactionStore.ts | 4 +- src/shared/store/userStore.ts | 107 ++ ui/admin-dashboard-overview/index.html | 728 ++++++--- ui/admin-reconciliation-management/index.html | 618 +++++-- ui/admin-system-audit-logs/index.html | 283 +++- ui/device-technical-detail/index.html | 229 ++- ui/merchant-detail-view/index.html | 16 +- ui/merchant-login/index.html | 31 +- ui/merchant-settlement-history/index.html | 232 ++- ui/settlement-batch-management/index.html | 717 +++++++-- ui/shared/admin-api.js | 159 +- ui/shared/merchant-api.js | 142 ++ ui/transaction-history-monitoring/index.html | 29 +- 68 files changed, 12222 insertions(+), 848 deletions(-) create mode 100644 18-mqtt-broker-mosquitto-debian13.md create mode 100644 DEPLOYMENT_READINESS.md create mode 100644 EXPORT_STORAGE_READINESS.md create mode 100644 OPERATIONAL_RUNBOOK.md create mode 100644 PILOT_EXECUTION_CHECKLIST.md create mode 100644 migrations/001_current_schema_bootstrap.mts create mode 100644 migrations/002_export_jobs.sql create mode 100644 migrations/003_export_job_storage.sql create mode 100644 scripts/backup-production.mjs create mode 100644 scripts/check-mqtt-acl.mjs create mode 100644 scripts/check-production-env.mjs create mode 100644 scripts/create-admin-user.mjs create mode 100644 scripts/create-merchant-user.mjs create mode 100644 scripts/load-test.mjs create mode 100644 scripts/migrate.mts create mode 100644 scripts/provision-mqtt-device.mjs create mode 100644 scripts/restore-drill-validate.mjs create mode 100644 scripts/restore-plan.mjs create mode 100644 scripts/run-staging-load-report.mjs create mode 100644 scripts/smoke-mqtt-acl.mjs create mode 100644 scripts/smoke-mqtt-real.mjs create mode 100644 scripts/ui-qa-check.mjs create mode 100644 src/routes/merchant.ts create mode 100644 src/shared/middleware/rateLimit.ts create mode 100644 src/shared/middleware/requestLogging.ts create mode 100644 src/shared/services/dynamicQrExpiryScheduler.ts create mode 100644 src/shared/services/exportJobWorker.ts create mode 100644 src/shared/services/health.ts create mode 100644 src/shared/services/logger.ts create mode 100644 src/shared/services/mqttSubscriber.ts create mode 100644 src/shared/services/sessionToken.ts create mode 100644 src/shared/store/exportJobStore.ts create mode 100644 src/shared/store/merchantUserStore.ts create mode 100644 src/shared/store/settlementStore.ts create mode 100644 src/shared/store/userStore.ts create mode 100644 ui/shared/merchant-api.js diff --git a/.env.example b/.env.example index 0b19fdd..3d26b00 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,54 @@ PORT=3000 +TRUST_PROXY=false +JSON_BODY_LIMIT=1mb +LOG_FORMAT=dev +LOG_LEVEL=info ADMIN_TOKEN=admin-dev-token +ADMIN_AUTH_ALLOW_LEGACY_TOKEN=true +ADMIN_DEV_LOGIN_ENABLED=true +ADMIN_SESSION_SECRET=change-me-long-random-admin-session-secret +ADMIN_SESSION_TTL_SECONDS=28800 +MERCHANT_TOKEN=merchant-dev-token +MERCHANT_PORTAL_PASSWORD=merchant +MERCHANT_AUTH_ALLOW_LEGACY_TOKEN=true +MERCHANT_DEV_LOGIN_ENABLED=true +MERCHANT_SESSION_SECRET=change-me-long-random-merchant-session-secret +MERCHANT_SESSION_TTL_SECONDS=28800 DEVICE_TOKEN=device-dev-token +DEVICE_AUTH_ALLOW_LEGACY_TOKEN=true TRACE_HEADER=x-request-id IDEMPOTENCY_TTL_MS=300000 INTEGRATION_WEBHOOK_SECRET=dev-callback-secret +MQTT_PUBLISH_MODE=simulator +MQTT_BROKER_URL=mqtts://mqtt.iptek.co:8883 +MQTT_USERNAME=qris-backend +MQTT_PASSWORD=change-me +MQTT_CLIENT_ID=qris-platform-backend +MQTT_CONNECT_TIMEOUT_MS=5000 +MQTT_SUBSCRIBE_ENABLED=false +MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/# MQTT_PUBLISH_FORCE_FAIL_ALL=false MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS= MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000 +DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true +DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS=60000 +DYNAMIC_QR_EXPIRY_SWEEP_LIMIT=100 +EXPORT_WORKER_ENABLED=true +EXPORT_WORKER_INTERVAL_MS=2000 +EXPORT_WORKER_BATCH_SIZE=2 +EXPORT_JOB_STALE_RUNNING_MS=900000 +EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS=5000 +EXPORT_STORAGE_DIR=./storage/exports +EXPORT_RETENTION_DAYS=7 +RATE_LIMIT_ENABLED=true +RATE_LIMIT_LOGIN_WINDOW_MS=60000 +RATE_LIMIT_LOGIN_MAX=20 +RATE_LIMIT_DEVICE_WINDOW_MS=60000 +RATE_LIMIT_DEVICE_MAX=600 +RATE_LIMIT_ADMIN_WRITE_WINDOW_MS=60000 +RATE_LIMIT_ADMIN_WRITE_MAX=300 +FINANCE_PLATFORM_FEE_BPS=70 +SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL=false # PostgreSQL settings PGHOST=127.0.0.1 diff --git a/18-mqtt-broker-mosquitto-debian13.md b/18-mqtt-broker-mosquitto-debian13.md new file mode 100644 index 0000000..fa69008 --- /dev/null +++ b/18-mqtt-broker-mosquitto-debian13.md @@ -0,0 +1,325 @@ +# MQTT Broker Mosquitto on Debian 13 + +Panduan operasional untuk menyiapkan broker MQTT awal platform QRIS Soundbox di Debian 13 dengan subdomain `mqtt.iptek.co`. + +Keputusan arsitektur terkait: +- `D-026`: broker MQTT sungguhan ditunda sampai infrastruktur siap; simulator/outbox tetap dipakai selama transisi. +- `D-027`: broker awal memakai Mosquitto, dengan kontrak topic dan adapter backend tetap migration-ready ke EMQX/managed MQTT. + +## Target Setup + +- Broker: Eclipse Mosquitto. +- Domain: `mqtt.iptek.co`. +- MQTT TLS publik: `8883/tcp`. +- MQTT local-only: `1883/tcp` pada `127.0.0.1`. +- TLS: Let's Encrypt. +- Auth: username/password. +- Authorization: ACL topic per user/device. +- Anonymous access: disabled. + +## DNS dan Paket + +Pastikan DNS `mqtt.iptek.co` sudah mengarah ke public IP server. + +```bash +dig +short mqtt.iptek.co +curl -4 ifconfig.me +``` + +Install paket: + +```bash +sudo apt update +sudo apt install -y mosquitto mosquitto-clients certbot ufw +sudo systemctl enable --now mosquitto +``` + +## Firewall + +```bash +sudo ufw default deny incoming +sudo ufw default allow outgoing + +sudo ufw allow OpenSSH +sudo ufw allow 80/tcp +sudo ufw allow 8883/tcp + +sudo ufw enable +sudo ufw status verbose +``` + +Jangan buka `1883/tcp` ke internet. Listener `1883` hanya untuk localhost/internal test. + +## Sertifikat TLS + +Ambil sertifikat Let's Encrypt: + +```bash +sudo certbot certonly --standalone -d mqtt.iptek.co +``` + +Copy sertifikat ke lokasi yang bisa dibaca Mosquitto: + +```bash +sudo install -d -o root -g mosquitto -m 750 /etc/mosquitto/certs + +sudo install -o root -g mosquitto -m 640 \ + /etc/letsencrypt/live/mqtt.iptek.co/fullchain.pem \ + /etc/mosquitto/certs/fullchain.pem + +sudo install -o root -g mosquitto -m 640 \ + /etc/letsencrypt/live/mqtt.iptek.co/privkey.pem \ + /etc/mosquitto/certs/privkey.pem +``` + +Buat renewal hook: + +```bash +sudo nano /etc/letsencrypt/renewal-hooks/deploy/mosquitto-cert-copy.sh +``` + +Isi: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +DOMAIN="mqtt.iptek.co" + +install -o root -g mosquitto -m 640 \ + "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" \ + /etc/mosquitto/certs/fullchain.pem + +install -o root -g mosquitto -m 640 \ + "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" \ + /etc/mosquitto/certs/privkey.pem + +systemctl reload mosquitto +``` + +Aktifkan: + +```bash +sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mosquitto-cert-copy.sh +``` + +## User dan Password + +Buat user backend dan device test: + +```bash +sudo mosquitto_passwd -c /etc/mosquitto/passwd qris-backend +sudo mosquitto_passwd /etc/mosquitto/passwd DEVICE_UUID_FROM_PLATFORM +sudo chown root:mosquitto /etc/mosquitto/passwd +sudo chmod 640 /etc/mosquitto/passwd +``` + +Rekomendasi: +- `qris-backend` dipakai backend platform. +- Username device memakai `device_id` UUID dari platform, supaya ACL `pattern` bisa mengikat topic `devices/{deviceId}/...` ke user/device. + +## ACL Topic + +Buat file ACL: + +```bash +sudo nano /etc/mosquitto/acl +``` + +Isi awal: + +```conf +user qris-backend +topic readwrite devices/# + +pattern write devices/%u/uplink/# +pattern read devices/%u/downlink/# +pattern write devices/%u/heartbeat +``` + +Permission: + +```bash +sudo chown root:mosquitto /etc/mosquitto/acl +sudo chmod 640 /etc/mosquitto/acl +``` + +## Konfigurasi Mosquitto + +Buat file: + +```bash +sudo nano /etc/mosquitto/conf.d/qris.conf +``` + +Isi minimal: + +```conf +per_listener_settings true + +listener 8883 0.0.0.0 +protocol mqtt +allow_anonymous false +password_file /etc/mosquitto/passwd +acl_file /etc/mosquitto/acl +certfile /etc/mosquitto/certs/fullchain.pem +keyfile /etc/mosquitto/certs/privkey.pem + +listener 1883 127.0.0.1 +protocol mqtt +allow_anonymous false +password_file /etc/mosquitto/passwd +acl_file /etc/mosquitto/acl +``` + +Catatan Debian: +- Jangan set ulang `persistence`, `persistence_location`, `log_dest`, atau `log_type` di `conf.d/qris.conf` jika sudah ada di `/etc/mosquitto/mosquitto.conf`. +- Jika muncul error `Duplicate persistence_location value`, hapus `persistence` dan `persistence_location` dari `qris.conf`. +- Jika muncul error `Duplicate "log_dest file" value`, hapus blok logging dari `qris.conf`. + +Test config: + +```bash +sudo mosquitto -c /etc/mosquitto/mosquitto.conf -v +``` + +Jika tidak ada error, tekan `Ctrl+C`, lalu restart service: + +```bash +sudo systemctl restart mosquitto +sudo systemctl status mosquitto --no-pager +``` + +Pastikan listener terbuka: + +```bash +sudo ss -lntp | grep mosquitto +``` + +Expected: +- `0.0.0.0:8883` +- `127.0.0.1:1883` + +## Test Publish Subscribe + +Terminal 1, subscribe sebagai backend: + +```bash +mosquitto_sub \ + -h mqtt.iptek.co \ + -p 8883 \ + -u qris-backend \ + -P 'PASSWORD_BACKEND' \ + -t 'devices/DEVICE_UUID_FROM_PLATFORM/uplink/#' \ + -v +``` + +Terminal 2, publish sebagai device: + +```bash +mosquitto_pub \ + -h mqtt.iptek.co \ + -p 8883 \ + -u DEVICE_UUID_FROM_PLATFORM \ + -P 'PASSWORD_DEVICE' \ + -t 'devices/DEVICE_UUID_FROM_PLATFORM/uplink/dynamic-qr/request' \ + -m '{"request_id":"test-001","amount":10000}' +``` + +Test ACL negatif: + +```bash +mosquitto_pub \ + -h mqtt.iptek.co \ + -p 8883 \ + -u DEVICE_UUID_FROM_PLATFORM \ + -P 'PASSWORD_DEVICE' \ + -t 'devices/OTHER_DEVICE_UUID/uplink/dynamic-qr/request' \ + -m '{}' +``` + +Pesan ke topic device lain harus ditolak atau tidak sampai ke subscriber. + +## Monitoring + +```bash +sudo journalctl -u mosquitto -f +sudo tail -f /var/log/mosquitto/mosquitto.log +sudo ss -lntp | grep mosquitto +``` + +Test renewal TLS: + +```bash +sudo certbot renew --dry-run +``` + +## Environment Backend Nanti + +Saat adapter broker sungguhan dipasang ke platform: + +```env +MQTT_PUBLISH_MODE=broker +MQTT_BROKER_URL=mqtts://mqtt.iptek.co:8883 +MQTT_USERNAME=qris-backend +MQTT_PASSWORD=... +MQTT_CLIENT_ID=qris-platform-backend +MQTT_CONNECT_TIMEOUT_MS=5000 +MQTT_TLS=true +``` + +Topic kontrak yang harus dipertahankan: + +```text +devices/{deviceId}/uplink/dynamic-qr/request +devices/{deviceId}/downlink/dynamic-qr/response +devices/{deviceId}/downlink/payment/success +devices/{deviceId}/downlink/config/push +devices/{deviceId}/uplink/config/ack +devices/{deviceId}/heartbeat +``` + +## Provisioning Credential Device + +Credential MQTT device dibuat dari aplikasi, lalu password satu kali tersebut dimasukkan ke Mosquitto. Jalankan backend platform lebih dulu, lalu rotate credential: + +```bash +ADMIN_TOKEN=admin-dev-token \ +npm run mqtt:provision-device -- \ + --base-url http://127.0.0.1:3000 \ + --device-id DEVICE_UUID_FROM_PLATFORM +``` + +Output berisi: +- `mqtt_username`: sama dengan `device_id`. +- `mqtt_password`: secret satu kali untuk dimasukkan ke broker dan device. +- `mosquitto_commands`: perintah yang bisa dijalankan di server broker. + +Jika script dijalankan langsung di host broker: + +```bash +ADMIN_TOKEN=admin-dev-token \ +npm run mqtt:provision-device -- \ + --base-url http://127.0.0.1:3000 \ + --device-id DEVICE_UUID_FROM_PLATFORM \ + --apply-local +``` + +Setelah password dimasukkan, reload Mosquitto: + +```bash +sudo systemctl reload mosquitto +``` + +Catatan penting: +- Platform hanya menyimpan fingerprint secret, bukan plaintext password. +- Jika password hilang, lakukan rotate ulang dan update device + broker. +- ACL `pattern` bergantung pada username yang sama dengan `device_id`; jangan mengganti username device menjadi `device_code` kecuali kontrak topic/ACL ikut diubah. + +## Migration Ready Notes + +- Jangan mengunci business logic di fitur Mosquitto-specific. +- Credential device tetap dikelola domain platform. +- Topic tetap `devices/{deviceId}/...`. +- `mqtt_messages` tetap menjadi outbox/trace awal di aplikasi. +- Benchmark sebelum milestone 20k, 50k, dan 100k connected device. diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md index bc4743e..93a9bfe 100644 --- a/CODEX_HANDOFF.md +++ b/CODEX_HANDOFF.md @@ -1,103 +1,251 @@ -# CODEx Handoff — QRIS Soundbox Platform +# Codex Handoff - QRIS Soundbox Platform -## Current status -- 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 - - lanjutan Fase 2 berikutnya: config drift status + retry push config + MQTT trace untuk config ACK - - health summary device Fase 2 untuk admin list/detail: status, score, age_seconds, reasons - - UI ops Fase 2 di device registry/detail: health score/reasons, config drift, retry config push - - dynamic QR expiry sweep via `POST /admin/transactions/expire-due` - - smoke e2e mencakup duplicate callback, invalid signature, ledger, audit, terminal tanpa binding, dynamic QR API-direct, dynamic QR expiry sweep, dynamic QR MQTT, dan device config push/status/retry/ack - - 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` jalan jika server aktif di `localhost:3100` - - `smoke:e2e` ✅ setelah server auto-start di port 3100 (cleanup + full flow berhasil). +Tanggal update: 2026-05-29, Asia/Jakarta. -## Files baru/terbaru yang sudah dibuat -- [UI: admin-system-dashboard](/home/wira/work/codex/qris-soundbox-platform/ui/admin-system-dashboard/index.html) - - Dashboard API wiring dipastikan terhubung ke backend untuk token admin, endpoint summary, dan retry/realtime UI. -- [UI: merchant-onboarding-flow](/home/wira/work/codex/qris-soundbox-platform/ui/merchant-onboarding-flow/index.html) - - Form onboarding disinkron ke API (create merchant, outlet, terminal, device, binding) + status badge flow. -- [UI: merchant-detail-view](/home/wira/work/codex/qris-soundbox-platform/ui/merchant-detail-view/index.html) - - Detail merchant kini ambil data API untuk merchant/outlet/transactions list. -- [UI: device-technical-detail](/home/wira/work/codex/qris-soundbox-platform/ui/device-technical-detail/index.html) - - Device detail sinkronisasi data API: detail device, binding terbaru, heartbeats, events, metrics device, dan stream log. -- [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 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 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: dynamicQrExpiry](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/dynamicQrExpiry.ts) - - Sweep transaksi dynamic QR `awaiting_payment` yang sudah melewati `expired_at`. -- [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. -- [Backend: deviceConfigStatus](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/deviceConfigStatus.ts) - - Derivasi status drift config: `applied`, `pending_ack`, `failed_ack`, `stale_ack`, `never_pushed`. -- [UI: device-registry-monitoring](/home/wira/work/codex/qris-soundbox-platform/ui/device-registry-monitoring/index.html) - - Drawer ops menampilkan health score/reasons, config drift, latest push/ACK, dan retry config push. -- [UI: device-technical-detail](/home/wira/work/codex/qris-soundbox-platform/ui/device-technical-detail/index.html) - - Detail device menampilkan health summary dan config delivery panel dengan retry push. -- [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. +Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat. -## Keputusan penting yang harus diikuti saat lanjut -1. Fase 1 Step 1–4 harus tetap jalan berurutan sebelum pengembangan Fase 2. -2. Backend target Postgres di local (`qris_soundbox_platform`) sudah dipakai di smoke test. -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. Config retry Fase 2 mengirim ulang config version yang sama; jangan naikkan versi kecuali settings berubah. -10. Dynamic QR expiry sweep saat ini endpoint admin/manual; bisa dinaikkan menjadi scheduler/background worker. -11. Untuk cek UI lokal, gunakan `http://127.0.0.1:3100/ui/admin-login`; credential dev adalah username `admin`, password `admin`. +## Status Terakhir -## Urutan kerja selanjutnya (disarankan) -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. Lanjut Fase 2 berikutnya: - - adapter broker MQTT sungguhan dari `mqtt_messages` outbox - - scheduler otomatis untuk dynamic QR expiry sweep - - filter/sorting UI berbasis `health_summary.score` dan `health_summary.reasons` - - manual visual QA device registry/detail untuk layout mobile dan drawer -5. Sebelum wiring UI baru, pastikan halaman tetap mengikuti desain `design/*/code.html` dan cek kontras teks pada panel transparan/overlay. +- Estimasi MVP / early pilot: 92-94%. +- Estimasi production-ready penuh: 82-85%. +- Platform sudah bukan prototype docs-only. Backend, UI operasional, migration, smoke test, rate limiting, audit logging, async export, runbook, dan script deployment sudah tersedia. +- Fokus terakhir yang selesai: rate limiting + security polish, login audit, admin audit UI real-data, placeholder nav cleanup, dan runbook/checklist produksi. +- Worktree kemungkinan masih dirty karena banyak perubahan aktif. Jangan revert perubahan yang tidak eksplisit diminta. -## Note kalau meneruskan sesi berikutnya -- Kode dan screen yang sudah dimodifikasi tidak perlu diulang dari nol; lanjut dari state saat ini. -- Prioritas saat lanjut: verifikasi “jalur UI sinkron API” lalu lanjutkan smoke flow end-to-end berkala. -- Gunakan [DECISIONS_LOG.md] sebagai rujukan wajib untuk keputusan yang sudah disepakati. +## Verifikasi Terakhir -## Selesai untuk off -- Sudah ada gabungan perubahan di repo: doc + UI + API integration + smoke validation. +- `npm run typecheck`: pass. +- `npm run db:migrate`: pass dan idempotent sampai migration `003_export_job_storage.sql`. +- `npm audit --json`: pass, 0 vulnerability. +- `npm run ui:qa`: pass setelah cleanup placeholder navigation. +- `npm run smoke:e2e`: pass setelah rate limiting dan login audit. +- Quick rate limit test: pass. Login admin salah pertama menghasilkan `401` dengan `RateLimit-Remaining: 0`, request berikutnya menghasilkan `429 RATE_LIMITED`. +- Quick login audit test: pass. Event `admin.login.success`, `admin.login.failed`, `merchant.login.success`, dan `merchant.login.failed` tercatat. +- Quick audit UI API test: pass. `GET /admin/audit-logs?action_contains=.login.&limit=10` mengembalikan event login. +- Production-like env check dummy: pass via `npm run deploy:check-env`, hanya warning opsional untuk `MQTT_SUBSCRIBE`. +- Staging/load/MQTT real sebelumnya sudah pernah diverifikasi: load level 2 1610 requests 0 errors, MQTT broker `mqtts://mqtt.iptek.co:8883` publish/subscribe OK. + +## Implementasi Selesai + +### 1. Auth, RBAC, dan Security + +- Admin session login tersedia lewat `/admin/login`, `/admin/logout`, `/admin/me`. +- Merchant session login tersedia lewat `/merchant/login`, `/merchant/logout`, `/merchant/me`. +- Legacy dev auth bisa dimatikan via env dan production check memblokir konfigurasi yang tidak aman. +- Admin dan merchant bootstrap script tersedia: + - `scripts/create-admin-user.mjs` + - `scripts/create-merchant-user.mjs` +- Password policy bootstrap diperketat: + - minimal 14 karakter; + - wajib lowercase, uppercase, angka, dan simbol; + - menolak kata mudah ditebak seperti product/default/password/admin/merchant/qris/soundbox. +- Rate limiting middleware baru: + - [src/shared/middleware/rateLimit.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/middleware/rateLimit.ts) + - dipasang ke `/admin/login`, `/merchant/login`, admin write routes, `/device`, dan `/integrations`. +- Env security baru: + - `TRUST_PROXY` + - `JSON_BODY_LIMIT` + - `RATE_LIMIT_ENABLED` + - `RATE_LIMIT_AUTH_WINDOW_MS` + - `RATE_LIMIT_AUTH_MAX` + - `RATE_LIMIT_ADMIN_WRITE_WINDOW_MS` + - `RATE_LIMIT_ADMIN_WRITE_MAX` + - `RATE_LIMIT_WRITE_WINDOW_MS` + - `RATE_LIMIT_WRITE_MAX` +- Error code baru `RATE_LIMITED` di [src/shared/errors/index.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/errors/index.ts). + +### 2. Audit, Monitoring, dan Logging + +- Audit logging login admin: + - `admin.login.success` + - `admin.login.failed` +- Audit logging login merchant: + - `merchant.login.success` + - `merchant.login.failed` +- `auditLogStore` mendukung `actor_type: merchant`. +- Filter audit baru `action_contains` tersedia di: + - [src/shared/store/auditLogStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/auditLogStore.ts) + - [src/routes/admin.ts](/home/wira/work/codex/qris-soundbox-platform/src/routes/admin.ts) +- Admin audit UI sudah memakai real API, bukan mock: + - [ui/admin-system-audit-logs/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/admin-system-audit-logs/index.html) +- Audit UI memiliki filter action/entity/date/search, preset login events, KPI count, dan drawer JSON detail. +- Observability/health sebelumnya sudah tersedia: + - `/health` + - `/health/ready` + - `/admin/observability/summary` + - `/admin/observability/dead-letter-replays` + - `/admin/observability/mqtt-status` + +### 3. MQTT dan Device Operations + +- MQTT worker dan policy production sudah diperketat. +- Wildcard subscribe default production dicegah oleh env check. +- MQTT ACL tooling tersedia: + - `scripts/check-mqtt-acl.mjs` + - `scripts/smoke-mqtt-acl.mjs` + - `scripts/provision-mqtt-device.mjs` +- Package scripts: + - `npm run mqtt:provision-device` + - `npm run mqtt:check-acl` + - `npm run smoke:mqtt-acl` + - `npm run smoke:mqtt-real` +- Real MQTT smoke pernah pass dengan broker `mqtts://mqtt.iptek.co:8883`. + +### 4. Settlement, Reconciliation, dan Finance Ops + +- Settlement batch, merchant settlement history, reconciliation management, adjustment approval, dan device technical detail UI sudah tersedia. +- Admin reconciliation UI sudah menggunakan async export flow dan export history. +- Key UI pages: + - [ui/admin-reconciliation-management/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/admin-reconciliation-management/index.html) + - [ui/settlement-batch-management/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/settlement-batch-management/index.html) + - [ui/merchant-settlement-history/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/merchant-settlement-history/index.html) + - [ui/device-technical-detail/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/device-technical-detail/index.html) +- Placeholder `href="#"` sudah dibersihkan dari UI yang masuk QA. + +### 5. Async Export dan Storage + +- Async export job sudah tersedia untuk settlement adjustment export. +- Export job worker: + - [src/shared/services/exportJobWorker.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/exportJobWorker.ts) +- Export job store: + - [src/shared/store/exportJobStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/exportJobStore.ts) +- Migration: + - [migrations/002_export_jobs.sql](/home/wira/work/codex/qris-soundbox-platform/migrations/002_export_jobs.sql) + - [migrations/003_export_job_storage.sql](/home/wira/work/codex/qris-soundbox-platform/migrations/003_export_job_storage.sql) +- Export file storage memakai `EXPORT_STORAGE_DIR`, dengan metadata path/size/expiry. +- Export retention cleanup tersedia via worker. +- Admin endpoints: + - `POST /admin/exports/settlement-adjustments` + - `GET /admin/exports` + - `GET /admin/exports/:jobId` + - `GET /admin/exports/:jobId/download` + +### 6. Deployment, Backup, Restore, dan Runbook + +- Production env checker diperketat: + - [scripts/check-production-env.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/check-production-env.mjs) +- Backup/restore tooling: + - [scripts/backup-production.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/backup-production.mjs) + - [scripts/restore-plan.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/restore-plan.mjs) + - [scripts/restore-drill-validate.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/restore-drill-validate.mjs) +- Load testing/report tooling: + - [scripts/load-test.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/load-test.mjs) + - [scripts/run-staging-load-report.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/run-staging-load-report.mjs) +- Operational docs baru: + - [OPERATIONAL_RUNBOOK.md](/home/wira/work/codex/qris-soundbox-platform/OPERATIONAL_RUNBOOK.md) + - [PILOT_EXECUTION_CHECKLIST.md](/home/wira/work/codex/qris-soundbox-platform/PILOT_EXECUTION_CHECKLIST.md) + - [EXPORT_STORAGE_READINESS.md](/home/wira/work/codex/qris-soundbox-platform/EXPORT_STORAGE_READINESS.md) +- README dan deployment readiness docs sudah direferensikan ke runbook/checklist tersebut. + +## Endpoint Penting + +- Health: + - `GET /health` + - `GET /health/ready` +- Admin auth/session: + - `POST /admin/login` + - `POST /admin/logout` + - `GET /admin/me` +- Admin audit/observability: + - `GET /admin/audit-logs` + - `GET /admin/observability/summary` + - `GET /admin/observability/dead-letter-replays` + - `GET /admin/observability/mqtt-status` +- Admin export: + - `POST /admin/exports/settlement-adjustments` + - `GET /admin/exports` + - `GET /admin/exports/:jobId` + - `GET /admin/exports/:jobId/download` +- Merchant auth/session: + - `POST /merchant/login` + - `POST /merchant/logout` + - `GET /merchant/me` +- Device and integration routes remain rate-limited for write-heavy paths: + - `/device` + - `/integrations` + +## Package Scripts Penting + +- `npm run typecheck` +- `npm run db:migrate` +- `npm run smoke:e2e` +- `npm run ui:qa` +- `npm run deploy:check-env` +- `npm run load:test` +- `npm run load:test:staging` +- `npm run backup:production` +- `npm run restore:plan` +- `npm run restore:validate` +- `npm run admin:create-user` +- `npm run merchant:create-user` +- `npm run mqtt:provision-device` +- `npm run mqtt:check-acl` +- `npm run smoke:mqtt-acl` +- `npm run smoke:mqtt-real` + +## File Kunci yang Sering Disentuh + +- App bootstrap: [src/app.ts](/home/wira/work/codex/qris-soundbox-platform/src/app.ts) +- Env config: [src/config/env.ts](/home/wira/work/codex/qris-soundbox-platform/src/config/env.ts) +- Admin routes: [src/routes/admin.ts](/home/wira/work/codex/qris-soundbox-platform/src/routes/admin.ts) +- Merchant routes: [src/routes/merchant.ts](/home/wira/work/codex/qris-soundbox-platform/src/routes/merchant.ts) +- Audit store: [src/shared/store/auditLogStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/auditLogStore.ts) +- Export worker: [src/shared/services/exportJobWorker.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/exportJobWorker.ts) +- Export store: [src/shared/store/exportJobStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/exportJobStore.ts) +- Rate limit middleware: [src/shared/middleware/rateLimit.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/middleware/rateLimit.ts) +- UI QA script: [scripts/ui-qa-check.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/ui-qa-check.mjs) +- Admin audit UI: [ui/admin-system-audit-logs/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/admin-system-audit-logs/index.html) +- Env sample: [.env.example](/home/wira/work/codex/qris-soundbox-platform/.env.example) + +## Decision Log Ringkas + +- D-026 sampai D-049: dasar auth, merchant/admin flows, migration, UI awal, dan smoke testing. +- D-050 sampai D-059: production hardening awal, MQTT policy, finance/reconciliation UI, settlement flows. +- D-060 sampai D-069: merchant auth productionization, DB migration idempotent, monitoring/logging, load test, async export. +- D-070 sampai D-074: export storage/history, MQTT ACL, backup/restore, staging load report. +- D-075 sampai D-080: rate limiting/security polish, login audit, audit UI real data, UI QA cleanup, runbook/checklist produksi. + +Rujukan utama: [DECISIONS_LOG.md](/home/wira/work/codex/qris-soundbox-platform/DECISIONS_LOG.md). + +## Sisa Gap Utama + +1. Eksekusi staging nyata dari checklist: + - deploy dengan env final; + - jalankan `deploy:check-env`, migration, smoke, UI QA, load report; + - simpan artefak hasil staging. +2. Pilot real device: + - provisioning device real; + - validasi MQTT ACL per device; + - transaksi QRIS test end-to-end; + - validasi soundbox delivery dan dead-letter handling. +3. Restore drill nyata: + - backup production/staging; + - restore ke database disposable; + - jalankan `restore:validate`; + - dokumentasikan RTO/RPO aktual. +4. Export storage production topology: + - pastikan `EXPORT_STORAGE_DIR` durable, absolute, writable, dan di-backup; + - jika multi-node, perlu shared filesystem/object storage strategy. +5. Manual visual QA: + - buka halaman admin utama di browser; + - cek layout mobile/desktop; + - cek login/session expiry state; + - cek empty/error/loading state. +6. Operational readiness: + - isi PIC, escalation contact, broker credential, backup location, dan pilot merchant list di runbook/checklist. + +## Prioritas Lanjutan Disarankan + +1. Jalankan full staging rehearsal dari `PILOT_EXECUTION_CHECKLIST.md`. +2. Lakukan manual visual QA admin UI dengan browser. +3. Jalankan restore drill sungguhan pada database disposable. +4. Finalisasi export storage production strategy. +5. Siapkan pilot real merchant/device dan rekam hasilnya di runbook. + +## Catatan Penting + +- Jangan hidupkan legacy auth di production. +- Jangan gunakan wildcard MQTT subscribe di production kecuali sedang maintenance terkontrol. +- `EXPORT_STORAGE_DIR` harus absolute path dan durable untuk production. +- Rate limiting sekarang aktif secara default jika `RATE_LIMIT_ENABLED=true`; hati-hati saat smoke test berulang pada login endpoint. +- `CODEX_HANDOFF.md` ini adalah snapshot operasional terbaru; untuk detail historis keputusan, baca `DECISIONS_LOG.md`. diff --git a/DECISIONS_LOG.md b/DECISIONS_LOG.md index a47e755..83b9102 100644 --- a/DECISIONS_LOG.md +++ b/DECISIONS_LOG.md @@ -288,3 +288,775 @@ Log keputusan arsitektur dan implementasi yang harus dijadikan acuan eksekusi. - Callback paid yang datang setelah transaksi sudah `expired` tetap ditolak oleh state transition guard. - Endpoint admin ini bisa menjadi dasar scheduler/background worker saat fase operasional berikutnya. - Status: Active + +## D-026 — Penundaan Broker MQTT Sungguhan +- Tanggal: 2026-05-28 +- Keputusan: + - Integrasi broker MQTT sungguhan ditunda sampai infrastruktur broker, credential, network access, dan topic policy siap. + - Selama broker belum siap, Fase 2 tetap memakai HTTP simulator dan `mqtt_messages` sebagai outbox/trace untuk dynamic QR MQTT, config push, dan config ACK. + - Prioritas lanjutan sebelum broker siap adalah hardening UI ops, scheduler dynamic QR expiry, dan observability outbox. +- Alasan: + - Broker MQTT membutuhkan persiapan eksternal di luar kode aplikasi agar implementasi adapter tidak menebak-nebak detail koneksi dan policy topic. + - Simulator/outbox saat ini sudah cukup untuk menguji kontrak payload, idempotency, correlation ID, dan visibilitas admin. +- Dampak / implikasi: + - Tidak menambahkan dependency broker/client MQTT sungguhan sampai detail infrastruktur tersedia. + - Adapter broker nanti harus memakai `mqtt_messages` sebagai sumber trace/outbox awal, bukan mengganti kontrak Fase 2 yang sudah berjalan. + - Pekerjaan berikutnya dapat lanjut ke scheduler expiry dan peningkatan UI/filter ops tanpa blocking pada broker. +- Status: Active + +## D-027 — Broker MQTT Awal Menggunakan Mosquitto +- Tanggal: 2026-05-28 +- Keputusan: + - Broker MQTT sungguhan pertama akan memakai Mosquitto karena ringan, sederhana dioperasikan, dan cukup untuk kebutuhan MVP/early production. + - Migrasi ke EMQX atau managed MQTT tetap dibuka jika skala, high availability, dashboard broker, rule engine, atau auth/authorization kompleks mulai dibutuhkan. + - Kontrak topic, credential model, dan adapter backend harus dibuat broker-agnostic agar migrasi tidak mengubah flow device. +- Alasan: + - Kebutuhan saat ini masih single broker dengan TLS, username/password, ACL per device, dan publish/subscribe dasar. + - Mosquitto memberi jalur deploy paling cepat sambil menjaga biaya dan kompleksitas operasional rendah. + - Target skala besar seperti 100.000 device masih memungkinkan dievaluasi lewat benchmark bertahap sebelum memutuskan migrasi. +- Dampak / implikasi: + - Hindari penggunaan fitur broker-spesifik yang sulit dipindahkan. + - Device topic tetap mengikuti kontrak `devices/{deviceId}/...`. + - Credential device tetap dikelola sebagai bagian dari domain platform agar tidak terkunci pada format internal broker tertentu. + - Benchmark kapasitas wajib dilakukan sebelum milestone skala besar, misalnya 20k, 50k, dan 100k connected device. +- Status: Active + +## D-028 — Finance Light dan Credential Device Operasional +- Tanggal: 2026-05-28 +- Keputusan: + - Ledger Fase 1 dinaikkan dari placeholder gross-only menjadi finance light: `gross_income`, `platform_fee`, dan `merchant_payable`. + - Platform fee awal memakai konfigurasi `FINANCE_PLATFORM_FEE_BPS` agar mudah diubah tanpa migrasi schema. + - Credential MQTT device dikelola di domain device melalui status credential, username MQTT, timestamp issue/rotate/revoke, dan fingerprint secret. + - Password credential hanya dikembalikan satu kali oleh endpoint rotate dan tidak disimpan sebagai plaintext. + - Username MQTT device harus sama dengan `device_id` karena ACL Mosquitto memakai pattern `devices/%u/...`. +- Alasan: + - Admin settlement dan reconciliation butuh angka net payable awal sebelum modul settlement penuh. + - Credential device perlu siap sebelum ACL/per-device broker MQTT dibuat agar provisioning tidak bergantung pada catatan manual. +- Dampak / implikasi: + - Callback paid membuat ledger idempotent per `transaction_id + entry_type`. + - Model fee saat ini masih global/default; fee profile merchant tetap bisa ditambahkan kemudian tanpa mengubah kontrak ledger entry. + - Endpoint `POST /admin/devices/{deviceId}/credentials/rotate` menjadi jalur provisioning credential MQTT awal. + - Script `npm run mqtt:provision-device` menjadi jalur ringan untuk rotate credential dan menghasilkan command `mosquitto_passwd`. + - UI device technical detail menyediakan rotate credential dan modal one-time secret untuk operator. + - ACL Mosquitto per device masih perlu disinkronkan dari credential platform saat tahap broker provisioning berikutnya. +- Status: Active + +## D-029 — Device Auth Per-Credential dengan Fallback Dev Token +- Tanggal: 2026-05-28 +- Keputusan: + - Endpoint `/device/*` menerima autentikasi per-device melalui header `X-Device-Id` dan `X-Device-Secret`. + - Secret device diverifikasi memakai fingerprint credential yang tersimpan di database; plaintext secret tidak disimpan. + - Credential device hanya boleh mengakses resource untuk `device_id` yang sama dengan `X-Device-Id`. + - `Authorization: Bearer DEVICE_TOKEN` tetap diterima sebagai fallback/dev compatibility selama transisi. +- Alasan: + - Credential MQTT yang sudah diprovision perlu menjadi identitas device yang sama untuk API platform. + - Token global device terlalu luas untuk production karena satu token bocor bisa mengakses semua device. + - Fallback dev token menjaga smoke test, simulator, dan tooling lama tetap berjalan saat migrasi bertahap. +- Dampak / implikasi: + - Device production sebaiknya mulai mengirim `X-Device-Id` dan `X-Device-Secret`. + - API menolak credential valid yang mencoba mengirim payload untuk device lain dengan `403 FORBIDDEN`. + - Production dapat set `DEVICE_AUTH_ALLOW_LEGACY_TOKEN=false` untuk mematikan fallback `DEVICE_TOKEN`. +- Status: Active + +## D-030 — Settlement Batch Light dari Merchant Payable +- Tanggal: 2026-05-28 +- Keputusan: + - Settlement awal dibuat sebagai batch dari ledger entry `merchant_payable` yang belum pernah masuk batch. + - Batch dikelompokkan per merchant dan menyimpan `gross_amount`, `platform_fee_amount`, `net_payable_amount`, `entry_count`, `cutoff_at`, dan status. + - Status awal batch adalah `created`; admin dapat menandai batch menjadi `paid`. + - Settlement light tidak memindahkan dana dan tidak mengelola rekening escrow; batch adalah rekonsiliasi/payable visibility. +- Alasan: + - Finance light sudah menghasilkan merchant payable, sehingga admin membutuhkan unit batch untuk proses settlement manual/merchant-direct. + - Model ini tetap cocok dengan keputusan payout merchant-direct, karena batch adalah daftar kewajiban bayar, bukan instruksi transfer otomatis. +- Dampak / implikasi: + - Ledger entry hanya boleh masuk satu batch lewat unique key `settlement_batch_entries.ledger_entry_id`. + - Batch berikutnya hanya mengambil payable yang belum dibatch. + - Endpoint baru: `POST /admin/settlement-batches`, `GET /admin/settlement-batches`, `GET /admin/settlement-batches/{batchId}`, `GET /admin/settlement-batches/{batchId}/export.csv`, dan `POST /admin/settlement-batches/{batchId}/mark-paid`. + - UI settlement batch management membaca batch real, menampilkan KPI, membuka detail drawer, generate batch, mark paid, dan download CSV payout report. + - Tahap berikutnya bisa menambahkan format bank-specific payout file jika dibutuhkan. +- Status: Active + +## D-031 — Dashboard Settlement / Finance Summary +- Tanggal: 2026-05-28 +- Keputusan: + - Admin dashboard overview menampilkan ringkasan settlement dan finance dari data batch real. + - Summary backend `/admin/dashboard/summary` memuat pending payout, paid payout, total platform fee, jumlah batch open/paid/total. + - Dashboard menampilkan panel "Settlement Finance" dengan batch terbaru dan tombol download CSV payout report per batch. +- Alasan: + - Setelah settlement batch light aktif, operator perlu melihat exposure payable dan fee tanpa harus masuk dulu ke halaman settlement detail. + - Summary di dashboard membantu sanity check finance harian sebelum payout manual/merchant-direct dijalankan. +- Dampak / implikasi: + - Angka dashboard saat ini bersumber dari maksimal 500 batch terbaru, cukup untuk fase awal dan smoke/manual ops ringan. + - Untuk volume besar, summary finance perlu dipindah ke query agregasi khusus atau materialized summary agar tidak bergantung pada limit list batch. +- Status: Active + +## D-032 — Settlement Mark Paid Membutuhkan Bukti Payout +- Tanggal: 2026-05-28 +- Keputusan: + - UI settlement tidak lagi menutup batch dengan satu klik langsung; operator harus mengisi `paid_at`, reference payout, dan note rekonsiliasi. + - Endpoint `POST /admin/settlement-batches/{batchId}/mark-paid` menerima `paid_reference` dan `paid_note`, lalu menyimpannya di `metadata_json`. + - Audit log tetap menyimpan before/after batch agar perubahan status dan bukti payout dapat ditelusuri. +- Alasan: + - Mark paid adalah aksi finansial operasional, sehingga perlu bukti transfer/reference sebelum status batch ditutup. + - Menyimpan bukti di metadata menjaga schema tetap ringan pada fase awal sambil mempertahankan traceability. +- Dampak / implikasi: + - UI mewajibkan reference payout, tetapi API masih kompatibel dengan payload lama untuk tooling internal. + - Jika workflow payout makin formal, field bukti payout dapat dipromosikan menjadi kolom dedicated atau tabel payout events. +- Status: Active + +## D-033 — Dashboard Finance Summary Menggunakan Agregasi DB +- Tanggal: 2026-05-28 +- Keputusan: + - Summary settlement/finance untuk admin dashboard dihitung lewat query agregasi langsung ke `settlement_batches`. + - Endpoint `/admin/dashboard/summary` tidak lagi menghitung total finance dari list batch yang dibatasi pagination. +- Alasan: + - Angka payable, paid payout, fee, dan jumlah batch harus mewakili seluruh data, bukan hanya batch terbaru. + - Jalur agregasi khusus lebih siap untuk volume data yang bertambah tanpa mengubah kontrak UI dashboard. +- Dampak / implikasi: + - UI dashboard tetap memakai kontrak field yang sama. + - Jika volume settlement makin besar, agregasi ini bisa ditingkatkan menjadi materialized summary tanpa mengubah respons API. +- Status: Active + +## D-034 — Merchant Portal Settlement View +- Tanggal: 2026-05-28 +- Keputusan: + - Merchant portal memiliki endpoint settlement merchant-scoped untuk profile, summary payout, daftar batch, detail batch, dan export CSV. + - Merchant login fase awal memakai token dev `MERCHANT_TOKEN` dan password portal sederhana `MERCHANT_PORTAL_PASSWORD`. + - UI `merchant-settlement-history` membaca data settlement real sesuai merchant yang login. +- Alasan: + - Settlement tidak hanya perlu visible untuk admin; merchant juga perlu transparansi pending payout, paid payout, detail batch, dan report. + - Merchant-scoped endpoint mencegah UI merchant memakai API admin atau melihat batch merchant lain. +- Dampak / implikasi: + - Auth merchant saat ini masih ringan untuk fase development; production perlu diganti ke user/session auth merchant yang sebenarnya. + - Kontrak endpoint sudah dipisah dari admin sehingga hardening auth bisa dilakukan tanpa mengubah UI settlement merchant secara besar. +- Status: Active + +## D-035 — Payout Event History +- Tanggal: 2026-05-28 +- Keputusan: + - Settlement batch memiliki event history formal di tabel `settlement_batch_events`. + - Event awal yang dicatat: `created`, `csv_exported`, dan `marked_paid`. + - Admin dan merchant detail settlement membaca event history yang sama. +- Alasan: + - Metadata dan audit log cukup untuk jejak internal, tetapi operator dan merchant membutuhkan timeline payout yang mudah dibaca. + - Event history menjadi fondasi untuk workflow berikutnya seperti failed/cancelled payout, reference update, dan dispute. +- Dampak / implikasi: + - Export CSV dari admin maupun merchant menambah event `csv_exported`. + - Event history tidak menggantikan audit log; audit log tetap dipakai untuk compliance internal admin. +- Status: Active + +## D-036 — Settlement Failed dan Cancel Workflow +- Tanggal: 2026-05-28 +- Keputusan: + - Batch settlement berstatus `created` dapat ditandai `failed` atau `cancelled` oleh admin dengan reason wajib. + - Endpoint baru: `POST /admin/settlement-batches/{batchId}/mark-failed` dan `POST /admin/settlement-batches/{batchId}/cancel`. + - Reason disimpan ke `failure_reason`, detail resolusi disimpan ke `metadata_json`, dan event `failed`/`cancelled` dicatat di payout event history. +- Alasan: + - Workflow payout operasional perlu menangani transfer gagal dan batch yang dibatalkan sebelum dibayar. + - Reason wajib menjaga keputusan finansial tetap dapat ditelusuri oleh operator dan merchant. +- Dampak / implikasi: + - Batch failed/cancelled menjadi final state, tetapi dapat direprocess lewat aturan D-037. +- Status: Active + +## D-037 — Reprocess Settlement Failed/Cancelled +- Tanggal: 2026-05-28 +- Keputusan: + - Batch settlement berstatus `failed` atau `cancelled` dapat direprocess menjadi batch baru berstatus `created`. + - Reprocess memindahkan `settlement_batch_entries` dari batch lama ke batch baru, karena ledger entry hanya boleh aktif pada satu batch. + - Batch lama tetap menjadi arsip final dan menyimpan `reprocessed_to_batch_id` di `metadata_json`. + - Endpoint baru: `POST /admin/settlement-batches/{batchId}/reprocess`. +- Alasan: + - Payable dari payout gagal/cancel perlu dapat diproses ulang tanpa membuat ledger entry duplikat. + - Memindahkan entry menjaga constraint unik `settlement_batch_entries.ledger_entry_id` sekaligus membuat batch baru siap mengikuti workflow normal. +- Dampak / implikasi: + - Detail report batch lama setelah reprocess tidak lagi menampilkan entry karena entry dipindah ke batch baru; angka aggregate batch lama tetap tersimpan sebagai arsip. + - Reprocess hanya boleh satu kali per batch lama untuk menghindari batch duplikat. + - Event history mencatat `reprocessed` pada batch lama dan `created` pada batch baru. +- Status: Active + +## D-038 — Settlement Reconciliation Mismatch Report +- Tanggal: 2026-05-28 +- Keputusan: + - Admin memiliki report reconciliation untuk membandingkan aggregate settlement batch dengan ledger dan transaksi aktual. + - Endpoint baru: `GET /admin/reconciliation/settlement-batches`. + - UI `admin-reconciliation-management` membaca data real dari endpoint tersebut untuk KPI matched, discrepancy, issue count, raw payload, dan tabel batch. + - Batch arsip yang sudah direprocess tidak dihitung sebagai mismatch palsu karena entry settlement-nya memang dipindah ke batch baru. +- Alasan: + - Operator finance butuh cara cepat melihat batch yang angka gross, fee, net payable, entry count, atau status transaksinya tidak sinkron. + - Report ini menjadi sanity layer sebelum workflow koreksi payout/reference yang lebih detail. +- Dampak / implikasi: + - Report masih internal-system reconciliation, belum mencocokkan bank statement eksternal. + - Smoke test memverifikasi endpoint report mengembalikan aggregate dan rows. +- Status: Active + +## D-039 — Settlement Payout Reference Update +- Tanggal: 2026-05-28 +- Keputusan: + - Admin dapat memperbarui `paid_reference` dan `paid_note` untuk settlement batch yang sudah berstatus `paid`. + - Endpoint baru: `PATCH /admin/settlement-batches/{batchId}/reference`. + - Update reference menyimpan nilai terbaru di `metadata_json`, mencatat event `reference_updated`, dan membuat audit log before/after. + - UI settlement batch management menampilkan form update reference hanya untuk batch `paid`. +- Alasan: + - Bukti payout manual bisa salah input atau perlu dilengkapi setelah rekonsiliasi. + - Perubahan bukti payout harus traceable tanpa membuka ulang status finansial batch. +- Dampak / implikasi: + - Reference update tidak mengubah status, nominal, entry, atau paid_at batch. + - Batch yang belum paid, failed, atau cancelled tidak dapat mengubah payout reference lewat endpoint ini. +- Status: Active + +## D-040 — Bank Generic Payout Export +- Tanggal: 2026-05-28 +- Keputusan: + - Endpoint export settlement mendukung format tambahan `bank_generic` lewat query `?format=bank_generic`. + - Format default `standard` tetap menjadi payout report detail per transaksi agar kompatibel dengan UI dan tooling yang sudah ada. + - Format `bank_generic` menghasilkan satu baris payout per batch berisi beneficiary account, account type, beneficiary name, amount, currency, remark, batch code, dan transaction count. + - Admin dan merchant export memakai pilihan format yang sama; UI admin settlement menyediakan pilihan General CSV atau Bank Upload. +- Alasan: + - Operasional finance membutuhkan file yang lebih dekat ke pola upload bank/payment rail, bukan hanya report detail transaksi. + - Format generik memberi baseline sebelum membuat template bank spesifik seperti BCA, Mandiri, BRI, atau payment gateway tertentu. +- Dampak / implikasi: + - `settlement_account_reference` dan `settlement_account_type` merchant menjadi sumber destination account pada file bank generic. + - Event `csv_exported` menyimpan format dan filename untuk jejak audit. + - Format bank spesifik berikutnya dapat ditambahkan sebagai nilai `format` baru tanpa mengubah endpoint. +- Status: Active + +## D-041 — Settlement Dispute/Adjustment Record +- Tanggal: 2026-05-28 +- Keputusan: + - Admin dapat mencatat adjustment/dispute ringan pada settlement batch lewat endpoint `POST /admin/settlement-batches/{batchId}/adjustments`. + - Adjustment disimpan di `metadata_json.adjustments` dengan tipe `credit` atau `debit`, nominal absolut, signed amount, reason, note, actor, dan timestamp. + - Total signed adjustment disimpan di `metadata_json.total_adjustment_amount`. + - Setiap adjustment mencatat event `adjustment_recorded` dan audit log before/after. + - UI settlement batch management menyediakan form Record Adjustment dan menampilkan total adjustment. +- Alasan: + - Setelah reconciliation, operator perlu mencatat koreksi/selisih payout tanpa langsung mengubah ledger historis. + - Event dan metadata cukup untuk fase awal dispute tracking sebelum dibuat ledger adjustment formal. +- Dampak / implikasi: + - Adjustment tidak mengubah `gross_amount`, `platform_fee_amount`, `net_payable_amount`, status batch, atau ledger entries. + - Batch arsip yang sudah direprocess tidak dapat menerima adjustment baru agar arsip tetap final. + - Tahap berikutnya dapat mempromosikan adjustment menjadi tabel/ledger entry khusus jika proses finance membutuhkannya. +- Status: Active + +## D-042 — Settlement Adjustment Summary di Finance Dashboard +- Tanggal: 2026-05-28 +- Keputusan: + - Summary finance settlement menghitung `adjustment_amount` dari `metadata_json.total_adjustment_amount` setiap batch. + - Endpoint `/admin/dashboard/summary` menambahkan `settlement_adjustment_amount` dan `settlement_adjusted_paid_amount`. + - UI admin dashboard menampilkan paid payout sebagai adjusted paid amount dan menampilkan adjustment sebagai angka terpisah. + - UI settlement batch management menampilkan total adjustment pada KPI finance. +- Alasan: + - Setelah adjustment/dispute dicatat, operator perlu melihat dampaknya di ringkasan finance tanpa membuka batch satu per satu. + - Paid payout asli tetap dibutuhkan sebagai angka batch historis, sementara adjusted paid memberi pandangan operasional setelah koreksi. +- Dampak / implikasi: + - Adjustment summary masih berbasis metadata, belum menjadi ledger formal. + - `settlement_paid_amount` tetap nominal paid batch asli; `settlement_adjusted_paid_amount` adalah paid amount ditambah signed adjustment. +- Status: Active + +## D-043 — Merchant Settlement Adjustment Visibility +- Tanggal: 2026-05-28 +- Keputusan: + - Merchant settlement summary memakai `adjusted_paid_amount` untuk total settled yang ditampilkan di portal merchant. + - Portal merchant menampilkan `adjustment_amount` sebagai angka terpisah agar koreksi payout tetap transparan. + - Drawer settlement merchant menampilkan adjustment/refund dan total disbursed setelah adjustment. +- Alasan: + - Merchant perlu melihat dampak koreksi payout yang sama dengan admin finance, bukan hanya nominal batch asli. + - Menampilkan adjustment terpisah menjaga transparansi tanpa mencampurkan koreksi dengan gross/net historis. +- Dampak / implikasi: + - Backend merchant sudah memakai summary yang sama dari `getSettlementFinanceSummary`. + - Smoke test memverifikasi `adjustment_amount` dan `adjusted_paid_amount` tersedia untuk merchant. +- Status: Active + +## D-044 — Settlement Adjustment Formal Table +- Tanggal: 2026-05-28 +- Keputusan: + - Adjustment settlement dipromosikan dari metadata-only menjadi tabel formal `settlement_batch_adjustments`. + - Endpoint `POST /admin/settlement-batches/{batchId}/adjustments` menulis row adjustment formal, lalu mensinkronkan ringkasan kompatibilitas ke `settlement_batches.metadata_json`. + - Detail batch admin dan merchant mengembalikan array `adjustments`. + - Summary finance menghitung adjustment dari tabel formal, bukan dari metadata. +- Alasan: + - Adjustment/dispute adalah data finance auditable dan perlu dapat di-query tanpa parsing metadata batch. + - Metadata tetap dipertahankan sebagai ringkasan agar UI dan payload lama tidak langsung patah. +- Dampak / implikasi: + - `metadata_json.adjustments` bukan sumber utama lagi; sumber utama adalah `settlement_batch_adjustments`. + - UI admin dan merchant membaca adjustment formal dari detail response saat tersedia, dengan fallback metadata. + - Jika ada data metadata-only lama di environment yang sudah berjalan, perlu migrasi backfill sebelum summary formal dianggap lengkap. +- Status: Active + +## D-045 — Backfill Adjustment Metadata ke Tabel Formal +- Tanggal: 2026-05-28 +- Keputusan: + - Migrasi schema melakukan backfill idempotent dari `settlement_batches.metadata_json.adjustments` ke `settlement_batch_adjustments`. + - Backfill memakai ID adjustment lama jika tersedia; jika tidak tersedia, ID dibuat deterministik dari batch dan urutan array. + - Insert backfill memakai `ON CONFLICT (id) DO NOTHING` supaya aman dijalankan berulang. + - Setelah backfill, `metadata_json.total_adjustment_amount` disinkronkan ulang dari tabel formal. +- Alasan: + - Ada kemungkinan environment yang sudah berjalan memiliki adjustment metadata-only dari D-041 sebelum tabel formal D-044. + - Summary finance sekarang menghitung dari tabel formal, sehingga data lama perlu ikut masuk agar laporan tidak turun nilainya. +- Dampak / implikasi: + - Backfill menjaga kompatibilitas metadata, tetapi sumber utama tetap `settlement_batch_adjustments`. + - Metadata lama yang tidak memiliki reason akan diberi reason default `Backfilled settlement adjustment`. +- Status: Active + +## D-046 — Settlement Adjustment Report +- Tanggal: 2026-05-28 +- Keputusan: + - Admin memiliki endpoint `GET /admin/settlement-adjustments` untuk membaca adjustment settlement lintas batch. + - Report mendukung filter `merchant_id`, `adjustment_type`, `from`, `to`, dan `limit`. + - Payload report menyertakan total count, total credit, total debit, net signed adjustment, serta row dengan konteks batch. + - UI `admin-reconciliation-management` menampilkan recent adjustment activity dari report formal. +- Alasan: + - Setelah adjustment menjadi tabel formal, finance perlu audit trail lintas batch tanpa membuka detail batch satu per satu. + - Reconciliation dashboard adalah tempat natural untuk melihat koreksi payout terbaru. +- Dampak / implikasi: + - Summary report dihitung dari semua row yang sesuai filter, sedangkan daftar row tetap dibatasi limit untuk UI. + - Report ini belum berupa export CSV khusus; export bisa ditambahkan jika finance membutuhkan lampiran audit. +- Status: Active + +## D-047 — Settlement Adjustment CSV Export +- Tanggal: 2026-05-28 +- Keputusan: + - Admin memiliki endpoint `GET /admin/settlement-adjustments/export.csv`. + - Export CSV memakai filter yang sama dengan report JSON: `merchant_id`, `adjustment_type`, `from`, `to`, dan `limit`. + - CSV menyertakan blok summary, lalu daftar adjustment dengan konteks batch, actor, reason, note, dan nominal signed. + - UI `admin-reconciliation-management` menyediakan tombol download CSV pada panel Recent Adjustment Activity. +- Alasan: + - Finance perlu lampiran audit adjustment lintas batch yang bisa disimpan atau dikirim di luar dashboard. + - CSV lebih cepat dipakai untuk proses operasional awal dibanding PDF/reporting engine. +- Dampak / implikasi: + - Export dibatasi maksimal 500 row mengikuti batas report saat ini. + - Jika volume adjustment besar, perlu pagination export atau async report job. +- Status: Active + +## D-048 — Settlement Adjustment Report Filter UI +- Tanggal: 2026-05-28 +- Keputusan: + - UI `admin-reconciliation-management` menambahkan filter adjustment berdasarkan merchant ID, tipe `credit/debit`, tanggal awal, dan tanggal akhir. + - Filter yang aktif dipakai untuk recent adjustment activity dan download CSV. + - Tombol Clear mengembalikan report ke default terbaru. +- Alasan: + - Finance perlu menelusuri adjustment lintas batch berdasarkan merchant atau periode audit tertentu. + - Export CSV harus konsisten dengan data yang sedang dilihat operator di dashboard. +- Dampak / implikasi: + - Filter merchant masih berupa input ID agar tidak menambah dependency daftar merchant pada panel reconciliation. + - Jika kebutuhan operasional meningkat, filter merchant dapat dinaikkan menjadi searchable select. +- Status: Active + +## D-049 — Searchable Merchant Filter untuk Adjustment Report +- Tanggal: 2026-05-28 +- Keputusan: + - Filter merchant pada adjustment report di UI reconciliation memakai daftar merchant dari endpoint admin. + - Control memakai native datalist agar operator bisa mencari merchant tanpa dependency UI baru. + - Input dapat menerima ID merchant langsung atau nilai yang cocok dengan kode, brand, atau legal name merchant. +- Alasan: + - Operator finance tidak seharusnya perlu copy-paste merchant ID manual untuk audit adjustment. + - Native datalist cukup ringan untuk fase sekarang dan tetap kompatibel dengan filter API yang menerima `merchant_id`. +- Dampak / implikasi: + - Untuk ribuan merchant, datalist perlu diganti menjadi remote search/pagination. + - Query backend tetap memakai merchant ID sehingga kontrak API tidak berubah. +- Status: Active + +## D-050 — Production Admin Session dan RBAC Baseline +- Tanggal: 2026-05-29 +- Keputusan: + - Admin auth mendukung signed session token berbasis user/role selain legacy dev token. + - Role awal disiapkan: admin, finance, ops, support, viewer. + - Endpoint settlement sensitif dipagari permission granular seperti `settlement:pay`, `settlement:adjust`, `settlement:export`, dan `reconciliation:read`. + - Legacy admin token dan login dev tetap default aktif untuk smoke/local, tetapi bisa dimatikan lewat `ADMIN_AUTH_ALLOW_LEGACY_TOKEN=false` dan `ADMIN_DEV_LOGIN_ENABLED=false`. +- Alasan: + - Production tidak boleh bergantung pada satu token admin global. + - Finance/ops/support/viewer membutuhkan batas akses yang berbeda saat pilot makin dekat. +- Dampak / implikasi: + - Production wajib mengatur `ADMIN_SESSION_SECRET` kuat dan menonaktifkan login dev. + - Smoke lama tetap kompatibel selama mode dev masih aktif. +- Status: Active + +## D-051 — MQTT Broker Observability dan Uplink Subscriber Opsional +- Tanggal: 2026-05-29 +- Keputusan: + - MQTT publisher menyimpan status runtime: koneksi terakhir, disconnect terakhir, error terakhir, dan counter publish success/failure. + - Service subscriber broker opsional dapat diaktifkan dengan `MQTT_SUBSCRIBE_ENABLED=true` untuk merekam uplink topic `devices/+/uplink/#` ke `mqtt_messages`. + - Endpoint `/admin/mqtt/status` mengembalikan status publisher, subscriber, dan trace pesan terakhir. +- Alasan: + - Operator perlu melihat health broker dan jejak message tanpa masuk ke broker langsung. + - Subscriber dibuat opsional agar local smoke tetap stabil tanpa dependency broker. +- Dampak / implikasi: + - Uplink dari broker saat ini direkam untuk observability; pemrosesan business flow device tetap memakai endpoint device yang sudah ada. + - Untuk production broker, ACL Mosquitto tetap harus disiapkan sesuai panduan. +- Status: Active + +## D-052 — Approval Workflow untuk Settlement Adjustment +- Tanggal: 2026-05-29 +- Keputusan: + - `settlement_batch_adjustments` memiliki `approval_status` (`pending`, `approved`, `rejected`) dan audit field approval/rejection. + - Summary finance, metadata total adjustment, dan UI detail hanya menghitung adjustment `approved`. + - Production dapat mewajibkan approval dengan `SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL=true`. + - Endpoint admin ditambahkan untuk approve/reject adjustment pending. +- Alasan: + - Adjustment payout adalah kontrol finance dan tidak boleh selalu final saat dicatat. + - Local smoke perlu tetap kompatibel, sehingga default dev masih auto-approved. +- Dampak / implikasi: + - Merchant hanya melihat nominal adjustment final/approved dalam summary dan detail. + - Finance dapat memfilter report adjustment berdasarkan `approval_status`. +- Status: Active + +## D-053 — Deployment Readiness Preflight +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan `npm run deploy:check-env` untuk memvalidasi env production kritikal. + - Checklist production dituangkan di `DEPLOYMENT_READINESS.md`. +- Alasan: + - Banyak flag dev yang sengaja aktif untuk smoke/local dan harus eksplisit dimatikan sebelum production. +- Dampak / implikasi: + - Deploy candidate harus menjalankan typecheck, smoke e2e, dan env preflight. + - Preflight sengaja gagal jika secret/default dev masih dipakai. +- Status: Active + +## D-054 — Admin User Bootstrap Script dan Finance Approval UI +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan script `npm run admin:create-user` untuk create/update user admin production dengan hash `scrypt`. + - Script mendukung role `admin`, `finance`, `ops`, `support`, `viewer`, status inactive, dan password rotation eksplisit. + - UI reconciliation menambahkan filter `approval_status` serta tombol approve/reject untuk adjustment pending. +- Alasan: + - RBAC production perlu cara operasional membuat user nyata tanpa edit database manual. + - Approval workflow adjustment harus bisa dipakai finance dari UI, bukan hanya lewat API. +- Dampak / implikasi: + - Production bootstrap admin user bisa dilakukan sebelum `ADMIN_DEV_LOGIN_ENABLED=false`. + - Finance dapat memproses pending adjustment dari panel Recent Adjustment Activity. +- Status: Active + +## D-055 — Merchant Session Auth dan Merchant User Bootstrap +- Tanggal: 2026-05-29 +- Keputusan: + - Merchant portal mendukung signed session token scoped ke `merchant_id`. + - Ditambahkan tabel `merchant_users` untuk login production berbasis email/password hash `scrypt`. + - Ditambahkan script `npm run merchant:create-user` untuk create/update merchant portal user. + - Login dev lama berbasis `MERCHANT_TOKEN` dan `MERCHANT_PORTAL_PASSWORD` tetap default aktif untuk local smoke, tetapi bisa dimatikan via `MERCHANT_AUTH_ALLOW_LEGACY_TOKEN=false` dan `MERCHANT_DEV_LOGIN_ENABLED=false`. +- Alasan: + - Merchant portal tidak boleh bergantung pada satu password/token global saat production. + - Setiap merchant membutuhkan user yang jelas dan token yang tidak bisa dipakai lintas merchant. +- Dampak / implikasi: + - Production wajib membuat merchant users sebelum mematikan dev login. + - UI merchant tetap kompatibel dengan response profile lama dan baru. +- Status: Active + +## D-056 — Versioned Migration Runner +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan command `npm run db:migrate`. + - Migration runner memakai tabel `schema_migrations` dan advisory lock Postgres untuk mencegah dua proses migrasi bersamaan. + - Migration file dibaca dari folder `migrations/` dengan format urut `NNN_description.sql` atau `NNN_description.mts`. + - Migration awal `001_current_schema_bootstrap.mts` menjalankan schema bootstrap existing sebagai baseline. +- Alasan: + - Schema sudah besar dan production deploy membutuhkan riwayat migration yang eksplisit. + - Baseline menjaga kompatibilitas dengan schema bootstrap yang sudah ada tanpa menggandakan SQL besar. +- Dampak / implikasi: + - Deploy candidate harus menjalankan `npm run db:migrate` sebelum start service. + - Migration berikutnya sebaiknya dibuat sebagai file terpisah, bukan menambah perubahan besar langsung ke bootstrap. +- Status: Active + +## D-057 — Production Structured Logging dan Observability Summary +- Tanggal: 2026-05-29 +- Keputusan: + - Request logging memakai middleware internal dengan structured fields: `request_id`, `trace_id`, method, path, status, latency, user-agent, dan IP. + - `LOG_FORMAT=json` tersedia untuk production stdout/stderr log collector; default local tetap readable. + - Error middleware menulis structured API/unhandled error log. + - Health endpoint ditambah: `GET /health/deep` dan `GET /admin/health/deep`. + - Admin endpoint `GET /admin/observability/summary` merangkum DB, MQTT, notification failure/pending, dan settlement reconciliation mismatch. +- Alasan: + - Pilot production membutuhkan log yang bisa dicari berdasarkan request/trace dan indikator operasional cepat. + - Mengurangi dependency pada log format dev dan console manual. +- Dampak / implikasi: + - `morgan` dihapus karena digantikan request logging internal. + - Production sebaiknya set `LOG_FORMAT=json` dan mengirim stdout/stderr ke log collector. +- Status: Active + +## D-058 — Initial Load Test Harness +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan script `npm run load:test`. + - Harness menguji transaction create, QRIS paid callback, device heartbeat, dynamic QR API, dan observability summary dengan concurrency configurable. + - Output berupa JSON summary berisi total success/error, durasi total, throughput perkiraan, serta p50/p95/max per label. + - Data merchant/transaksi load test dibersihkan setelah run. +- Alasan: + - Setelah structured logging dan observability tersedia, perlu baseline performa awal sebelum pilot. + - Script ringan cukup untuk menemukan bottleneck awal tanpa memasang load testing framework eksternal. +- Dampak / implikasi: + - Angka baseline lokal pertama: 190 request, 0 error, durasi 1202.49 ms, throughput perkiraan 158.01 req/s. + - p95 lokal: transaction create 119.54 ms, callback paid 119.30 ms, heartbeat 45.93 ms, dynamic QR 41.75 ms, observability summary 69.63 ms. + - Angka ini bukan kapasitas production final; perlu run ulang di environment target dan dengan data lebih besar. +- Status: Active + +## D-059 — Admin UI RBAC Awareness +- Tanggal: 2026-05-29 +- Keputusan: + - Admin UI shared helper menyimpan profile admin, membaca `GET /admin/me`, dan menyediakan helper permission untuk elemen `data-admin-permission`. + - Settlement batch UI dan reconciliation UI menyembunyikan/disable aksi finance sesuai permission seperti `settlement:write`, `settlement:pay`, `settlement:adjust`, dan `settlement:export`. +- Alasan: + - RBAC backend sudah aktif, tetapi UI perlu memberi sinyal operasional yang jelas sebelum user menekan aksi yang akan ditolak API. +- Dampak / implikasi: + - UI menjadi role-aware tanpa mengganti enforcement backend. + - API tetap menjadi sumber otorisasi final. +- Status: Active + +## D-060 — Async Settlement Adjustment Export Jobs +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan tabel `export_jobs`, migration `002_export_jobs.sql`, dan store `exportJobStore`. + - Ditambahkan endpoint `POST /admin/exports/settlement-adjustments`, `GET /admin/exports/:jobId`, dan `GET /admin/exports/:jobId/download`. + - Job export saat ini dieksekusi segera di request lifecycle sebagai skeleton async, tetapi contract status/result/download sudah siap dipisah ke worker. +- Alasan: + - Export report adjustment bisa membesar dan perlu contract job sebelum dipindah ke background worker production. +- Dampak / implikasi: + - UI/API consumer bisa mulai memakai pola job polling. + - Tahap berikutnya adalah worker queue dan storage object jika volume export besar. +- Status: Active + +## D-061 — Load Test Level 2 dan Audit Cleanup +- Tanggal: 2026-05-29 +- Keputusan: + - Load test lokal level 2 dijalankan dengan 300 callback, 600 heartbeat, 300 dynamic QR, 100 observability read, concurrency 25. + - Dependency `uuid` dihapus karena tidak dipakai source dan menjadi sumber audit moderate. + - `npm audit` sekarang menghasilkan 0 vulnerability. +- Alasan: + - Pilot membutuhkan baseline lebih besar dari smoke awal dan dependency audit bersih. +- Dampak / implikasi: + - Hasil lokal level 2: 1610 request, 0 error, durasi 4955.45 ms, throughput perkiraan 324.9 req/s. + - p95 lokal: transaction create 183.56 ms, callback paid 129.03 ms, heartbeat 90.4 ms, dynamic QR 71.57 ms, observability summary 230.96 ms. + - Angka ini tetap baseline lokal, bukan kapasitas production final. +- Status: Active + +## D-062 — Real MQTT Broker Smoke Validation +- Tanggal: 2026-05-29 +- Keputusan: + - `npm run smoke:mqtt-real` dijalankan terhadap broker `mqtts://mqtt.iptek.co:8883`. + - Test memverifikasi broker connect, subscribe `devices/+/downlink/#`, payment success downlink, config push, dan dynamic QR response. +- Alasan: + - MQTT broker production-like harus divalidasi terpisah dari simulator sebelum pilot device real. +- Dampak / implikasi: + - Smoke broker real terakhir lulus dengan 3 message diterima dan cleanup data smoke berhasil. + - Smoke e2e utama tetap memakai simulator agar CI/local tidak bergantung broker eksternal. +- Status: Active + +## D-063 — Merchant Portal Session Polish +- Tanggal: 2026-05-29 +- Keputusan: + - Merchant API helper menyimpan user session dan auth mode. + - Merchant settlement UI menampilkan nama/session role user serta menyediakan tombol logout yang membersihkan session lokal. +- Alasan: + - Setelah merchant session auth production tersedia, portal perlu feedback login yang lebih jelas dan alur keluar yang eksplisit. +- Dampak / implikasi: + - Merchant operator bisa melihat identitas session aktif dan logout tanpa menghapus storage manual. +- Status: Active + +## D-064 — Export Job Worker Productionization +- Tanggal: 2026-05-29 +- Keputusan: + - Export adjustment settlement diproses oleh worker internal `exportJobWorker`, bukan lagi langsung di request lifecycle. + - Endpoint create job mengembalikan job `pending`; worker melakukan atomic claim ke status `running`, lalu menyelesaikan ke `completed` atau `failed`. + - Worker memiliki konfigurasi `EXPORT_WORKER_ENABLED`, `EXPORT_WORKER_INTERVAL_MS`, `EXPORT_WORKER_BATCH_SIZE`, `EXPORT_JOB_STALE_RUNNING_MS`, dan `EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS`. + - `/admin/observability/summary` menampilkan status worker dan count job per status. +- Alasan: + - Export besar tidak boleh menahan request admin dan perlu pola polling yang production-friendly. + - Stale running job perlu bisa di-reset setelah restart/crash worker. +- Dampak / implikasi: + - Client harus polling `GET /admin/exports/:jobId` sampai status `completed` sebelum download. + - Hasil export saat ini masih disimpan di DB `result_body`; tahap berikutnya adalah storage eksternal/object storage jika ukuran file membesar. +- Status: Active + +## D-065 — Reconciliation UI Async Export Polling +- Tanggal: 2026-05-29 +- Keputusan: + - UI `admin-reconciliation-management` mengganti download adjustment CSV sinkron menjadi alur async export job. + - Tombol export membuat job via `POST /admin/exports/settlement-adjustments`, menampilkan status job, polling `GET /admin/exports/:jobId`, lalu download dari endpoint job saat `completed`. + - Shared admin API helper menambahkan method create/get/download export job. +- Alasan: + - Finance user perlu UX yang selaras dengan worker export agar report besar tidak bergantung request CSV sinkron. +- Dampak / implikasi: + - Export lama `GET /admin/settlement-adjustments/export.csv` masih ada untuk kompatibilitas, tetapi UI reconciliation memakai job async. + - Tahap berikutnya adalah tampilan riwayat job export jika finance perlu mengambil ulang file lama. +- Status: Active + +## D-066 — Export File Storage, Retention, dan Job History +- Tanggal: 2026-05-29 +- Keputusan: + - Hasil export baru disimpan sebagai file di `EXPORT_STORAGE_DIR`, bukan `result_body` database. + - Metadata `result_storage_path`, `result_size_bytes`, dan `expires_at` ditambahkan melalui migration `003_export_job_storage.sql`. + - Worker membersihkan hasil export expired berdasarkan `EXPORT_RETENTION_DAYS`. + - Endpoint `GET /admin/exports` ditambahkan untuk history job, dan UI reconciliation menampilkan lima job export adjustment terbaru. +- Alasan: + - File CSV besar tidak ideal disimpan sebagai body di database. + - Finance membutuhkan visibility status/download ulang job export terbaru. +- Dampak / implikasi: + - Directory `EXPORT_STORAGE_DIR` harus writable dan masuk strategi backup/retention. + - Export lama yang masih punya `result_body` tetap bisa didownload sebagai fallback. +- Status: Active + +## D-067 — MQTT ACL Hardening Helper +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan script `npm run mqtt:check-acl` untuk print template dan validasi file ACL Mosquitto. + - Provisioning device sekarang menampilkan topic scope per device dan command validasi ACL. +- Alasan: + - Pilot hardware real butuh guard agar device hanya publish/subscribe topic miliknya sendiri. +- Dampak / implikasi: + - Production preflight harus menjalankan validasi ACL setelah perubahan file broker. + - Username MQTT device tetap harus sama dengan `device_id`. +- Status: Active + +## D-068 — Backup/Restore Readiness Scripts +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan `npm run backup:production` untuk dump Postgres dan opsional copy Mosquitto passwd/ACL. + - Ditambahkan `npm run restore:plan` yang default hanya mencetak command restore dan hanya execute jika diberi `--execute`. +- Alasan: + - Pilot production membutuhkan backup yang bisa dijalankan dan restore drill yang tidak mudah terpanggil destruktif tanpa sadar. +- Dampak / implikasi: + - Restore harus diuji pada database disposable sebelum pilot live. + - Backup directory dan file Mosquitto perlu masuk retention/secure storage operational. +- Status: Active + +## D-069 — Staging Load Test Profile +- Tanggal: 2026-05-29 +- Keputusan: + - Load test mendapat skenario async export adjustment via `LOAD_EXPORTS`. + - Ditambahkan script `npm run load:test:staging` dengan profile lebih besar: callback, heartbeat, dynamic QR, observability read, dan export job. +- Alasan: + - Baseline lokal belum mencakup worker export dan belum cukup mewakili staging/production-like environment. +- Dampak / implikasi: + - Jalankan profile ini terhadap staging dengan `BASE_URL` dan secret/token staging. + - Angka hasil staging harus dicatat terpisah dari baseline lokal. +- Status: Active + +## D-070 — MQTT ACL Smoke Test untuk Pilot Device +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan `npm run smoke:mqtt-acl` untuk validasi credential device terhadap ACL broker. + - Smoke memastikan device A bisa akses topic miliknya dan ditolak saat subscribe ke downlink device B. +- Alasan: + - File ACL yang benar secara template belum cukup; perlu validasi runtime sebelum device fisik pilot. +- Dampak / implikasi: + - Smoke membutuhkan dua username device test yang sudah ada di broker. + - Jalankan setelah provisioning credential device dan reload Mosquitto. +- Status: Active + +## D-071 — Restore Drill Validation +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan `npm run restore:validate`. + - Validator menjalankan migration idempotent, cek `/health`, `/admin/health/deep`, dan memastikan tabel kunci tersedia. +- Alasan: + - Restore drill harus berakhir dengan bukti service bisa start dan schema masih konsisten. +- Dampak / implikasi: + - Jalankan terhadap service/database restore disposable, bukan production live. + - `RESTORE_DRILL_RUN_MIGRATE=false` tersedia jika migration ingin dijalankan manual. +- Status: Active + +## D-072 — UI QA Lightweight Gate +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan `npm run ui:qa`. + - Checker memvalidasi inline script halaman operasional, async export reconciliation, dan permission-aware settlement actions. + - Placeholder navigation di halaman operasional utama dibersihkan dan kini menjadi failure jika muncul lagi. +- Alasan: + - Perubahan UI HTML inline rawan regresi sintaks yang tidak tertangkap TypeScript. +- Dampak / implikasi: + - UI QA ringan menjadi gate untuk mencegah placeholder nav kembali di halaman operasional utama. +- Status: Active + +## D-073 — Staging Load Report Artifact +- Tanggal: 2026-05-29 +- Keputusan: + - `scripts/load-test.mjs` dapat menulis JSON summary ke `LOAD_REPORT_FILE`. + - `npm run load:test:staging` sekarang memakai wrapper yang otomatis menyimpan report ke `reports/`. +- Alasan: + - Staging baseline perlu artifact yang bisa disimpan di handoff/release note, bukan hanya terminal output. +- Dampak / implikasi: + - Folder `reports/` berisi hasil run staging dan dapat diarsipkan oleh operator. +- Status: Active + +## D-074 — Strict Production Security Preflight +- Tanggal: 2026-05-29 +- Keputusan: + - `npm run deploy:check-env` sekarang gagal jika legacy admin/merchant/device auth masih aktif. + - Production preflight mewajibkan finance approval, export worker, export storage dir, retention positif, dan secret minimal 24 karakter. +- Alasan: + - Pilot live tidak boleh berjalan dengan fallback dev auth atau secret pendek. +- Dampak / implikasi: + - `.env` lokal dev memang akan gagal preflight production; validasi ini ditujukan untuk environment candidate. +- Status: Active + +## D-075 — Rate Limiting dan Request Security Polish +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan middleware rate limit in-memory dengan header `RateLimit-*` dan error `RATE_LIMITED`. + - Rate limit dipasang untuk admin/merchant login, admin write routes, device routes, dan integration callback routes. + - App menambahkan `JSON_BODY_LIMIT`, optional `TRUST_PROXY`, referrer policy, dan HSTS saat `NODE_ENV=production`. +- Alasan: + - Endpoint login, device, admin write, dan webhook/callback perlu guard dasar sebelum pilot publik. +- Dampak / implikasi: + - Default local dibuat cukup longgar agar smoke/load lokal tetap berjalan. + - Production wajib `RATE_LIMIT_ENABLED=true` dan tuning limit sesuai trafik pilot. +- Status: Active + +## D-076 — Placeholder Navigation Cleanup +- Tanggal: 2026-05-29 +- Keputusan: + - Placeholder `href="#"` dibersihkan dari halaman operasional utama: reconciliation, settlement batch, merchant settlement history, dan device technical detail. + - Link diarahkan ke halaman UI nyata seperti dashboard, settlement, reconciliation, device detail, transaction history, hub, atau login. +- Alasan: + - Placeholder nav mengganggu manual UI QA dan bisa membingungkan operator pilot. +- Dampak / implikasi: + - `npm run ui:qa` sekarang hijau tanpa warning placeholder. + - Beberapa target masih berupa halaman representatif sampai navigasi produk final dirapikan. +- Status: Active + +## D-077 — Login Audit dan Bootstrap Password Policy +- Tanggal: 2026-05-29 +- Keputusan: + - Admin login success/failure dicatat ke audit log dengan action `admin.login.success` dan `admin.login.failed`. + - Merchant login success/failure dicatat ke audit log dengan action `merchant.login.success` dan `merchant.login.failed`. + - Script `admin:create-user` dan `merchant:create-user` memperketat password policy: minimal 14 karakter, lowercase, uppercase, angka, simbol, dan tanpa kata default/produk obvious. +- Alasan: + - Pilot production membutuhkan visibility login dan guard sederhana sebelum user bootstrap. +- Dampak / implikasi: + - Audit log dapat dipakai untuk review brute force/credential misuse. + - Password bootstrap lama yang terlalu sederhana akan ditolak. +- Status: Active + +## D-078 — Operational Runbook dan Pilot Checklist +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan `OPERATIONAL_RUNBOOK.md` untuk SOP pre-deploy, deploy, smoke, backup, restore, rollback, dan incident response. + - Ditambahkan `PILOT_EXECUTION_CHECKLIST.md` untuk go/no-go pilot. +- Alasan: + - Artefak operasional perlu eksplisit agar handover tidak bergantung pada chat atau ingatan developer. +- Dampak / implikasi: + - Operator punya satu dokumen SOP dan satu checklist eksekusi pilot yang bisa dicentang. +- Status: Active + +## D-079 — Export Storage Deployment Readiness +- Tanggal: 2026-05-29 +- Keputusan: + - Ditambahkan `EXPORT_STORAGE_READINESS.md`. + - Production preflight mewajibkan `EXPORT_STORAGE_DIR` absolute path. + - Dokumen menjelaskan single-node pilot, multi-node shared storage, dan batasan sebelum object storage adapter tersedia. +- Alasan: + - Export sudah file-based, sehingga deployment multi-node perlu aturan storage yang jelas. +- Dampak / implikasi: + - Untuk multi-node, export directory harus shared atau download bisa gagal di node berbeda. + - S3/object storage tetap menjadi peningkatan future jika skala membutuhkan. +- Status: Active + +## D-080 — Admin Audit Log UI Wiring +- Tanggal: 2026-05-29 +- Keputusan: + - Halaman `admin-system-audit-logs` membaca data real dari `GET /admin/audit-logs`. + - Backend audit log mendukung filter `action_contains` untuk preset login events lintas admin/merchant. + - UI audit menambahkan filter action/entity/date, search client-side, KPI total/login failed/login success, dan drawer JSON payload. +- Alasan: + - Login audit sudah dicatat, sehingga operator membutuhkan view cepat untuk investigasi login failure/success. +- Dampak / implikasi: + - `npm run ui:qa` sekarang memasukkan halaman audit logs ke gate. + - Audit UI bisa dipakai untuk review brute force dan credential misuse selama pilot. +- Status: Active diff --git a/DEPLOYMENT_READINESS.md b/DEPLOYMENT_READINESS.md new file mode 100644 index 0000000..ae8d51e --- /dev/null +++ b/DEPLOYMENT_READINESS.md @@ -0,0 +1,57 @@ +# Deployment Readiness + +## Production Preflight + +Run this before deploying a production candidate: + +```bash +npm run typecheck +npm run db:migrate +npm audit +npm run smoke:e2e +npm run deploy:check-env +npm run mqtt:check-acl -- --file /etc/mosquitto/acl +``` + +Use `npm run smoke:mqtt-real` separately after Mosquitto ACL, backend credential, and broker networking are ready. +Use `npm run smoke:mqtt-acl` with two provisioned device credentials before hardware pilot. + +## Required Production Controls + +- Create production admin users with `npm run admin:create-user -- --email --name --role --password `. +- Create merchant portal users with `npm run merchant:create-user -- --merchant --email --name --role --password `. +- Set `ADMIN_DEV_LOGIN_ENABLED=false`. +- Set `ADMIN_AUTH_ALLOW_LEGACY_TOKEN=false` after admin users and roles are configured. +- Set `MERCHANT_DEV_LOGIN_ENABLED=false`. +- Set `MERCHANT_AUTH_ALLOW_LEGACY_TOKEN=false` after merchant portal users are configured. +- Set `DEVICE_AUTH_ALLOW_LEGACY_TOKEN=false` after all devices use per-device credentials. +- Set `ADMIN_SESSION_SECRET`, `INTEGRATION_WEBHOOK_SECRET`, `MQTT_PASSWORD`, and database credentials to strong non-default values. +- Set `MQTT_PUBLISH_MODE=broker`. +- Set `MQTT_SUBSCRIBE_ENABLED=true` when broker uplink observability is needed. +- Keep `EXPORT_WORKER_ENABLED=true` for async export processing, or run a dedicated app instance with the worker enabled. +- Set `EXPORT_STORAGE_DIR` to a backed-up writable directory and set `EXPORT_RETENTION_DAYS` according to finance retention needs. +- Use an absolute `EXPORT_STORAGE_DIR`; see `EXPORT_STORAGE_READINESS.md` for single-node vs multi-node notes. +- Keep `RATE_LIMIT_ENABLED=true` and tune rate limit env values for expected pilot traffic. +- Set `TRUST_PROXY=true` when behind a reverse proxy/load balancer. +- Set `JSON_BODY_LIMIT` to the smallest practical request body limit. +- Set `SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL=true` for finance approval control. + +## Operational Checks + +- Confirm `/health`, `/admin/health`, and `/admin/mqtt/status`. +- Confirm `/health/deep` and `/admin/health/deep` for DB/MQTT readiness. +- Confirm `/admin/observability/summary` for notification failure, settlement mismatch, DB, and MQTT summary. +- Confirm async export worker status in `/admin/observability/summary`. +- Confirm async export job flow with `POST /admin/exports/settlement-adjustments`, polling `GET /admin/exports/:jobId`, and download. +- Confirm export files are written under `EXPORT_STORAGE_DIR` and expire after retention. +- Run `npm run backup:production -- --dry-run` and one real backup before pilot. +- Run `npm run restore:plan -- --backup ` and perform a restore drill on a disposable database. +- After restore drill, run `npm run restore:validate` against the restored service. +- Run staging load profile with `npm run load:test:staging` against a production-like environment. +- Run `npm run ui:qa`. +- Set `LOG_FORMAT=json` and ship stdout/stderr to the production log collector. +- Confirm Postgres backup/restore procedure and retention. +- Confirm Mosquitto password file and ACL are backed up. +- Confirm logs include `request_id` for admin, device, and integration paths. +- Confirm settlement CSV export format with the target bank/payment rail before live payout operations. +- Keep `OPERATIONAL_RUNBOOK.md` and `PILOT_EXECUTION_CHECKLIST.md` available to the operator on duty. diff --git a/EXPORT_STORAGE_READINESS.md b/EXPORT_STORAGE_READINESS.md new file mode 100644 index 0000000..79dd506 --- /dev/null +++ b/EXPORT_STORAGE_READINESS.md @@ -0,0 +1,55 @@ +# Export Storage Readiness + +## Current Mode + +Async export result files are written to `EXPORT_STORAGE_DIR`. Metadata remains in Postgres table `export_jobs`. + +Required production properties: + +- Absolute path, for example `/var/lib/qris/exports`. +- Writable by the app/worker process. +- Included in backup or mounted on durable storage. +- Same mount visible to all app instances that serve `GET /admin/exports/:jobId/download`. + +## Single-Node Pilot + +Recommended: + +```bash +EXPORT_STORAGE_DIR=/var/lib/qris/exports +EXPORT_RETENTION_DAYS=7 +``` + +Filesystem setup example: + +```bash +sudo mkdir -p /var/lib/qris/exports +sudo chown : /var/lib/qris/exports +sudo chmod 750 /var/lib/qris/exports +``` + +## Multi-Node Production + +Do not use node-local disk unless traffic is sticky to the same node that processed the export job. + +Preferred options: + +- Shared filesystem mounted at the same `EXPORT_STORAGE_DIR` on every app node. +- S3-compatible object storage adapter in a future implementation. + +Until object storage exists, run one export worker/app instance or ensure all download-serving instances share the export directory. + +## Retention + +Worker clears downloadable file references after `EXPORT_RETENTION_DAYS`. If a finance user needs an expired file, create a new export job. + +## Validation + +```bash +npm run deploy:check-env +POST /admin/exports/settlement-adjustments +GET /admin/exports/:jobId +GET /admin/exports/:jobId/download +``` + +Check `/admin/observability/summary` for worker state and export job counts. diff --git a/OPERATIONAL_RUNBOOK.md b/OPERATIONAL_RUNBOOK.md new file mode 100644 index 0000000..d579519 --- /dev/null +++ b/OPERATIONAL_RUNBOOK.md @@ -0,0 +1,146 @@ +# QRIS Soundbox Platform Operational Runbook + +## Scope + +Runbook ini untuk pilot/staging/production operator. Semua command diasumsikan dijalankan dari root repo atau release directory. + +## Pre-Deploy + +1. Pull/build release artifact. +2. Isi environment production dan pastikan secret bukan default. +3. Jalankan: + +```bash +npm ci +npm run typecheck +npm audit +npm run db:migrate +npm run deploy:check-env +npm run mqtt:check-acl -- --file /etc/mosquitto/acl +``` + +4. Buat/cek admin dan merchant user production: + +```bash +npm run admin:create-user -- --email --name --role admin --password +npm run merchant:create-user -- --merchant --email --name --role owner --password +``` + +## Deploy + +1. Jalankan migration sebelum service baru menerima traffic: + +```bash +npm run db:migrate +``` + +2. Start/restart service dengan `LOG_FORMAT=json`. +3. Cek: + +```bash +curl -fsS http://127.0.0.1:3000/health +curl -fsS http://127.0.0.1:3000/health/deep +``` + +4. Cek admin authenticated health: + +```bash +curl -fsS -H "Authorization: Bearer " http://127.0.0.1:3000/admin/health/deep +``` + +## Post-Deploy Smoke + +```bash +npm run smoke:e2e +npm run ui:qa +npm run smoke:mqtt-real +MQTT_TEST_DEVICE_A_USERNAME= MQTT_TEST_DEVICE_A_PASSWORD= MQTT_TEST_DEVICE_B_USERNAME= npm run smoke:mqtt-acl +``` + +Untuk staging/production-like baseline: + +```bash +BASE_URL=https://staging.example.com npm run load:test:staging +``` + +Simpan report `reports/load-staging-*.json` bersama catatan release. + +## Backup + +Sebelum deploy besar dan minimal harian: + +```bash +npm run backup:production -- --out /var/backups/qris --include-mosquitto +``` + +Pastikan backup disalin ke storage aman dan terenkripsi. File penting: + +- Postgres dump `.dump` +- Mosquitto passwd +- Mosquitto ACL +- Environment/secret reference di secret manager, bukan file plain text + +## Restore Drill + +1. Siapkan database disposable. +2. Tampilkan rencana: + +```bash +npm run restore:plan -- --backup /var/backups/qris/.dump +``` + +3. Jalankan restore hanya ke database disposable: + +```bash +npm run restore:plan -- --backup /var/backups/qris/.dump -- --execute +``` + +4. Start service mengarah ke DB restore. +5. Validasi: + +```bash +npm run restore:validate +``` + +## Rollback + +1. Hentikan traffic ke release baru. +2. Rollback service image/release ke versi sebelumnya. +3. Jika migration baru hanya additive, jangan rollback database. +4. Jika database harus dikembalikan, restore dari backup terbaru ke database disposable dulu, lalu promote sesuai prosedur infra. +5. Jalankan `/health`, `/admin/health/deep`, dan smoke minimal. + +## Incident Response + +### API latency/error naik + +1. Cek `/admin/observability/summary`. +2. Cek log dengan `request_id`/`trace_id`. +3. Cek Postgres connection dan slow query. +4. Turunkan traffic atau rate limit jika perlu. + +### MQTT publish/subscribe bermasalah + +1. Cek `/admin/mqtt/status`. +2. Cek broker service, certificate, ACL, dan passwd. +3. Jalankan `npm run smoke:mqtt-real`. +4. Untuk credential device, rotate via UI atau `npm run mqtt:provision-device`. + +### Export macet + +1. Cek `/admin/observability/summary` bagian `export_jobs`. +2. Pastikan `EXPORT_STORAGE_DIR` writable. +3. Restart worker/app untuk reset stale running job. +4. Jika file expired, minta user membuat export baru. + +### Login brute force + +1. Cek audit log action `admin.login.failed` dan `merchant.login.failed`. +2. Naikkan strictness `RATE_LIMIT_LOGIN_MAX`. +3. Disable user mencurigakan via DB/admin tooling sementara. + +## Routine Operations + +- Harian: cek health/deep health, backup, MQTT status, failed notification. +- Mingguan: restore drill sample, review audit failed login, review export storage usage. +- Sebelum pilot device baru: provision credential, update broker passwd, validate ACL, smoke MQTT ACL. diff --git a/PILOT_EXECUTION_CHECKLIST.md b/PILOT_EXECUTION_CHECKLIST.md new file mode 100644 index 0000000..2505a5c --- /dev/null +++ b/PILOT_EXECUTION_CHECKLIST.md @@ -0,0 +1,51 @@ +# Pilot Execution Checklist + +## Environment + +- [ ] `npm run deploy:check-env` passes with production/staging env. +- [ ] `npm run db:migrate` is idempotent. +- [ ] `npm audit` reports 0 vulnerabilities. +- [ ] `EXPORT_STORAGE_DIR` is writable and backed up. +- [ ] `EXPORT_STORAGE_READINESS.md` reviewed for single-node/multi-node deployment mode. +- [ ] `LOG_FORMAT=json` logs are collected. + +## UI QA + +- [ ] `npm run ui:qa` passes. +- [ ] Admin dashboard opens on desktop and mobile. +- [ ] Settlement batch page: list, detail drawer, export, permission states. +- [ ] Reconciliation page: filters, async export status/history/download. +- [ ] Merchant settlement page: login session display, logout, export. +- [ ] Device technical detail: health/config/credential modal. + +## MQTT Pilot Device + +- [ ] `npm run mqtt:check-acl -- --file /etc/mosquitto/acl` passes. +- [ ] Device A and B credentials exist in broker passwd. +- [ ] `npm run smoke:mqtt-acl` passes. +- [ ] `npm run smoke:mqtt-real` passes. +- [ ] Physical device receives payment success downlink. +- [ ] Physical device receives config push and sends ACK. + +## Backup/Restore + +- [ ] `npm run backup:production -- --out --include-mosquitto` succeeds. +- [ ] Backup copied to secure storage. +- [ ] `npm run restore:plan -- --backup ` reviewed. +- [ ] Restore drill on disposable database succeeds. +- [ ] `npm run restore:validate` passes against restored service. + +## Load/Staging Baseline + +- [ ] `BASE_URL= npm run load:test:staging` succeeds. +- [ ] Report stored in `reports/`. +- [ ] p95 and error count recorded in handoff/release notes. +- [ ] Export worker metrics checked in `/admin/observability/summary`. + +## Go/No-Go + +- [ ] No critical failed notifications. +- [ ] No settlement reconciliation mismatch unexplained. +- [ ] No unexpected login failure spike. +- [ ] Rollback release artifact is available. +- [ ] Operator has access to `OPERATIONAL_RUNBOOK.md`. diff --git a/README.md b/README.md index 0515b0d..a4558e6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Paket ini berisi blueprint final v1 untuk platform merchant aggregator QRIS + so - 09-screen-inventory.md - 10-design-blueprint.md - 11-low-fi-wireframes.md +- 18-mqtt-broker-mosquitto-debian13.md ## Tujuan Dokumen ini dibuat supaya tim bisa langsung mulai: @@ -33,7 +34,9 @@ Dokumen ini dibuat supaya tim bisa langsung mulai: - middleware idempotency untuk endpoint sensitif - endpoint awal: - `GET /health` + - `GET /health/deep` - `GET /admin/health` (dengan `Authorization: Bearer `) + - `GET /admin/health/deep` - `POST /admin/login` - `POST /admin/sample-idempotent` - `POST /admin/merchants` @@ -51,6 +54,7 @@ Dokumen ini dibuat supaya tim bisa langsung mulai: - `POST /admin/devices` - `GET /admin/devices` - `GET /admin/devices/{id}` + - `POST /admin/devices/{id}/credentials/rotate` - `POST /admin/devices/{id}/bind` - `POST /admin/devices/{id}/unbind` - `POST /admin/devices/{id}/commands` @@ -64,6 +68,40 @@ Dokumen ini dibuat supaya tim bisa langsung mulai: - `GET /admin/devices/{id}/mqtt-messages` - `GET /admin/audit-logs` - `GET /admin/ledger-entries` + - `GET /admin/dashboard/summary` + - Memuat settlement finance summary termasuk `settlement_adjustment_amount` dan `settlement_adjusted_paid_amount`. + - `POST /admin/settlement-batches` + - `GET /admin/settlement-batches` + - `GET /admin/settlement-batches/{batchId}` + - `GET /admin/settlement-batches/{batchId}/export.csv` + - Query `format=bank_generic` tersedia untuk CSV upload bank generik; default tetap settlement report standard. + - `POST /admin/settlement-batches/{batchId}/mark-paid` + - `PATCH /admin/settlement-batches/{batchId}/reference` + - `POST /admin/settlement-batches/{batchId}/adjustments` + - Adjustment disimpan di tabel formal `settlement_batch_adjustments` dan ikut tampil di detail batch admin/merchant. + - Metadata adjustment lama otomatis dibackfill ke tabel formal saat schema initialization berjalan. + - `GET /admin/settlement-adjustments` + - Report adjustment lintas settlement batch dengan filter `merchant_id`, `adjustment_type`, `from`, `to`, dan `limit`. + - `GET /admin/settlement-adjustments/export.csv` + - Export CSV untuk report adjustment settlement dengan filter yang sama. + - `POST /admin/exports/settlement-adjustments` + - Membuat job export CSV adjustment settlement secara async, dengan permission `settlement:export`. + - `GET /admin/exports/{jobId}` + - Melihat status job export. + - `GET /admin/exports/{jobId}/download` + - Download hasil CSV saat job sudah `completed`. + - `POST /admin/settlement-batches/{batchId}/mark-failed` + - `POST /admin/settlement-batches/{batchId}/cancel` + - `POST /admin/settlement-batches/{batchId}/reprocess` + - `GET /admin/reconciliation/settlement-batches` + - `GET /admin/observability/summary` + - `POST /merchant/login` + - `GET /merchant/profile` + - `GET /merchant/settlement-summary` + - `GET /merchant/settlement-batches` + - `GET /merchant/settlement-batches/{batchId}` + - `GET /merchant/settlement-batches/{batchId}/export.csv` + - Query `format=bank_generic` tersedia untuk CSV upload bank generik; default tetap settlement report standard. - `GET /admin/transactions` - `GET /admin/transactions/{transactionId}` - `POST /admin/transactions` @@ -81,10 +119,24 @@ Dokumen ini dibuat supaya tim bisa langsung mulai: ```bash npm install cp .env.example .env +npm run db:migrate npm run dev npm run build && npm start ``` +Schema migration memakai tabel `schema_migrations`. Migration awal `001_current_schema_bootstrap` membaseline schema saat ini; migration berikutnya bisa ditambahkan ke folder `migrations/` dengan format `NNN_description.sql` atau `NNN_description.mts`. + +Untuk mode broker MQTT sungguhan, set `MQTT_PUBLISH_MODE=broker` di `.env` bersama `MQTT_BROKER_URL`, `MQTT_USERNAME`, `MQTT_PASSWORD`, dan `MQTT_CLIENT_ID`. Smoke test otomatis tetap override ke `MQTT_PUBLISH_MODE=simulator` agar tidak bergantung pada broker eksternal. + +Dynamic QR expiry sweep berjalan otomatis bila `DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true`; interval dan batch size diatur lewat `DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS` dan `DYNAMIC_QR_EXPIRY_SWEEP_LIMIT`. + +Finance light membuat ledger `gross_income`, `platform_fee`, dan `merchant_payable` saat transaksi menjadi `paid`. Rate fee default diatur lewat `FINANCE_PLATFORM_FEE_BPS`. + +Settlement light membuat batch dari ledger `merchant_payable` yang belum dibatch, dikelompokkan per merchant, lalu admin dapat menandai batch sebagai `paid`. +UI `settlement-batch-management` sudah membaca batch real, menampilkan KPI settlement, membuka drawer detail batch, event history payout, generate batch, confirm paid dengan reference payout/note, mark failed/cancel dengan reason, reprocess failed/cancelled batch, dan download CSV payout report. +Admin dashboard overview juga menampilkan summary settlement/finance dari agregasi DB: pending payout, paid payout, total platform fee, jumlah batch, batch terbaru, dan download CSV payout report per batch. +UI `merchant-settlement-history` sudah membaca settlement batch milik merchant yang login, menampilkan pending/paid payout termasuk adjustment dan adjusted paid amount, membuka detail batch dengan payout event history, dan download CSV payout report merchant-scoped. + ### Cleanup data smoke test ```bash @@ -97,7 +149,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, duplicate callback, invalid signature, audit log, ledger placeholder, skenario terminal tanpa binding, dynamic QR API-direct, expiry sweep dynamic QR, dynamic QR MQTT, device config push/retry/status/ack, dan trace MQTT config ack. +Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification, duplicate callback, invalid signature, audit log, ledger finance light, settlement batch light, skenario terminal tanpa binding, dynamic QR API-direct, expiry sweep dynamic QR, dynamic QR MQTT, device config push/retry/status/ack, dan trace MQTT config ack. ### Smoke test end-to-end (bootstrap + flow + cleanup) @@ -113,6 +165,164 @@ Perintah ini menjalankan: - jalankan flow smoke lengkap - hentikan server setelah selesai +### Load test awal + +Jalankan backend lokal lalu eksekusi: + +```bash +PORT=3120 LOAD_CALLBACKS=30 LOAD_HEARTBEATS=60 LOAD_DYNAMIC_QR=30 LOAD_READS=30 LOAD_CONCURRENCY=10 npm run load:test +``` + +Script ini membuat data load test sementara, menjalankan burst transaction create, callback QRIS paid, heartbeat device, dynamic QR API, dan observability read, lalu membersihkan merchant/transaksi load test. + +Baseline lokal level 2 terakhir: 1610 request, 0 error, durasi 4955.45 ms, throughput perkiraan 324.9 req/s. p95 lokal: transaction create 183.56 ms, callback paid 129.03 ms, heartbeat 90.4 ms, dynamic QR 71.57 ms, observability summary 230.96 ms. + +### Smoke test broker MQTT sungguhan + +```bash +npm run smoke:mqtt-real +``` + +Perintah ini membaca `.env`, menjalankan app lokal dengan `MQTT_PUBLISH_MODE=broker`, subscribe ke broker `devices/+/downlink/#`, lalu memverifikasi publish real untuk payment success, config push, dan dynamic QR response. Test akan membersihkan data merchant/transaksi/MQTT trace yang dibuat khusus smoke. + +Smoke broker real terakhir lulus terhadap `mqtts://mqtt.iptek.co:8883`: broker connect ok, subscribe `devices/+/downlink/#`, menerima payment success, config push, dynamic QR response, dan cleanup data smoke. + +### Async export worker + +Export adjustment settlement memakai worker internal yang aktif default lewat `EXPORT_WORKER_ENABLED=true`. Endpoint create job mengembalikan status `pending`, worker mengambil job secara berkala, lalu client dapat polling `GET /admin/exports/{jobId}` sampai `completed` dan download dari `download_url`. + +UI `admin-reconciliation-management` sudah memakai flow async ini untuk tombol download adjustment report. + +Konfigurasi utama: + +- `EXPORT_WORKER_INTERVAL_MS` +- `EXPORT_WORKER_BATCH_SIZE` +- `EXPORT_JOB_STALE_RUNNING_MS` +- `EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS` +- `EXPORT_STORAGE_DIR` +- `EXPORT_RETENTION_DAYS` + +Status worker dan count job tampil di `/admin/observability/summary`. +Hasil export baru disimpan sebagai file di `EXPORT_STORAGE_DIR`; metadata job tetap di database. Worker juga membersihkan hasil export yang melewati `EXPORT_RETENTION_DAYS`. + +### Provisioning credential MQTT device + +```bash +npm run mqtt:provision-device -- --base-url http://127.0.0.1:3000 --device-id +``` + +Perintah ini rotate credential device lewat API admin dan menampilkan command `mosquitto_passwd` untuk broker. Username MQTT device sama dengan `device_id` agar cocok dengan ACL Mosquitto `devices/%u/...`. + +Validasi/template ACL Mosquitto: + +```bash +npm run mqtt:check-acl -- --print-template +npm run mqtt:check-acl -- --file /etc/mosquitto/acl +``` + +Smoke ACL memakai dua username device test: + +```bash +MQTT_TEST_DEVICE_A_USERNAME= \ +MQTT_TEST_DEVICE_A_PASSWORD= \ +MQTT_TEST_DEVICE_B_USERNAME= \ +npm run smoke:mqtt-acl +``` + +UI device technical detail juga menyediakan tombol `Rotate Credential` dan modal one-time secret untuk operator. + +### Backup dan restore readiness + +```bash +npm run backup:production -- --out ./backups --include-mosquitto +npm run restore:plan -- --backup ./backups/.dump +npm run restore:validate +``` + +`backup:production` membuat dump Postgres format custom dan opsional menyalin file Mosquitto passwd/ACL. `restore:plan` default hanya menampilkan command restore; tambahkan `--execute` hanya pada database restore yang memang sudah disiapkan. `restore:validate` menjalankan migration idempotent, health check, admin deep health, dan cek tabel kunci setelah restore. + +### Load test staging profile + +```bash +npm run load:test:staging +``` + +Profile staging menjalankan transaction create, QRIS callback, heartbeat, dynamic QR, observability read, dan async export job. Set `BASE_URL`, token, dan env database staging sebelum menjalankan terhadap environment target. +Hasil staging disimpan otomatis ke folder `reports/` melalui `LOAD_REPORT_FILE`. + +### UI QA ringan + +```bash +npm run ui:qa +``` + +Checker ini memvalidasi inline script halaman operasional, memastikan export reconciliation memakai async job, dan memastikan navigasi placeholder di halaman operasional utama sudah dibersihkan. + +### Rate limiting dan security polish + +Rate limit in-memory aktif default untuk login admin/merchant, admin write routes, device routes, dan integration callback routes. +Login admin dan merchant juga dicatat ke audit log dengan action `admin.login.success`, `admin.login.failed`, `merchant.login.success`, dan `merchant.login.failed`. +Halaman `/ui/admin-system-audit-logs` sudah membaca audit log real, menyediakan preset login events, filter action/entity/date, pencarian client-side, KPI login success/failed, dan drawer JSON payload. + +Konfigurasi utama: + +- `RATE_LIMIT_ENABLED` +- `RATE_LIMIT_LOGIN_WINDOW_MS` +- `RATE_LIMIT_LOGIN_MAX` +- `RATE_LIMIT_DEVICE_WINDOW_MS` +- `RATE_LIMIT_DEVICE_MAX` +- `RATE_LIMIT_ADMIN_WRITE_WINDOW_MS` +- `RATE_LIMIT_ADMIN_WRITE_MAX` +- `TRUST_PROXY` +- `JSON_BODY_LIMIT` + +Runbook operasional utama: `OPERATIONAL_RUNBOOK.md`. Checklist pilot: `PILOT_EXECUTION_CHECKLIST.md`. Catatan export storage: `EXPORT_STORAGE_READINESS.md`. + +### Membuat admin user production + +```bash +npm run admin:create-user -- \ + --email finance@example.com \ + --name "Finance Ops" \ + --role finance \ + --password "ganti-dengan-password-kuat" +``` + +Role yang tersedia: `admin`, `finance`, `ops`, `support`, dan `viewer`. Untuk update password user yang sudah ada, tambahkan `--rotate-password`. + +### Membuat merchant user production + +```bash +npm run merchant:create-user -- \ + --merchant \ + --email owner@merchant.com \ + --name "Merchant Owner" \ + --role owner \ + --password "ganti-dengan-password-kuat" +``` + +Role merchant yang tersedia: `owner`, `finance`, `ops`, dan `viewer`. Untuk update password user yang sudah ada, tambahkan `--rotate-password`. + +### Device API auth + +Endpoint `/device/*` mendukung credential per-device: + +```bash +curl -X POST http://127.0.0.1:3000/device/heartbeat \ + -H "Content-Type: application/json" \ + -H "X-Device-Id: " \ + -H "X-Device-Secret: " \ + -d '{"device_id":"","timestamp":"2026-05-28T00:00:00.000Z","network_strength":88,"battery_level":77}' +``` + +Credential hanya boleh dipakai untuk `device_id` yang sama. `Authorization: Bearer DEVICE_TOKEN` masih tersedia sebagai fallback/dev compatibility. + +Untuk production, fallback token global bisa dimatikan: + +```env +DEVICE_AUTH_ALLOW_LEGACY_TOKEN=false +``` + ### Endpoint device lain - `POST /device/commands/ack` @@ -124,3 +334,5 @@ Perintah ini menjalankan: 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/status/retry/ack. Catatan Fase 2 ops: endpoint daftar/detail device admin juga mengirim `health_summary` (`status`, `score`, `age_seconds`, `reasons`) untuk membantu triage device. UI device registry dan device technical detail sudah menampilkan health summary, config drift, dan retry config push. + +Catatan credential device: `POST /admin/devices/{id}/credentials/rotate` menerbitkan MQTT username/password satu kali, menyimpan fingerprint secret di database, dan payload device biasa hanya menampilkan ringkasan credential tanpa secret. diff --git a/migrations/001_current_schema_bootstrap.mts b/migrations/001_current_schema_bootstrap.mts new file mode 100644 index 0000000..d01912e --- /dev/null +++ b/migrations/001_current_schema_bootstrap.mts @@ -0,0 +1,5 @@ +import type { ensureSchema } from "../src/shared/db/pool"; + +export async function up(context: { ensureSchema: typeof ensureSchema }) { + await context.ensureSchema(); +} diff --git a/migrations/002_export_jobs.sql b/migrations/002_export_jobs.sql new file mode 100644 index 0000000..480fb30 --- /dev/null +++ b/migrations/002_export_jobs.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS export_jobs ( + id TEXT PRIMARY KEY, + job_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')), + requested_by TEXT, + request_json JSONB NOT NULL DEFAULT '{}'::jsonb, + result_content_type TEXT, + result_filename TEXT, + result_body TEXT, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_export_jobs_status ON export_jobs (status, created_at DESC); diff --git a/migrations/003_export_job_storage.sql b/migrations/003_export_job_storage.sql new file mode 100644 index 0000000..5e956e2 --- /dev/null +++ b/migrations/003_export_job_storage.sql @@ -0,0 +1,6 @@ +ALTER TABLE export_jobs + ADD COLUMN IF NOT EXISTS result_storage_path TEXT, + ADD COLUMN IF NOT EXISTS result_size_bytes INTEGER, + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_export_jobs_expires_at ON export_jobs (expires_at); diff --git a/package-lock.json b/package-lock.json index b24f957..eab4534 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,14 @@ "name": "qris-soundbox-platform", "version": "0.1.0", "dependencies": { + "dotenv": "^17.4.2", "express": "^4.19.2", "helmet": "^7.1.0", - "morgan": "^1.10.0", - "pg": "^8.21.0", - "uuid": "^10.0.0" + "mqtt": "^5.15.1", + "pg": "^8.21.0" }, "devDependencies": { "@types/express": "^4.17.21", - "@types/morgan": "^1.9.9", "@types/node": "^22.7.0", "@types/pg": "^8.20.0", "tsc-watch": "^6.0.4", @@ -25,6 +24,15 @@ "typescript": "^5.6.3" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -963,21 +971,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/morgan": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", - "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1009,6 +1006,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -1042,6 +1048,27 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1081,24 +1108,38 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", "license": "MIT", "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" } }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -1123,6 +1164,48 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/broker-factory": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz", + "integrity": "sha512-L45k5HMbPIrMid0nTOZ/UPXG/c0aRuQKVrSDFIb1zOkvfiyHgYmIjc3cSiN1KwQIvRDOtKE0tfb3I9EZ3CmpQQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1213,6 +1296,41 @@ "node": ">= 6" } }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -1309,6 +1427,18 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1448,6 +1578,24 @@ "through": "~2.3.1" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -1494,6 +1642,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1673,6 +1834,12 @@ "node": ">=16.0.0" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1705,12 +1872,41 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1737,6 +1933,16 @@ "node": ">=10" } }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1767,6 +1973,12 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1852,6 +2064,15 @@ "node": ">= 0.6" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -1865,34 +2086,95 @@ "ufo": "^1.6.3" } }, - "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "node_modules/mqtt": { + "version": "5.15.1", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz", + "integrity": "sha512-V1WnkGuJh3ec9QXzy5Iylw8OOBK+Xu1WhxcQ9mMpLThG+/JZIMV1PgLNRgIiqXhZnvnVLsuyxHl5A/3bHHbcAA==", "license": "MIT", "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.1.0" + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" }, "engines": { - "node": ">= 0.8.0" + "node": ">=16.0.0" } }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", "license": "MIT", "dependencies": { - "ee-first": "1.1.1" + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt-packet/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" }, "engines": { - "node": ">= 0.8" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, + "node_modules/mqtt-packet/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mqtt/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mqtt/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1927,6 +2209,39 @@ "dev": true, "license": "MIT" }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/number-allocator/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/number-allocator/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1961,15 +2276,6 @@ "node": ">= 0.8" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2228,6 +2534,21 @@ "node": ">=0.10.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2296,6 +2617,22 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2320,6 +2657,12 @@ "node": ">=8" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", @@ -2537,6 +2880,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -2588,6 +2955,15 @@ "duplexer": "~0.1.1" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -2723,6 +3099,12 @@ "typescript": "*" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -3317,6 +3699,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3342,7 +3730,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -3354,6 +3741,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -3363,20 +3756,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -3402,6 +3781,74 @@ "node": ">= 8" } }, + "node_modules/worker-factory": { + "version": "7.0.49", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz", + "integrity": "sha512-lW7tpgy6aUv2dFsQhv1yv+XFzdkCf/leoKRTGMPVK5/die6RrUjqgJHJf556qO+ZfytNG6wPXc17E8zzsOLUDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.31", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.31.tgz", + "integrity": "sha512-ngkq5S6JuZyztom8tDgBzorLo9byhBMko/sXfgiUD945AuzKGg1GCgDMCC3NaYkicLpGKXutONM36wEX8UbBCA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.16", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.16.tgz", + "integrity": "sha512-JyP3AvUGyPGbBGW7XiUewm2+0pN/aYo1QpVf5kdXAfkDZcN3p7NbWrG6XnyDEpDIvfHk/+LCnOW/NsuiU9riYA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "broker-factory": "^3.1.14", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.14" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.14", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.14.tgz", + "integrity": "sha512-/qF06C60sXmSLfUl7WglvrDIbspmPOM8UrG63Dnn4bi2x4/DfqHS/+dxF5B+MdHnYO5tVuZYLHdAodrKdabTIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1", + "worker-factory": "^7.0.49" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 94b4a73..d860142 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,32 @@ "start:dist": "node --experimental-specifier-resolution=node dist/index.js", "smoke:cleanup": "node scripts/smoke-cleanup.mjs", "typecheck": "tsc --noEmit", + "db:migrate": "tsx scripts/migrate.mts", + "load:test": "node scripts/load-test.mjs", + "load:test:staging": "node scripts/run-staging-load-report.mjs", "smoke:flow": "node scripts/smoke.mjs", - "smoke:e2e": "bash -c 'export PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform; npm run smoke:cleanup; PORT=3100 ADMIN_TOKEN=admin-dev-token DEVICE_TOKEN=device-dev-token INTEGRATION_WEBHOOK_SECRET=dev-callback-secret npm start > /tmp/qris-smoke-e2e-server.log 2>&1 & echo $! >/tmp/qris-smoke-e2e.pid; for i in $(seq 1 120); do if curl -s -f http://127.0.0.1:3100/health >/dev/null; then break; fi; sleep 0.2; done; npm run smoke:flow; status=$?; kill $(cat /tmp/qris-smoke-e2e.pid) >/dev/null 2>&1 || true; exit $status'" + "smoke:mqtt-real": "PORT=3115 MQTT_PUBLISH_MODE=broker node scripts/smoke-mqtt-real.mjs", + "deploy:check-env": "node scripts/check-production-env.mjs", + "backup:production": "node scripts/backup-production.mjs", + "restore:plan": "node scripts/restore-plan.mjs", + "restore:validate": "node scripts/restore-drill-validate.mjs", + "ui:qa": "node scripts/ui-qa-check.mjs", + "admin:create-user": "node scripts/create-admin-user.mjs", + "merchant:create-user": "node scripts/create-merchant-user.mjs", + "mqtt:provision-device": "node scripts/provision-mqtt-device.mjs", + "mqtt:check-acl": "node scripts/check-mqtt-acl.mjs", + "smoke:mqtt-acl": "node scripts/smoke-mqtt-acl.mjs", + "smoke:e2e": "bash -c 'export PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform; npm run smoke:cleanup; PORT=3100 ADMIN_TOKEN=admin-dev-token DEVICE_TOKEN=device-dev-token INTEGRATION_WEBHOOK_SECRET=dev-callback-secret MQTT_PUBLISH_MODE=simulator npm start > /tmp/qris-smoke-e2e-server.log 2>&1 & echo $! >/tmp/qris-smoke-e2e.pid; for i in $(seq 1 120); do if curl -s -f http://127.0.0.1:3100/health >/dev/null; then break; fi; sleep 0.2; done; npm run smoke:flow; status=$?; kill $(cat /tmp/qris-smoke-e2e.pid) >/dev/null 2>&1 || true; exit $status'" }, "dependencies": { + "dotenv": "^17.4.2", "express": "^4.19.2", "helmet": "^7.1.0", - "morgan": "^1.10.0", - "pg": "^8.21.0", - "uuid": "^10.0.0" + "mqtt": "^5.15.1", + "pg": "^8.21.0" }, "devDependencies": { "@types/express": "^4.17.21", - "@types/morgan": "^1.9.9", "@types/node": "^22.7.0", "@types/pg": "^8.20.0", "tsc-watch": "^6.0.4", diff --git a/scripts/backup-production.mjs b/scripts/backup-production.mjs new file mode 100644 index 0000000..2777bb4 --- /dev/null +++ b/scripts/backup-production.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import "dotenv/config"; + +const args = process.argv.slice(2); + +function getArg(name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(name) { + return args.includes(name); +} + +function usage() { + console.log(`Usage: + node scripts/backup-production.mjs [--out ./backups] [--include-mosquitto] [--dry-run] + +Creates a timestamped PostgreSQL custom-format dump. When --include-mosquitto is set, +copies Mosquitto passwd/ACL files if readable. +`); +} + +if (hasFlag("--help") || hasFlag("-h")) { + usage(); + process.exit(0); +} + +const outDir = path.resolve(process.cwd(), getArg("--out") || process.env.BACKUP_DIR || "./backups"); +const includeMosquitto = hasFlag("--include-mosquitto"); +const dryRun = hasFlag("--dry-run"); +const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); +const dbName = process.env.PGDATABASE || "qris_soundbox_platform"; +const dbBackup = path.join(outDir, `${dbName}-${timestamp}.dump`); +const passwdFile = process.env.MOSQUITTO_PASSWD_FILE || "/etc/mosquitto/passwd"; +const aclFile = process.env.MOSQUITTO_ACL_FILE || "/etc/mosquitto/acl"; + +fs.mkdirSync(outDir, { recursive: true }); + +const pgDumpArgs = ["-Fc", "-f", dbBackup]; +if (process.env.DATABASE_URL) { + pgDumpArgs.push(process.env.DATABASE_URL); +} else { + pgDumpArgs.push("-h", process.env.PGHOST || "127.0.0.1"); + pgDumpArgs.push("-p", String(process.env.PGPORT || 5432)); + pgDumpArgs.push("-U", process.env.PGUSER || "postgres"); + pgDumpArgs.push(dbName); +} + +const plan = { + out_dir: outDir, + database_backup: dbBackup, + include_mosquitto: includeMosquitto, + commands: [`pg_dump ${pgDumpArgs.map((arg) => (arg.includes(" ") ? JSON.stringify(arg) : arg)).join(" ")}`] +}; + +if (dryRun) { + console.log(JSON.stringify({ dry_run: true, ...plan }, null, 2)); + process.exit(0); +} + +const result = spawnSync("pg_dump", pgDumpArgs, { + stdio: "inherit", + env: process.env +}); +if (result.status !== 0) { + throw new Error(`pg_dump failed with status ${result.status}`); +} + +const copied = []; +if (includeMosquitto) { + for (const source of [passwdFile, aclFile]) { + if (!fs.existsSync(source)) { + continue; + } + const target = path.join(outDir, `${path.basename(source)}-${timestamp}`); + fs.copyFileSync(source, target); + copied.push({ source, target }); + } +} + +console.log(JSON.stringify({ ok: true, ...plan, copied }, null, 2)); diff --git a/scripts/check-mqtt-acl.mjs b/scripts/check-mqtt-acl.mjs new file mode 100644 index 0000000..6f00f35 --- /dev/null +++ b/scripts/check-mqtt-acl.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +import fs from "node:fs"; + +const args = process.argv.slice(2); + +function getArg(name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(name) { + return args.includes(name); +} + +function usage() { + console.log(`Usage: + node scripts/check-mqtt-acl.mjs --file /etc/mosquitto/acl + node scripts/check-mqtt-acl.mjs --print-template + +Required Mosquitto ACL rules: + pattern readwrite devices/%u/uplink/# + pattern read devices/%u/downlink/# + user qris-backend + topic read devices/+/uplink/# + topic write devices/+/downlink/# +`); +} + +const requiredLines = [ + "pattern readwrite devices/%u/uplink/#", + "pattern read devices/%u/downlink/#", + "user qris-backend", + "topic read devices/+/uplink/#", + "topic write devices/+/downlink/#" +]; + +if (hasFlag("--help") || hasFlag("-h")) { + usage(); + process.exit(0); +} + +if (hasFlag("--print-template")) { + console.log(`# QRIS Soundbox Mosquitto ACL +# Device username must equal platform device_id. +${requiredLines.join("\n")} +`); + process.exit(0); +} + +const aclFile = getArg("--file") || process.env.MOSQUITTO_ACL_FILE; +if (!aclFile) { + usage(); + process.exit(1); +} + +const content = fs.readFileSync(aclFile, "utf8"); +const normalized = content + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + +const missing = requiredLines.filter((line) => !normalized.includes(line)); +const result = { + file: aclFile, + ok: missing.length === 0, + missing, + required: requiredLines +}; + +console.log(JSON.stringify(result, null, 2)); +process.exit(result.ok ? 0 : 1); diff --git a/scripts/check-production-env.mjs b/scripts/check-production-env.mjs new file mode 100644 index 0000000..bc13eb4 --- /dev/null +++ b/scripts/check-production-env.mjs @@ -0,0 +1,141 @@ +import "dotenv/config"; +import path from "node:path"; + +const required = [ + "ADMIN_SESSION_SECRET", + "MERCHANT_SESSION_SECRET", + "INTEGRATION_WEBHOOK_SECRET", + "DATABASE_URL", + "MQTT_BROKER_URL", + "MQTT_USERNAME", + "MQTT_PASSWORD", + "MQTT_CLIENT_ID", + "EXPORT_STORAGE_DIR" +]; + +const insecureDefaults = new Map([ + ["ADMIN_TOKEN", "admin-dev-token"], + ["MERCHANT_TOKEN", "merchant-dev-token"], + ["DEVICE_TOKEN", "device-dev-token"], + ["MERCHANT_PORTAL_PASSWORD", "merchant"], + ["INTEGRATION_WEBHOOK_SECRET", "dev-callback-secret"], + ["MQTT_PASSWORD", "change-me"], + ["ADMIN_SESSION_SECRET", "change-me-long-random-admin-session-secret"], + ["MERCHANT_SESSION_SECRET", "change-me-long-random-merchant-session-secret"] +]); + +const warnings = []; +const errors = []; + +for (const name of required) { + if (!process.env[name]) { + errors.push(`${name} is required`); + } +} + +for (const [name, value] of insecureDefaults.entries()) { + if (name === "ADMIN_TOKEN" && process.env.ADMIN_AUTH_ALLOW_LEGACY_TOKEN === "false") { + continue; + } + if (name === "DEVICE_TOKEN" && process.env.DEVICE_AUTH_ALLOW_LEGACY_TOKEN === "false") { + continue; + } + if (name === "MERCHANT_TOKEN" && process.env.MERCHANT_AUTH_ALLOW_LEGACY_TOKEN === "false") { + continue; + } + if (name === "MERCHANT_PORTAL_PASSWORD" && process.env.MERCHANT_DEV_LOGIN_ENABLED === "false") { + continue; + } + if (process.env[name] === value) { + errors.push(`${name} still uses the development default`); + } +} + +function requireFalse(name) { + if (process.env[name] !== "false") { + errors.push(`${name} must be false in production`); + } +} + +function requireTrue(name, message) { + if (process.env[name] !== "true") { + errors.push(message || `${name} must be true in production`); + } +} + +requireFalse("ADMIN_AUTH_ALLOW_LEGACY_TOKEN"); +requireFalse("DEVICE_AUTH_ALLOW_LEGACY_TOKEN"); +requireFalse("MERCHANT_AUTH_ALLOW_LEGACY_TOKEN"); + +if (process.env.ADMIN_DEV_LOGIN_ENABLED !== "false") { + errors.push("ADMIN_DEV_LOGIN_ENABLED must be false in production"); +} + +if (process.env.MERCHANT_DEV_LOGIN_ENABLED !== "false") { + errors.push("MERCHANT_DEV_LOGIN_ENABLED must be false in production"); +} + +if (process.env.MQTT_PUBLISH_MODE !== "broker") { + errors.push("MQTT_PUBLISH_MODE must be broker in production"); +} + +if (process.env.MQTT_SUBSCRIBE_ENABLED !== "true") { + warnings.push("MQTT_SUBSCRIBE_ENABLED should be true when device uplink observability is required"); +} + +requireTrue("SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL", "SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL must be true for production finance control"); + +if (process.env.DATABASE_URL && !/^postgres(ql)?:\/\//.test(process.env.DATABASE_URL)) { + errors.push("DATABASE_URL must be a postgres connection string"); +} + +if (process.env.MQTT_BROKER_URL && !/^mqtts:\/\//.test(process.env.MQTT_BROKER_URL)) { + warnings.push("MQTT_BROKER_URL should use mqtts:// for production"); +} + +for (const name of ["ADMIN_SESSION_SECRET", "MERCHANT_SESSION_SECRET", "INTEGRATION_WEBHOOK_SECRET", "MQTT_PASSWORD"]) { + if ((process.env[name] || "").length < 24) { + errors.push(`${name} must be at least 24 characters`); + } +} + +if (process.env.LOG_FORMAT !== "json") { + warnings.push("LOG_FORMAT should be json in production"); +} + +if (process.env.EXPORT_WORKER_ENABLED !== "true") { + errors.push("EXPORT_WORKER_ENABLED must be true in production"); +} + +if (Number(process.env.EXPORT_RETENTION_DAYS || 0) <= 0) { + errors.push("EXPORT_RETENTION_DAYS must be a positive number"); +} + +if (process.env.EXPORT_STORAGE_DIR && !path.isAbsolute(process.env.EXPORT_STORAGE_DIR)) { + errors.push("EXPORT_STORAGE_DIR must be an absolute path in production"); +} + +if (process.env.RATE_LIMIT_ENABLED !== "true") { + errors.push("RATE_LIMIT_ENABLED must be true in production"); +} + +if (process.env.TRUST_PROXY !== "true") { + warnings.push("TRUST_PROXY should be true when running behind a reverse proxy/load balancer"); +} + +if (!process.env.JSON_BODY_LIMIT) { + errors.push("JSON_BODY_LIMIT is required"); +} + +for (const warning of warnings) { + console.warn(`WARN ${warning}`); +} + +if (errors.length) { + for (const error of errors) { + console.error(`ERROR ${error}`); + } + process.exit(1); +} + +console.log("Production environment preflight passed"); diff --git a/scripts/create-admin-user.mjs b/scripts/create-admin-user.mjs new file mode 100644 index 0000000..fe2fb8d --- /dev/null +++ b/scripts/create-admin-user.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +import { randomBytes, scryptSync } from "node:crypto"; +import { Pool } from "pg"; +import "dotenv/config"; + +const args = process.argv.slice(2); +const validRoles = new Set(["admin", "finance", "ops", "support", "viewer"]); + +function getArg(name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(name) { + return args.includes(name); +} + +function usage() { + console.log(`Usage: + npm run admin:create-user -- --email --name --role --password [--inactive] [--rotate-password] + +Examples: + npm run admin:create-user -- --email finance@example.com --name "Finance Ops" --role finance --password "change-this" + npm run admin:create-user -- --email admin@example.com --name "Main Admin" --role admin --password "change-this" --rotate-password + +Environment: + DATABASE_URL or PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE +`); +} + +function validatePasswordPolicy(value) { + const failures = []; + if (value.length < 14) failures.push("at least 14 characters"); + if (!/[a-z]/.test(value)) failures.push("a lowercase letter"); + if (!/[A-Z]/.test(value)) failures.push("an uppercase letter"); + if (!/[0-9]/.test(value)) failures.push("a number"); + if (!/[^A-Za-z0-9]/.test(value)) failures.push("a symbol"); + if (/password|admin|qris|soundbox|change-this/i.test(value)) failures.push("no obvious product/default words"); + return failures; +} + +const email = (getArg("--email") || "").trim().toLowerCase(); +const name = (getArg("--name") || "").trim(); +const roleName = (getArg("--role") || "").trim().toLowerCase(); +const password = getArg("--password") || ""; +const status = hasFlag("--inactive") ? "inactive" : "active"; +const rotatePassword = hasFlag("--rotate-password"); + +if (hasFlag("--help") || hasFlag("-h")) { + usage(); + process.exit(0); +} + +const errors = []; +if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + errors.push("--email must be a valid email address"); +} +if (!name) { + errors.push("--name is required"); +} +if (!validRoles.has(roleName)) { + errors.push("--role must be one of admin, finance, ops, support, viewer"); +} +const passwordFailures = validatePasswordPolicy(password); +if (passwordFailures.length) { + errors.push(`--password must include ${passwordFailures.join(", ")}`); +} +if (errors.length) { + usage(); + for (const error of errors) { + console.error(`ERROR ${error}`); + } + process.exit(1); +} + +function makePool() { + if (process.env.DATABASE_URL) { + return new Pool({ connectionString: process.env.DATABASE_URL }); + } + return new Pool({ + host: process.env.PGHOST || "127.0.0.1", + port: Number(process.env.PGPORT || 5432), + user: process.env.PGUSER || "postgres", + password: process.env.PGPASSWORD || "postgres", + database: process.env.PGDATABASE || "qris_soundbox_platform" + }); +} + +function hashPassword(value) { + const salt = randomBytes(16).toString("hex"); + const keyLength = 64; + const derived = scryptSync(value, salt, keyLength).toString("hex"); + return `scrypt$${salt}$${keyLength}$${derived}`; +} + +function userIdFromEmail(value) { + return `user_${value.replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64)}`; +} + +const pool = makePool(); + +try { + const role = await pool.query("SELECT * FROM roles WHERE name = $1", [roleName]); + if (!role.rows.length) { + throw new Error(`role '${roleName}' not found. Run npm run db:migrate so schema/seed roles are initialized.`); + } + + const existing = await pool.query("SELECT id, email FROM users WHERE LOWER(email) = LOWER($1)", [email]); + const passwordHash = hashPassword(password); + const now = new Date().toISOString(); + + if (existing.rows.length) { + const updatePasswordSql = rotatePassword ? ", password_hash = $6" : ""; + const params = rotatePassword + ? [name, role.rows[0].id, status, now, email, passwordHash] + : [name, role.rows[0].id, status, now, email]; + await pool.query( + `UPDATE users + SET name = $1, + role_id = $2, + status = $3, + created_at = COALESCE(created_at, $4) + ${updatePasswordSql} + WHERE LOWER(email) = LOWER($5)`, + params + ); + console.log(`Updated admin user ${email} role=${roleName} status=${status} password_rotated=${rotatePassword}`); + } else { + await pool.query( + `INSERT INTO users (id, name, email, password_hash, role_id, status, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, + [userIdFromEmail(email), name, email, passwordHash, role.rows[0].id, status, now] + ); + console.log(`Created admin user ${email} role=${roleName} status=${status}`); + } +} finally { + await pool.end(); +} diff --git a/scripts/create-merchant-user.mjs b/scripts/create-merchant-user.mjs new file mode 100644 index 0000000..d8fbdf5 --- /dev/null +++ b/scripts/create-merchant-user.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { randomBytes, randomUUID, scryptSync } from "node:crypto"; +import { Pool } from "pg"; +import "dotenv/config"; + +const args = process.argv.slice(2); +const validRoles = new Set(["owner", "finance", "ops", "viewer"]); + +function getArg(name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(name) { + return args.includes(name); +} + +function usage() { + console.log(`Usage: + npm run merchant:create-user -- --merchant --email --name --role --password [--inactive] [--rotate-password] + +Examples: + npm run merchant:create-user -- --merchant m_abc123 --email owner@merchant.com --name "Merchant Owner" --role owner --password "change-this" + npm run merchant:create-user -- --merchant --email finance@merchant.com --name "Merchant Finance" --role finance --password "change-this" --rotate-password + +Environment: + DATABASE_URL or PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE +`); +} + +function validatePasswordPolicy(value) { + const failures = []; + if (value.length < 14) failures.push("at least 14 characters"); + if (!/[a-z]/.test(value)) failures.push("a lowercase letter"); + if (!/[A-Z]/.test(value)) failures.push("an uppercase letter"); + if (!/[0-9]/.test(value)) failures.push("a number"); + if (!/[^A-Za-z0-9]/.test(value)) failures.push("a symbol"); + if (/password|merchant|qris|soundbox|change-this/i.test(value)) failures.push("no obvious product/default words"); + return failures; +} + +const merchantRef = (getArg("--merchant") || "").trim(); +const email = (getArg("--email") || "").trim().toLowerCase(); +const name = (getArg("--name") || "").trim(); +const roleName = (getArg("--role") || "owner").trim().toLowerCase(); +const password = getArg("--password") || ""; +const status = hasFlag("--inactive") ? "inactive" : "active"; +const rotatePassword = hasFlag("--rotate-password"); + +if (hasFlag("--help") || hasFlag("-h")) { + usage(); + process.exit(0); +} + +const errors = []; +if (!merchantRef) { + errors.push("--merchant is required"); +} +if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + errors.push("--email must be a valid email address"); +} +if (!name) { + errors.push("--name is required"); +} +if (!validRoles.has(roleName)) { + errors.push("--role must be one of owner, finance, ops, viewer"); +} +const passwordFailures = validatePasswordPolicy(password); +if (passwordFailures.length) { + errors.push(`--password must include ${passwordFailures.join(", ")}`); +} +if (errors.length) { + usage(); + for (const error of errors) { + console.error(`ERROR ${error}`); + } + process.exit(1); +} + +function makePool() { + if (process.env.DATABASE_URL) { + return new Pool({ connectionString: process.env.DATABASE_URL }); + } + return new Pool({ + host: process.env.PGHOST || "127.0.0.1", + port: Number(process.env.PGPORT || 5432), + user: process.env.PGUSER || "postgres", + password: process.env.PGPASSWORD || "postgres", + database: process.env.PGDATABASE || "qris_soundbox_platform" + }); +} + +function hashPassword(value) { + const salt = randomBytes(16).toString("hex"); + const keyLength = 64; + const derived = scryptSync(value, salt, keyLength).toString("hex"); + return `scrypt$${salt}$${keyLength}$${derived}`; +} + +const pool = makePool(); + +try { + const merchant = await pool.query( + "SELECT * FROM merchants WHERE id = $1 OR merchant_code = $1 LIMIT 1", + [merchantRef] + ); + if (!merchant.rows.length) { + throw new Error(`merchant '${merchantRef}' not found`); + } + + const merchantId = merchant.rows[0].id; + const existing = await pool.query("SELECT id, email FROM merchant_users WHERE LOWER(email) = LOWER($1)", [email]); + const passwordHash = hashPassword(password); + const now = new Date().toISOString(); + + if (existing.rows.length) { + const updatePasswordSql = rotatePassword ? ", password_hash = $7" : ""; + const params = rotatePassword + ? [merchantId, name, roleName, status, now, email, passwordHash] + : [merchantId, name, roleName, status, now, email]; + await pool.query( + `UPDATE merchant_users + SET merchant_id = $1, + name = $2, + role_name = $3, + status = $4, + updated_at = $5 + ${updatePasswordSql} + WHERE LOWER(email) = LOWER($6)`, + params + ); + console.log(`Updated merchant user ${email} merchant=${merchantId} role=${roleName} status=${status} password_rotated=${rotatePassword}`); + } else { + await pool.query( + `INSERT INTO merchant_users (id, merchant_id, name, email, password_hash, role_name, status, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [`merchant_user_${randomUUID()}`, merchantId, name, email, passwordHash, roleName, status, now, now] + ); + console.log(`Created merchant user ${email} merchant=${merchantId} role=${roleName} status=${status}`); + } +} finally { + await pool.end(); +} diff --git a/scripts/load-test.mjs b/scripts/load-test.mjs new file mode 100644 index 0000000..d4bedce --- /dev/null +++ b/scripts/load-test.mjs @@ -0,0 +1,413 @@ +#!/usr/bin/env node +import { createHmac } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { Pool } from "pg"; +import "dotenv/config"; + +const PORT = process.env.PORT || "3120"; +const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}`; +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "admin-dev-token"; +const DEVICE_TOKEN = process.env.DEVICE_TOKEN || "device-dev-token"; +const SECRET = process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret"; + +const RUN_ID = process.env.LOAD_RUN_ID || `load-${Date.now()}`; +const CALLBACKS = Number(process.env.LOAD_CALLBACKS || 30); +const HEARTBEATS = Number(process.env.LOAD_HEARTBEATS || 60); +const DYNAMIC_QR = Number(process.env.LOAD_DYNAMIC_QR || 30); +const READS = Number(process.env.LOAD_READS || 30); +const EXPORTS = Number(process.env.LOAD_EXPORTS || 0); +const CONCURRENCY = Number(process.env.LOAD_CONCURRENCY || 10); + +const created = { + merchantIds: [], + partnerReferences: [] +}; + +const metrics = new Map(); + +function percentile(values, p) { + if (!values.length) { + return 0; + } + const sorted = [...values].sort((a, b) => a - b); + const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); + return sorted[index]; +} + +function record(label, durationMs, ok) { + const entry = metrics.get(label) || { durations: [], ok: 0, error: 0 }; + entry.durations.push(durationMs); + if (ok) { + entry.ok += 1; + } else { + entry.error += 1; + } + metrics.set(label, entry); +} + +async function req(path, options = {}) { + const startedAt = performance.now(); + const label = options.label || `${options.method || "GET"} ${path}`; + try { + 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; + } + const ok = response.ok; + record(label, performance.now() - startedAt, ok); + if (!ok) { + throw new Error(`${label} failed status=${response.status} body=${typeof body === "string" ? body.slice(0, 120) : JSON.stringify(body).slice(0, 120)}`); + } + return body?.data !== undefined ? body.data : body; + } catch (error) { + record(label, performance.now() - startedAt, false); + throw error; + } +} + +function reqAdmin(path, options = {}) { + return req(path, { + ...options, + headers: { + ...(options.headers || {}), + Authorization: `Bearer ${ADMIN_TOKEN}` + } + }); +} + +function reqDevice(path, options = {}) { + return req(path, { + ...options, + headers: { + ...(options.headers || {}), + Authorization: `Bearer ${DEVICE_TOKEN}` + } + }); +} + +async function runPool(items, concurrency, worker) { + const queue = [...items]; + const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { + while (queue.length) { + const item = queue.shift(); + await worker(item); + } + }); + await Promise.all(workers); +} + +async function waitForHealth() { + for (let i = 0; i < 100; i += 1) { + try { + await req("/health", { label: "health_check" }); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + throw new Error("health check timeout"); +} + +async function createBundle() { + const merchant = await reqAdmin("/admin/merchants", { + method: "POST", + body: { + legal_name: `Load Test Merchant ${RUN_ID}`, + brand_name: `LOAD-${RUN_ID}`, + settlement_account_reference: `bank:${RUN_ID}`, + settlement_account_type: "merchant_bank_account", + payout_mode: "merchant_direct" + }, + label: "setup_create_merchant" + }); + created.merchantIds.push(merchant.id); + + const outlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, { + method: "POST", + body: { name: `Load Outlet ${RUN_ID}` }, + label: "setup_create_outlet" + }); + const terminal = await reqAdmin(`/admin/outlets/${outlet.id}/terminals`, { + method: "POST", + body: { terminal_code: `LOAD-TERM-${RUN_ID}`, qr_mode: "static" }, + label: "setup_create_terminal" + }); + const device = await reqAdmin("/admin/devices", { + method: "POST", + body: { + device_code: `LOAD-DEV-${RUN_ID}`, + vendor: "load", + model: "static", + communication_mode: "mqtt", + status: "active" + }, + label: "setup_create_device" + }); + await reqAdmin(`/admin/devices/${device.id}/bind`, { + method: "POST", + body: { + merchant_id: merchant.id, + outlet_id: outlet.id, + terminal_id: terminal.id + }, + label: "setup_bind_device" + }); + + const dynamicOutlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, { + method: "POST", + body: { name: `Load Dynamic Outlet ${RUN_ID}` }, + label: "setup_create_dynamic_outlet" + }); + const dynamicTerminal = await reqAdmin(`/admin/outlets/${dynamicOutlet.id}/terminals`, { + method: "POST", + body: { terminal_code: `LOAD-DYN-${RUN_ID}`, qr_mode: "dynamic_api" }, + label: "setup_create_dynamic_terminal" + }); + const dynamicDevice = await reqAdmin("/admin/devices", { + method: "POST", + body: { + device_code: `LOAD-DYN-DEV-${RUN_ID}`, + vendor: "load", + model: "dynamic-api", + communication_mode: "api", + capability_profile_json: { + dynamic_qr: { api_direct: true, mqtt: false }, + flows: ["dynamic_qr:api_direct", "static_payment_notification"] + }, + status: "active" + }, + label: "setup_create_dynamic_device" + }); + await reqAdmin(`/admin/devices/${dynamicDevice.id}/bind`, { + method: "POST", + body: { + merchant_id: merchant.id, + outlet_id: dynamicOutlet.id, + terminal_id: dynamicTerminal.id + }, + label: "setup_bind_dynamic_device" + }); + + return { + merchant, + outlet, + terminal, + device, + dynamicOutlet, + dynamicTerminal, + dynamicDevice + }; +} + +async function createTransaction(bundle, index) { + const partnerReference = `LOAD-PR-${RUN_ID}-${index}`; + created.partnerReferences.push(partnerReference); + return reqAdmin("/admin/transactions", { + method: "POST", + body: { + partner_reference: partnerReference, + merchant_id: bundle.merchant.id, + outlet_id: bundle.outlet.id, + terminal_id: bundle.terminal.id, + device_id: bundle.device.id, + amount: 10000 + index, + currency: "IDR", + qr_mode: "static", + initiation_mode: "static", + status: "initiated" + }, + label: "transaction_create" + }); +} + +async function callbackPaid(index) { + const callback = { + partner_reference: `LOAD-PR-${RUN_ID}-${index}`, + partner_txn_id: `LOAD-PTX-${RUN_ID}-${index}`, + amount: 10000 + index, + currency: "IDR", + payment_status: "paid", + status: "paid", + paid_at: new Date().toISOString() + }; + const signature = createHmac("sha256", SECRET).update(JSON.stringify(callback)).digest("hex"); + return req("/integrations/qris/callback", { + method: "POST", + headers: { "X-Partner-Signature": signature }, + body: { ...callback, signature }, + label: "qris_callback_paid" + }); +} + +async function heartbeat(bundle, index) { + return reqDevice("/device/heartbeat", { + method: "POST", + body: { + device_id: bundle.device.id, + timestamp: new Date().toISOString(), + firmware_version: "load-1.0.0", + network_strength: 70 + (index % 20), + battery_level: 60 + (index % 30), + state: "load-test" + }, + label: "device_heartbeat" + }); +} + +async function dynamicQr(bundle, index) { + const requestId = `LOAD-DYN-${RUN_ID}-${index}`; + return reqDevice("/device/transactions/dynamic-qr", { + method: "POST", + headers: { "Idempotency-Key": requestId }, + body: { + device_id: bundle.dynamicDevice.id, + terminal_id: bundle.dynamicTerminal.id, + amount: 15000 + index, + currency: "IDR", + request_id: requestId, + expires_in_seconds: 300 + }, + label: "dynamic_qr_create" + }); +} + +async function exportAdjustments(index) { + const job = await reqAdmin("/admin/exports/settlement-adjustments", { + method: "POST", + body: { limit: 500 }, + label: "export_adjustments_create" + }); + let current = job; + for (let attempt = 0; attempt < 30 && !["completed", "failed"].includes(current.status); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 500)); + current = await reqAdmin(`/admin/exports/${job.id}`, { + label: "export_adjustments_poll" + }); + } + if (current.status !== "completed") { + throw new Error(`export job ${index} ended with status ${current.status}`); + } + await reqAdmin(`/admin/exports/${job.id}/download`, { + label: "export_adjustments_download" + }); +} + +async function cleanup() { + const pool = new Pool( + process.env.DATABASE_URL + ? { connectionString: process.env.DATABASE_URL } + : { + host: process.env.PGHOST || "127.0.0.1", + port: Number(process.env.PGPORT || 5432), + user: process.env.PGUSER || "postgres", + password: process.env.PGPASSWORD || "postgres", + database: process.env.PGDATABASE || "qris_soundbox_platform" + } + ); + try { + const deletedTransactions = await pool.query( + "DELETE FROM transactions WHERE partner_reference = ANY($1::text[]) OR partner_reference LIKE $2 RETURNING id", + [created.partnerReferences, `LOAD-%-${RUN_ID}-%`] + ); + const deletedMerchants = await pool.query( + "DELETE FROM merchants WHERE id = ANY($1::text[]) OR legal_name = $2 RETURNING id", + [created.merchantIds, `Load Test Merchant ${RUN_ID}`] + ); + return { + transactions_deleted: deletedTransactions.rowCount, + merchants_deleted: deletedMerchants.rowCount + }; + } finally { + await pool.end(); + } +} + +function printSummary(startedAt, cleanupResult) { + const totalDurationMs = performance.now() - startedAt; + const rows = [...metrics.entries()].map(([label, entry]) => ({ + label, + ok: entry.ok, + error: entry.error, + count: entry.durations.length, + p50_ms: Number(percentile(entry.durations, 50).toFixed(2)), + p95_ms: Number(percentile(entry.durations, 95).toFixed(2)), + max_ms: Number(Math.max(...entry.durations).toFixed(2)) + })); + const totalOk = rows.reduce((sum, row) => sum + row.ok, 0); + const totalError = rows.reduce((sum, row) => sum + row.error, 0); + const summary = { + run_id: RUN_ID, + base_url: BASE, + config: { + callbacks: CALLBACKS, + heartbeats: HEARTBEATS, + dynamic_qr: DYNAMIC_QR, + reads: READS, + exports: EXPORTS, + concurrency: CONCURRENCY + }, + totals: { + ok: totalOk, + error: totalError, + duration_ms: Number(totalDurationMs.toFixed(2)), + approx_throughput_rps: Number((totalOk / (totalDurationMs / 1000)).toFixed(2)) + }, + metrics: rows, + cleanup: cleanupResult + }; + const serialized = JSON.stringify(summary, null, 2); + console.log(serialized); + if (process.env.LOAD_REPORT_FILE) { + const reportFile = path.resolve(process.cwd(), process.env.LOAD_REPORT_FILE); + fs.mkdirSync(path.dirname(reportFile), { recursive: true }); + fs.writeFileSync(reportFile, `${serialized}\n`, "utf8"); + } +} + +async function main() { + const startedAt = performance.now(); + let cleanupResult = null; + try { + await waitForHealth(); + const bundle = await createBundle(); + + const callbackIndexes = Array.from({ length: CALLBACKS }, (_item, index) => index + 1); + await runPool(callbackIndexes, CONCURRENCY, (index) => createTransaction(bundle, index)); + await runPool(callbackIndexes, CONCURRENCY, callbackPaid); + + await runPool(Array.from({ length: HEARTBEATS }, (_item, index) => index + 1), CONCURRENCY, (index) => + heartbeat(bundle, index) + ); + await runPool(Array.from({ length: DYNAMIC_QR }, (_item, index) => index + 1), CONCURRENCY, (index) => + dynamicQr(bundle, index) + ); + await runPool(Array.from({ length: READS }, (_item, index) => index + 1), CONCURRENCY, async () => { + await reqAdmin("/admin/observability/summary", { label: "observability_summary" }); + }); + await runPool(Array.from({ length: EXPORTS }, (_item, index) => index + 1), Math.min(CONCURRENCY, 5), exportAdjustments); + + cleanupResult = await cleanup(); + printSummary(startedAt, cleanupResult); + } catch (error) { + cleanupResult = await cleanup().catch((cleanupError) => ({ error: cleanupError.message })); + printSummary(startedAt, cleanupResult); + throw error; + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/migrate.mts b/scripts/migrate.mts new file mode 100644 index 0000000..540ec6f --- /dev/null +++ b/scripts/migrate.mts @@ -0,0 +1,112 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { closePool, ensureSchema, getPool } from "../src/shared/db/pool"; + +type MigrationContext = { + ensureSchema: typeof ensureSchema; +}; + +type JsMigrationModule = { + up?: (context: MigrationContext) => Promise; +}; + +const migrationsDir = path.resolve(process.cwd(), "migrations"); + +async function ensureMigrationTable() { + await getPool().query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL, + checksum TEXT, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); +} + +async function listMigrationFiles() { + const entries = await fs.readdir(migrationsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((name) => /^\d+_.+\.(sql|mjs|mts)$/.test(name)) + .sort((a, b) => a.localeCompare(b)); +} + +async function getAppliedMigrationIds() { + const { rows } = await getPool().query("SELECT id FROM schema_migrations ORDER BY id ASC"); + return new Set(rows.map((row) => String(row.id))); +} + +function migrationId(filename: string) { + return filename.split("_")[0]; +} + +async function runSqlMigration(filePath: string) { + const sql = await fs.readFile(filePath, "utf8"); + if (!sql.trim()) { + return; + } + await getPool().query(sql); +} + +async function runJsMigration(filePath: string) { + const moduleUrl = pathToFileURL(filePath).href; + const migration = (await import(moduleUrl)) as JsMigrationModule; + if (typeof migration.up !== "function") { + throw new Error(`${path.basename(filePath)} must export async function up(context)`); + } + await migration.up({ ensureSchema }); +} + +async function recordMigration(id: string, filename: string) { + await getPool().query( + `INSERT INTO schema_migrations (id, filename, applied_at) + VALUES ($1,$2,NOW()) + ON CONFLICT (id) DO NOTHING`, + [id, filename] + ); +} + +async function main() { + await ensureMigrationTable(); + const lock = await getPool().query("SELECT pg_try_advisory_lock($1) AS locked", [7642001]); + if (!lock.rows[0]?.locked) { + throw new Error("another migration process is already running"); + } + + try { + const files = await listMigrationFiles(); + const applied = await getAppliedMigrationIds(); + let appliedCount = 0; + + for (const filename of files) { + const id = migrationId(filename); + if (applied.has(id)) { + console.log(`skip ${filename}`); + continue; + } + + const filePath = path.join(migrationsDir, filename); + console.log(`apply ${filename}`); + if (filename.endsWith(".sql")) { + await runSqlMigration(filePath); + } else { + await runJsMigration(filePath); + } + await recordMigration(id, filename); + appliedCount += 1; + } + + console.log(`migration complete applied=${appliedCount}`); + } finally { + await getPool().query("SELECT pg_advisory_unlock($1)", [7642001]); + await closePool(); + } +} + +main().catch(async (error) => { + console.error(error instanceof Error ? error.message : error); + await closePool(); + process.exit(1); +}); diff --git a/scripts/provision-mqtt-device.mjs b/scripts/provision-mqtt-device.mjs new file mode 100644 index 0000000..e75796a --- /dev/null +++ b/scripts/provision-mqtt-device.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import "dotenv/config"; + +const args = process.argv.slice(2); + +function getArg(name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(name) { + return args.includes(name); +} + +function usage() { + console.log(`Usage: + node scripts/provision-mqtt-device.mjs --device-id [--base-url http://127.0.0.1:3000] [--apply-local] + +Options: + --device-id Device UUID from the platform. + --base-url Running backend URL. Default: BASE_URL env or http://127.0.0.1:3000. + --apply-local Run mosquitto_passwd locally. Use only on the broker host. + +Environment: + ADMIN_TOKEN Admin bearer token. Default: admin-dev-token. + MOSQUITTO_PASSWD_FILE Default: /etc/mosquitto/passwd. + MOSQUITTO_ACL_FILE Default: /etc/mosquitto/acl. +`); +} + +const deviceId = getArg("--device-id"); +const baseUrl = getArg("--base-url") || process.env.BASE_URL || "http://127.0.0.1:3000"; +const adminToken = process.env.ADMIN_TOKEN || "admin-dev-token"; +const passwdFile = process.env.MOSQUITTO_PASSWD_FILE || "/etc/mosquitto/passwd"; +const aclFile = process.env.MOSQUITTO_ACL_FILE || "/etc/mosquitto/acl"; +const applyLocal = hasFlag("--apply-local"); + +if (!deviceId || hasFlag("--help") || hasFlag("-h")) { + usage(); + process.exit(deviceId ? 0 : 1); +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, "'\\''")}'`; +} + +async function rotateCredential() { + const response = await fetch(`${baseUrl}/admin/devices/${deviceId}/credentials/rotate`, { + method: "POST", + headers: { + Authorization: `Bearer ${adminToken}`, + "Content-Type": "application/json" + }, + body: "{}" + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.message || payload?.code || `rotate failed with status ${response.status}`); + } + + return payload.data; +} + +function applyMosquittoPassword(username, password) { + const result = spawnSync("sudo", ["mosquitto_passwd", "-b", passwdFile, username, password], { + stdio: "inherit" + }); + if (result.status !== 0) { + throw new Error(`mosquitto_passwd failed with status ${result.status}`); + } +} + +const data = await rotateCredential(); +const username = data.credential.mqtt_username; +const password = data.credential.mqtt_password; + +if (applyLocal) { + applyMosquittoPassword(username, password); +} + +console.log( + JSON.stringify( + { + device_id: data.device.id, + mqtt_username: username, + mqtt_password: password, + one_time_secret: true, + applied_local: applyLocal, + mosquitto_commands: [ + `sudo mosquitto_passwd -b ${shellQuote(passwdFile)} ${shellQuote(username)} ${shellQuote(password)}`, + `node scripts/check-mqtt-acl.mjs --file ${shellQuote(aclFile)} || node scripts/check-mqtt-acl.mjs --print-template`, + "sudo chown root:mosquitto /etc/mosquitto/passwd", + "sudo chmod 640 /etc/mosquitto/passwd", + "sudo systemctl reload mosquitto" + ], + topic_scope: [ + `device publish/readwrite: devices/${username}/uplink/#`, + `device subscribe/read: devices/${username}/downlink/#`, + "backend read: devices/+/uplink/#", + "backend write: devices/+/downlink/#" + ] + }, + null, + 2 + ) +); diff --git a/scripts/restore-drill-validate.mjs b/scripts/restore-drill-validate.mjs new file mode 100644 index 0000000..db24d44 --- /dev/null +++ b/scripts/restore-drill-validate.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { Pool } from "pg"; +import "dotenv/config"; + +const baseUrl = process.env.BASE_URL || `http://127.0.0.1:${process.env.PORT || 3000}`; +const adminToken = process.env.ADMIN_TOKEN || "admin-dev-token"; +const runMigrate = process.env.RESTORE_DRILL_RUN_MIGRATE !== "false"; + +async function httpCheck(path, headers = {}) { + const response = await fetch(`${baseUrl}${path}`, { headers }); + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + if (!response.ok) { + throw new Error(`${path} failed status=${response.status} body=${text.slice(0, 200)}`); + } + return body?.data !== undefined ? body.data : body; +} + +async function dbCheck() { + const pool = new Pool( + process.env.DATABASE_URL + ? { connectionString: process.env.DATABASE_URL } + : { + host: process.env.PGHOST || "127.0.0.1", + port: Number(process.env.PGPORT || 5432), + user: process.env.PGUSER || "postgres", + password: process.env.PGPASSWORD || "postgres", + database: process.env.PGDATABASE || "qris_soundbox_platform" + } + ); + try { + const tables = await pool.query( + "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('merchants','devices','transactions','export_jobs','schema_migrations')" + ); + return tables.rows.map((row) => row.table_name).sort(); + } finally { + await pool.end(); + } +} + +if (runMigrate) { + const result = spawnSync("npm", ["run", "db:migrate"], { stdio: "inherit", env: process.env }); + if (result.status !== 0) { + throw new Error(`db:migrate failed with status ${result.status}`); + } +} + +const [health, adminHealth, tables] = await Promise.all([ + httpCheck("/health"), + httpCheck("/admin/health/deep", { Authorization: `Bearer ${adminToken}` }), + dbCheck() +]); + +console.log( + JSON.stringify( + { + ok: true, + base_url: baseUrl, + health, + admin_health_status: adminHealth.status, + tables + }, + null, + 2 + ) +); diff --git a/scripts/restore-plan.mjs b/scripts/restore-plan.mjs new file mode 100644 index 0000000..b45ed96 --- /dev/null +++ b/scripts/restore-plan.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import "dotenv/config"; + +const args = process.argv.slice(2); + +function getArg(name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function hasFlag(name) { + return args.includes(name); +} + +function usage() { + console.log(`Usage: + node scripts/restore-plan.mjs --backup ./backups/qris.dump [--execute] + +Default mode prints the restore command only. --execute runs pg_restore against the +configured PGDATABASE/DATABASE_URL target, so use it only on a prepared restore DB. +`); +} + +if (hasFlag("--help") || hasFlag("-h")) { + usage(); + process.exit(0); +} + +const backup = getArg("--backup"); +if (!backup) { + usage(); + process.exit(1); +} + +const backupPath = path.resolve(process.cwd(), backup); +if (!fs.existsSync(backupPath)) { + throw new Error(`backup not found: ${backupPath}`); +} + +const dbName = process.env.PGDATABASE || "qris_soundbox_platform"; +const pgRestoreArgs = ["--clean", "--if-exists", "--no-owner"]; +if (process.env.DATABASE_URL) { + pgRestoreArgs.push("-d", process.env.DATABASE_URL); +} else { + pgRestoreArgs.push("-h", process.env.PGHOST || "127.0.0.1"); + pgRestoreArgs.push("-p", String(process.env.PGPORT || 5432)); + pgRestoreArgs.push("-U", process.env.PGUSER || "postgres"); + pgRestoreArgs.push("-d", dbName); +} +pgRestoreArgs.push(backupPath); + +const command = `pg_restore ${pgRestoreArgs.map((arg) => (arg.includes(" ") ? JSON.stringify(arg) : arg)).join(" ")}`; + +if (!hasFlag("--execute")) { + console.log(JSON.stringify({ execute: false, command, backup: backupPath }, null, 2)); + process.exit(0); +} + +const result = spawnSync("pg_restore", pgRestoreArgs, { + stdio: "inherit", + env: process.env +}); +if (result.status !== 0) { + throw new Error(`pg_restore failed with status ${result.status}`); +} + +console.log(JSON.stringify({ ok: true, backup: backupPath }, null, 2)); diff --git a/scripts/run-staging-load-report.mjs b/scripts/run-staging-load-report.mjs new file mode 100644 index 0000000..6324de3 --- /dev/null +++ b/scripts/run-staging-load-report.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const reportDir = path.resolve(process.cwd(), process.env.LOAD_REPORT_DIR || "./reports"); +const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); +const reportFile = path.join(reportDir, `load-staging-${timestamp}.json`); + +fs.mkdirSync(reportDir, { recursive: true }); + +const env = { + ...process.env, + LOAD_CALLBACKS: process.env.LOAD_CALLBACKS || "500", + LOAD_HEARTBEATS: process.env.LOAD_HEARTBEATS || "1000", + LOAD_DYNAMIC_QR: process.env.LOAD_DYNAMIC_QR || "500", + LOAD_READS: process.env.LOAD_READS || "200", + LOAD_EXPORTS: process.env.LOAD_EXPORTS || "10", + LOAD_CONCURRENCY: process.env.LOAD_CONCURRENCY || "30", + LOAD_REPORT_FILE: reportFile +}; + +const result = spawnSync("node", ["scripts/load-test.mjs"], { + stdio: "inherit", + env +}); + +if (result.status !== 0) { + throw new Error(`staging load report failed with status ${result.status}`); +} + +console.log(JSON.stringify({ ok: true, report_file: reportFile }, null, 2)); diff --git a/scripts/smoke-mqtt-acl.mjs b/scripts/smoke-mqtt-acl.mjs new file mode 100644 index 0000000..21cfcfe --- /dev/null +++ b/scripts/smoke-mqtt-acl.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +import mqtt from "mqtt"; +import "dotenv/config"; + +const brokerUrl = process.env.MQTT_BROKER_URL; +const deviceA = { + username: process.env.MQTT_TEST_DEVICE_A_USERNAME, + password: process.env.MQTT_TEST_DEVICE_A_PASSWORD +}; +const deviceB = { + username: process.env.MQTT_TEST_DEVICE_B_USERNAME, + password: process.env.MQTT_TEST_DEVICE_B_PASSWORD || process.env.MQTT_TEST_DEVICE_A_PASSWORD +}; +const timeoutMs = Number(process.env.MQTT_ACL_SMOKE_TIMEOUT_MS || 8000); + +function usage() { + console.log(`Usage: + MQTT_BROKER_URL=mqtts://broker:8883 \\ + MQTT_TEST_DEVICE_A_USERNAME= \\ + MQTT_TEST_DEVICE_A_PASSWORD= \\ + MQTT_TEST_DEVICE_B_USERNAME= \\ + npm run smoke:mqtt-acl + +This validates Mosquitto ACL behavior from a device credential perspective: + - device A can subscribe/read devices/A/downlink/# + - device A can publish/write devices/A/uplink/# + - device A is denied subscribe/read to devices/B/downlink/# +`); +} + +if (!brokerUrl || !deviceA.username || !deviceA.password || !deviceB.username) { + usage(); + process.exit(1); +} + +function connectDevice(device) { + const client = mqtt.connect(brokerUrl, { + username: device.username, + password: device.password, + clientId: `qris-acl-smoke-${device.username}-${Date.now()}`, + reconnectPeriod: 0, + connectTimeout: timeoutMs + }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + client.end(true); + reject(new Error(`connect timeout for ${device.username}`)); + }, timeoutMs); + client.once("connect", () => { + clearTimeout(timer); + resolve(client); + }); + client.once("error", (error) => { + clearTimeout(timer); + client.end(true); + reject(error); + }); + }); +} + +function subscribe(client, topic) { + return new Promise((resolve, reject) => { + client.subscribe(topic, { qos: 1 }, (error, grants) => { + if (error) { + reject(error); + return; + } + resolve(grants || []); + }); + }); +} + +function publish(client, topic, payload) { + return new Promise((resolve, reject) => { + client.publish(topic, payload, { qos: 1 }, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +function granted(grants) { + return grants.some((grant) => Number(grant.qos) !== 128); +} + +const client = await connectDevice(deviceA); +try { + const ownDownlink = `devices/${deviceA.username}/downlink/#`; + const ownUplink = `devices/${deviceA.username}/uplink/acl-smoke`; + const foreignDownlink = `devices/${deviceB.username}/downlink/#`; + + const ownGrants = await subscribe(client, ownDownlink); + if (!granted(ownGrants)) { + throw new Error(`device ${deviceA.username} could not subscribe own downlink topic`); + } + + await publish( + client, + ownUplink, + JSON.stringify({ type: "acl_smoke", device_id: deviceA.username, timestamp: new Date().toISOString() }) + ); + + const foreignGrants = await subscribe(client, foreignDownlink); + if (granted(foreignGrants)) { + throw new Error(`ACL allows ${deviceA.username} to subscribe foreign downlink ${foreignDownlink}`); + } + + console.log( + JSON.stringify( + { + ok: true, + broker_url: brokerUrl, + device: deviceA.username, + own_downlink: ownDownlink, + own_uplink: ownUplink, + foreign_downlink_denied: foreignDownlink + }, + null, + 2 + ) + ); +} finally { + client.end(true); +} diff --git a/scripts/smoke-mqtt-real.mjs b/scripts/smoke-mqtt-real.mjs new file mode 100644 index 0000000..8298bc7 --- /dev/null +++ b/scripts/smoke-mqtt-real.mjs @@ -0,0 +1,424 @@ +import { spawn } from "node:child_process"; +import { createHmac } from "node:crypto"; +import { Pool } from "pg"; +import mqtt from "mqtt"; +import "dotenv/config"; + +const PORT = process.env.PORT || "3115"; +const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}`; +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "admin-dev-token"; +const DEVICE_TOKEN = process.env.DEVICE_TOKEN || "device-dev-token"; +const SECRET = process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret"; +const BROKER_URL = process.env.MQTT_BROKER_URL; +const MQTT_USERNAME = process.env.MQTT_USERNAME; +const MQTT_PASSWORD = process.env.MQTT_PASSWORD; +const MQTT_CLIENT_ID = `${process.env.MQTT_CLIENT_ID || "qris-platform-backend"}-real-smoke-${Date.now()}`; + +const created = { + merchantIds: [], + dynamicPartnerReferences: [], + mqttCorrelationIds: [] +}; + +function short(data) { + const json = typeof data === "string" ? data : JSON.stringify(data || {}); + return json.length > 220 ? `${json.slice(0, 220)}...` : json; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function req(path, 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.ok) { + throw new Error(`${options._label || path} failed: ${response.status} ${short(body)}`); + } + + console.log(`${options._label || `${options.method || "GET"} ${path}`} => ${response.status} ${short(body)}`); + return body?.data !== undefined ? body.data : body; +} + +function reqAdmin(path, options = {}) { + return req(path, { + ...options, + headers: { + ...(options.headers || {}), + Authorization: `Bearer ${ADMIN_TOKEN}` + } + }); +} + +function reqDevice(path, options = {}) { + return req(path, { + ...options, + headers: { + ...(options.headers || {}), + Authorization: `Bearer ${DEVICE_TOKEN}` + } + }); +} + +async function waitForHealth() { + for (let i = 0; i < 100; i += 1) { + try { + await req("/health", { _label: "GET /health" }); + return; + } catch { + await sleep(200); + } + } + throw new Error("server health timeout"); +} + +function waitForMqtt(client) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("MQTT_CONNECT_TIMEOUT")), 10000); + client.once("connect", () => { + clearTimeout(timer); + resolve(); + }); + client.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function subscribe(client, topic) { + return new Promise((resolve, reject) => { + client.subscribe(topic, { qos: 1 }, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +function waitForMessage(messages, predicate, label) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} message timeout`)), 12000); + const interval = setInterval(() => { + const found = messages.find(predicate); + if (found) { + clearInterval(interval); + clearTimeout(timer); + resolve(found); + } + }, 100); + }); +} + +async function cleanup() { + const pool = new Pool( + process.env.DATABASE_URL + ? { connectionString: process.env.DATABASE_URL } + : { + host: process.env.PGHOST || "127.0.0.1", + port: Number(process.env.PGPORT || 5432), + user: process.env.PGUSER || "postgres", + password: process.env.PGPASSWORD || "postgres", + database: process.env.PGDATABASE || "qris_soundbox_platform" + } + ); + + try { + const dynamic = await pool.query( + "DELETE FROM transactions WHERE partner_reference = ANY($1::text[]) RETURNING id", + [created.dynamicPartnerReferences] + ); + const merchants = await pool.query( + "DELETE FROM merchants WHERE id = ANY($1::text[]) OR legal_name LIKE 'Real MQTT Smoke Merchant %' RETURNING id", + [created.merchantIds] + ); + const mqttMessages = await pool.query( + "DELETE FROM mqtt_messages WHERE correlation_id = ANY($1::text[]) OR payload_json::text LIKE '%real-mqtt-smoke-%' RETURNING id", + [created.mqttCorrelationIds] + ); + console.log( + `cleanup => dynamic_transactions=${dynamic.rowCount} merchants=${merchants.rowCount} mqtt_messages=${mqttMessages.rowCount}` + ); + } finally { + await pool.end(); + } +} + +async function createMerchantBundle(ts, suffix, terminalMode = "static", deviceMode = "mqtt", capability = {}) { + const merchant = await reqAdmin("/admin/merchants", { + method: "POST", + body: { + legal_name: `Real MQTT Smoke Merchant ${suffix} ${ts}`, + brand_name: `RMS-${suffix}-${ts}`, + settlement_account_reference: `bank:${suffix}:${ts}`, + settlement_account_type: "merchant_bank_account", + payout_mode: "merchant_direct" + }, + _label: `POST /admin/merchants ${suffix}` + }); + created.merchantIds.push(merchant.id); + + const outlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, { + method: "POST", + body: { name: `Real MQTT Smoke Outlet ${suffix} ${ts}` }, + _label: `POST /admin/merchants/:id/outlets ${suffix}` + }); + + const terminal = await reqAdmin(`/admin/outlets/${outlet.id}/terminals`, { + method: "POST", + body: { terminal_code: `RMS-${suffix}-TERM-${ts}`, qr_mode: terminalMode }, + _label: `POST /admin/outlets/:id/terminals ${suffix}` + }); + + const device = await reqAdmin("/admin/devices", { + method: "POST", + body: { + device_code: `RMS-${suffix}-DEV-${ts}`, + vendor: "smoke", + model: "mqtt-real", + communication_mode: deviceMode, + status: "active", + capability_profile_json: capability + }, + _label: `POST /admin/devices ${suffix}` + }); + + await reqAdmin(`/admin/devices/${device.id}/bind`, { + method: "POST", + body: { + merchant_id: merchant.id, + outlet_id: outlet.id, + terminal_id: terminal.id + }, + _label: `POST /admin/devices/:id/bind ${suffix}` + }); + + return { merchant, outlet, terminal, device }; +} + +async function main() { + if (!BROKER_URL || !MQTT_USERNAME || !MQTT_PASSWORD) { + throw new Error("MQTT_BROKER_URL, MQTT_USERNAME, and MQTT_PASSWORD are required"); + } + + const server = spawn("npm", ["start"], { + cwd: process.cwd(), + env: { + ...process.env, + PORT, + MQTT_PUBLISH_MODE: "broker" + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + const serverLog = []; + server.stdout.on("data", (chunk) => serverLog.push(chunk.toString())); + server.stderr.on("data", (chunk) => serverLog.push(chunk.toString())); + + const client = mqtt.connect(BROKER_URL, { + clientId: MQTT_CLIENT_ID, + username: MQTT_USERNAME, + password: MQTT_PASSWORD, + connectTimeout: 10000, + reconnectPeriod: 0, + clean: true + }); + + const messages = []; + client.on("message", (topic, payload) => { + let parsed; + try { + parsed = JSON.parse(payload.toString()); + } catch { + parsed = payload.toString(); + } + messages.push({ topic, payload: parsed }); + }); + + try { + await Promise.all([waitForHealth(), waitForMqtt(client)]); + await subscribe(client, "devices/+/downlink/#"); + console.log(`MQTT subscribe => devices/+/downlink/#`); + + const ts = Date.now(); + + const staticBundle = await createMerchantBundle(ts, "PAY", "static", "mqtt", { + flows: ["static_payment_notification"] + }); + await reqDevice("/device/heartbeat", { + method: "POST", + body: { + device_id: staticBundle.device.id, + timestamp: new Date().toISOString(), + network_strength: 88, + battery_level: 77, + state: "idle" + }, + _label: "POST /device/heartbeat payment" + }); + + const partnerReference = `REAL-MQTT-SMOKE-PR-${ts}`; + const paymentTx = await reqAdmin("/admin/transactions", { + method: "POST", + body: { + partner_reference: partnerReference, + merchant_id: staticBundle.merchant.id, + outlet_id: staticBundle.outlet.id, + terminal_id: staticBundle.terminal.id, + device_id: staticBundle.device.id, + amount: 32100, + currency: "IDR", + qr_mode: "static", + initiation_mode: "static", + status: "initiated" + }, + _label: "POST /admin/transactions payment" + }); + + const callback = { + partner_reference: partnerReference, + partner_txn_id: `REAL-MQTT-SMOKE-PTX-${ts}`, + amount: 32100, + currency: "IDR", + payment_status: "paid", + status: "paid", + paid_at: new Date().toISOString() + }; + const signature = createHmac("sha256", SECRET).update(JSON.stringify(callback)).digest("hex"); + await req("/integrations/qris/callback", { + method: "POST", + headers: { "X-Partner-Signature": signature }, + body: { ...callback, signature }, + _label: "POST /integrations/qris/callback payment" + }); + + const paymentMessage = await waitForMessage( + messages, + (item) => + item.topic === `devices/${staticBundle.device.id}/downlink/payment/success` && + item.payload?.message_type === "payment_success" && + item.payload?.transaction_id === paymentTx.id, + "payment success" + ); + + const apiBundle = await createMerchantBundle(ts, "CFG", "dynamic_api", "api", { + features: { dynamic_qr: { api_direct: true } }, + flows: ["dynamic_qr:api_direct"] + }); + const configVersion = Math.floor(Date.now() / 1000); + await reqAdmin(`/admin/devices/${apiBundle.device.id}/config`, { + method: "PATCH", + body: { + config_version: configVersion, + settings: { + volume: 62, + language: "id-ID", + heartbeat_interval_seconds: 45, + test_marker: `real-mqtt-smoke-${configVersion}` + } + }, + _label: "PATCH /admin/devices/:id/config" + }); + + const configMessage = await waitForMessage( + messages, + (item) => + item.topic === `devices/${apiBundle.device.id}/downlink/config/push` && + item.payload?.message_type === "config_push" && + item.payload?.config_version === configVersion, + "config push" + ); + + const mqttBundle = await createMerchantBundle(ts, "DYN", "dynamic_mqtt", "mqtt", { + features: { dynamic_qr: { mqtt: true } }, + flows: ["dynamic_qr:mqtt"] + }); + const requestId = `REAL-MQTT-SMOKE-DYN-${ts}`; + created.mqttCorrelationIds.push(requestId); + await reqDevice("/device/mqtt/uplink/dynamic-qr/request", { + method: "POST", + body: { + message_type: "dynamic_qr_request", + device_id: mqttBundle.device.id, + terminal_id: mqttBundle.terminal.id, + amount: 12345, + currency: "IDR", + request_id: requestId + }, + _label: "POST /device/mqtt/uplink/dynamic-qr/request" + }); + created.dynamicPartnerReferences.push(`DYN-${requestId}`); + + const dynamicMessage = await waitForMessage( + messages, + (item) => + item.topic === `devices/${mqttBundle.device.id}/downlink/dynamic-qr/response` && + item.payload?.message_type === "dynamic_qr_response" && + item.payload?.correlation_id === requestId, + "dynamic QR response" + ); + + console.log( + JSON.stringify( + { + broker_connect: "ok", + subscribed_topic: "devices/+/downlink/#", + payment_success: { + topic: paymentMessage.topic, + transaction_id: paymentMessage.payload.transaction_id, + amount: paymentMessage.payload.amount + }, + config_push: { + topic: configMessage.topic, + config_version: configMessage.payload.config_version + }, + dynamic_qr_response: { + topic: dynamicMessage.topic, + transaction_id: dynamicMessage.payload.transaction_id, + correlation_id: dynamicMessage.payload.correlation_id + }, + received_messages_count: messages.length + }, + null, + 2 + ) + ); + } finally { + client.end(true); + server.kill("SIGTERM"); + await sleep(500); + await cleanup(); + if (server.exitCode === null) { + server.kill("SIGKILL"); + } + if (serverLog.length) { + process.env.SMOKE_MQTT_REAL_DEBUG === "true" && console.log(serverLog.join("")); + } + } + process.exit(0); +} + +main().catch(async (error) => { + console.error(error instanceof Error ? error.message : error); + await cleanup().catch(() => undefined); + process.exit(1); +}); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index ca3d521..d45a707 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -3,6 +3,8 @@ import { createHmac } from "node:crypto"; const PORT = process.env.PORT || "3100"; const BASE = process.env.BASE_URL || `http://127.0.0.1:${PORT}`; const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "admin-dev-token"; +const MERCHANT_TOKEN = process.env.MERCHANT_TOKEN || "merchant-dev-token"; +const MERCHANT_PORTAL_PASSWORD = process.env.MERCHANT_PORTAL_PASSWORD || "merchant"; const DEVICE_TOKEN = process.env.DEVICE_TOKEN || "device-dev-token"; const SECRET = process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret"; @@ -67,10 +69,32 @@ async function reqAdmin(path, opts = {}) { return req(path, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${ADMIN_TOKEN}` } }); } +async function reqMerchant(path, merchantId, opts = {}) { + return req(path, { + ...opts, + headers: { + ...(opts.headers || {}), + Authorization: `Bearer ${MERCHANT_TOKEN}`, + 'X-Merchant-Id': merchantId + } + }); +} + async function reqDevice(path, opts = {}) { return req(path, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${DEVICE_TOKEN}` } }); } +async function reqDeviceCredential(path, deviceId, secret, opts = {}) { + return req(path, { + ...opts, + headers: { + ...(opts.headers || {}), + 'X-Device-Id': deviceId, + 'X-Device-Secret': secret + } + }); +} + (async () => { await req('/health', { _label: 'GET /health' }); await req('/admin/login', { method: 'POST', body: { username: 'admin', password: 'admin' }, _label: 'POST /admin/login' }); @@ -141,6 +165,28 @@ async function reqDevice(path, opts = {}) { _label: 'POST /device/heartbeat' }); + const credential = await reqAdmin(`/admin/devices/${deviceId}/credentials/rotate`, { + method: 'POST', + body: {}, + _label: 'POST /admin/devices/:id/credentials/rotate' + }); + const deviceSecret = credential?.data?.credential?.mqtt_password; + if (!deviceSecret) { + throw new Error('device credential rotate did not return one-time password'); + } + await reqDeviceCredential('/device/heartbeat', deviceId, deviceSecret, { + method: 'POST', + body: { + device_id: deviceId, + timestamp: new Date().toISOString(), + firmware_version: '1.2.4', + network_strength: 89, + battery_level: 76, + state: 'credential-auth' + }, + _label: 'POST /device/heartbeat credential auth' + }); + const tx = await reqAdmin('/admin/transactions', { method: 'POST', body: { @@ -194,6 +240,163 @@ async function reqDevice(path, opts = {}) { 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' }); + const settlementBatchCreate = await reqAdmin('/admin/settlement-batches', { + method: 'POST', + body: { + merchant_id: merchantId, + cutoff_at: new Date().toISOString() + }, + _label: 'POST /admin/settlement-batches' + }); + const settlementBatch = settlementBatchCreate?.data?.batches?.[0]; + if (!settlementBatch || settlementBatch.entry_count < 1 || Number(settlementBatch.net_payable_amount) <= 0) { + throw new Error('settlement batch did not include merchant payable entries'); + } + await reqAdmin('/admin/settlement-batches?status=created', { _label: 'GET /admin/settlement-batches' }); + await reqAdmin(`/admin/settlement-batches/${settlementBatch.id}`, { _label: 'GET /admin/settlement-batches/:id' }); + const settlementCsv = await req(`/admin/settlement-batches/${settlementBatch.id}/export.csv`, { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + _label: 'GET /admin/settlement-batches/:id/export.csv' + }); + if (!String(settlementCsv).includes('batch_code,batch_status,merchant_id')) { + throw new Error('settlement CSV export missing expected header'); + } + const settlementBankCsv = await req(`/admin/settlement-batches/${settlementBatch.id}/export.csv?format=bank_generic`, { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + _label: 'GET /admin/settlement-batches/:id/export.csv bank_generic' + }); + if (!String(settlementBankCsv).includes('transfer_type,beneficiary_account_reference,beneficiary_account_type')) { + throw new Error('settlement bank generic CSV export missing expected header'); + } + await reqAdmin(`/admin/settlement-batches/${settlementBatch.id}/mark-paid`, { + method: 'POST', + body: { + paid_at: new Date().toISOString(), + paid_reference: `SMOKE-PAYOUT-${ts}`, + paid_note: 'Smoke payout reconciliation note' + }, + _label: 'POST /admin/settlement-batches/:id/mark-paid' + }); + const settlementDetailWithEvents = await reqAdmin(`/admin/settlement-batches/${settlementBatch.id}`, { + _label: 'GET /admin/settlement-batches/:id events' + }); + const settlementEvents = settlementDetailWithEvents?.data?.events || []; + if (!settlementEvents.some((event) => event.event_type === 'created') || !settlementEvents.some((event) => event.event_type === 'marked_paid')) { + throw new Error('settlement payout event history missing created or marked_paid event'); + } + await reqExpect(`/admin/settlement-batches/${settlementBatch.id}/mark-paid`, 409, { + method: 'POST', + body: {}, + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + _label: 'POST /admin/settlement-batches/:id/mark-paid duplicate' + }); + await reqAdmin(`/admin/settlement-batches/${settlementBatch.id}/reference`, { + method: 'PATCH', + body: { + paid_reference: `SMOKE-PAYOUT-UPDATED-${ts}`, + paid_note: 'Smoke payout reference correction' + }, + _label: 'PATCH /admin/settlement-batches/:id/reference' + }); + const settlementDetailAfterReference = await reqAdmin(`/admin/settlement-batches/${settlementBatch.id}`, { + _label: 'GET /admin/settlement-batches/:id reference-updated' + }); + if ( + settlementDetailAfterReference?.data?.batch?.metadata_json?.paid_reference !== `SMOKE-PAYOUT-UPDATED-${ts}` || + !settlementDetailAfterReference?.data?.events?.some((event) => event.event_type === 'reference_updated') + ) { + throw new Error('settlement reference update did not persist metadata or event history'); + } + await reqAdmin(`/admin/settlement-batches/${settlementBatch.id}/adjustments`, { + method: 'POST', + body: { + adjustment_type: 'debit', + amount: 100, + reason: 'Smoke reconciliation fee correction', + note: 'Smoke adjustment event' + }, + _label: 'POST /admin/settlement-batches/:id/adjustments' + }); + const settlementDetailAfterAdjustment = await reqAdmin(`/admin/settlement-batches/${settlementBatch.id}`, { + _label: 'GET /admin/settlement-batches/:id adjustment-recorded' + }); + if ( + Number(settlementDetailAfterAdjustment?.data?.batch?.metadata_json?.total_adjustment_amount || 0) !== -100 || + !Array.isArray(settlementDetailAfterAdjustment?.data?.adjustments) || + settlementDetailAfterAdjustment.data.adjustments.length < 1 || + Number(settlementDetailAfterAdjustment.data.adjustments[0]?.signed_amount || 0) !== -100 || + !settlementDetailAfterAdjustment?.data?.events?.some((event) => event.event_type === 'adjustment_recorded') + ) { + throw new Error('settlement adjustment did not persist metadata or event history'); + } + const settlementAdjustmentReport = await reqAdmin(`/admin/settlement-adjustments?merchant_id=${merchantId}&limit=20`, { + _label: 'GET /admin/settlement-adjustments' + }); + if ( + !Array.isArray(settlementAdjustmentReport?.data?.rows) || + settlementAdjustmentReport.data.rows.length < 1 || + Number(settlementAdjustmentReport.data.signed_amount || 0) !== -100 || + !settlementAdjustmentReport.data.rows.some((row) => row.batch_id === settlementBatch.id && Number(row.signed_amount || 0) === -100) + ) { + throw new Error('settlement adjustment report missing expected row or totals'); + } + const settlementAdjustmentCsv = await req(`/admin/settlement-adjustments/export.csv?merchant_id=${merchantId}&limit=20`, { + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + _label: 'GET /admin/settlement-adjustments/export.csv' + }); + if ( + !String(settlementAdjustmentCsv).includes('adjustment_id,batch_id,batch_code') || + !String(settlementAdjustmentCsv).includes('Smoke reconciliation fee correction') + ) { + throw new Error('settlement adjustment CSV export missing expected header or row'); + } + const merchantLogin = await req('/merchant/login', { + method: 'POST', + body: { username: merchantId, password: MERCHANT_PORTAL_PASSWORD }, + _label: 'POST /merchant/login' + }); + if (merchantLogin?.data?.merchant?.id !== merchantId || !merchantLogin?.data?.token) { + throw new Error('merchant login did not return expected merchant session'); + } + const merchantSummary = await reqMerchant('/merchant/settlement-summary', merchantId, { + _label: 'GET /merchant/settlement-summary' + }); + const merchantPaidAmount = Number(merchantSummary?.data?.paid_amount || 0); + const merchantAdjustmentAmount = Number(merchantSummary?.data?.adjustment_amount || 0); + const merchantAdjustedPaidAmount = Number(merchantSummary?.data?.adjusted_paid_amount || 0); + if ( + merchantPaidAmount < Number(settlementBatch.net_payable_amount || 0) || + merchantAdjustmentAmount !== -100 || + Math.abs(merchantAdjustedPaidAmount - (merchantPaidAmount - 100)) > 0.01 + ) { + throw new Error('merchant settlement summary missing paid amount'); + } + await reqMerchant('/merchant/settlement-batches', merchantId, { + _label: 'GET /merchant/settlement-batches' + }); + const merchantBatchDetail = await reqMerchant(`/merchant/settlement-batches/${settlementBatch.id}`, merchantId, { + _label: 'GET /merchant/settlement-batches/:id' + }); + if (!merchantBatchDetail?.data?.events?.some((event) => event.event_type === 'marked_paid')) { + throw new Error('merchant settlement batch detail missing payout event history'); + } + if (!Array.isArray(merchantBatchDetail?.data?.adjustments) || merchantBatchDetail.data.adjustments.length < 1) { + throw new Error('merchant settlement batch detail missing formal adjustment rows'); + } + const merchantSettlementCsv = await req(`/merchant/settlement-batches/${settlementBatch.id}/export.csv`, { + headers: { Authorization: `Bearer ${MERCHANT_TOKEN}`, 'X-Merchant-Id': merchantId }, + _label: 'GET /merchant/settlement-batches/:id/export.csv' + }); + if (!String(merchantSettlementCsv).includes('batch_code,batch_status,merchant_id')) { + throw new Error('merchant settlement CSV export missing expected header'); + } + const merchantSettlementBankCsv = await req(`/merchant/settlement-batches/${settlementBatch.id}/export.csv?format=bank_generic`, { + headers: { Authorization: `Bearer ${MERCHANT_TOKEN}`, 'X-Merchant-Id': merchantId }, + _label: 'GET /merchant/settlement-batches/:id/export.csv bank_generic' + }); + if (!String(merchantSettlementBankCsv).includes('transfer_type,beneficiary_account_reference,beneficiary_account_type')) { + throw new Error('merchant settlement bank generic CSV export missing expected header'); + } 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' }); @@ -204,7 +407,20 @@ async function reqDevice(path, opts = {}) { body: {}, _label: 'POST /admin/transactions/:id/retry-notification' }); - await reqAdmin('/admin/dashboard/summary', { _label: 'GET /admin/dashboard/summary' }); + const dashboardSummary = await reqAdmin('/admin/dashboard/summary', { _label: 'GET /admin/dashboard/summary' }); + const dashboardData = dashboardSummary?.data || {}; + const dashboardPaidAmount = Number(dashboardData.settlement_paid_amount || 0); + const dashboardAdjustmentAmount = Number(dashboardData.settlement_adjustment_amount || 0); + const dashboardAdjustedPaidAmount = Number(dashboardData.settlement_adjusted_paid_amount || 0); + if ( + dashboardPaidAmount < Number(settlementBatch.net_payable_amount || 0) || + dashboardAdjustmentAmount !== -100 || + Math.abs(dashboardAdjustedPaidAmount - (dashboardPaidAmount - 100)) > 0.01 || + Number(dashboardData.settlement_paid_batches || 0) < 1 || + Number(dashboardData.settlement_total_batches || 0) < 1 + ) { + throw new Error('dashboard settlement finance summary missing paid settlement aggregate'); + } const noBindingOutlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, { method: 'POST', @@ -253,6 +469,129 @@ async function reqDevice(path, opts = {}) { _label: 'GET /admin/ledger-entries no-binding' }); await reqAdmin('/admin/notifications/failed', { _label: 'GET /admin/notifications/failed no-binding' }); + const failedBatchCreate = await reqAdmin('/admin/settlement-batches', { + method: 'POST', + body: { + merchant_id: merchantId, + cutoff_at: new Date().toISOString() + }, + _label: 'POST /admin/settlement-batches failed-case' + }); + const failedBatch = failedBatchCreate?.data?.batches?.[0]; + if (!failedBatch) { + throw new Error('failed settlement case did not create a batch'); + } + await reqAdmin(`/admin/settlement-batches/${failedBatch.id}/mark-failed`, { + method: 'POST', + body: { + reason: 'Smoke payout rail rejected transfer', + note: 'Smoke failed settlement lifecycle' + }, + _label: 'POST /admin/settlement-batches/:id/mark-failed' + }); + const failedBatchDetail = await reqAdmin(`/admin/settlement-batches/${failedBatch.id}`, { + _label: 'GET /admin/settlement-batches/:id failed' + }); + if ( + failedBatchDetail?.data?.batch?.status !== 'failed' || + !failedBatchDetail?.data?.events?.some((event) => event.event_type === 'failed') + ) { + throw new Error('settlement failed lifecycle did not persist failed status/event'); + } + const reprocessedFailed = await reqAdmin(`/admin/settlement-batches/${failedBatch.id}/reprocess`, { + method: 'POST', + body: {}, + _label: 'POST /admin/settlement-batches/:id/reprocess failed' + }); + const reprocessedFailedBatch = reprocessedFailed?.data?.new_batch; + if (!reprocessedFailedBatch || reprocessedFailedBatch.status !== 'created') { + throw new Error('failed settlement reprocess did not create a new created batch'); + } + const reprocessedFailedSource = await reqAdmin(`/admin/settlement-batches/${failedBatch.id}`, { + _label: 'GET /admin/settlement-batches/:id reprocessed-source' + }); + if (!reprocessedFailedSource?.data?.events?.some((event) => event.event_type === 'reprocessed')) { + throw new Error('failed settlement reprocess did not create source reprocessed event'); + } + await reqExpect(`/admin/settlement-batches/${failedBatch.id}/reprocess`, 409, { + method: 'POST', + body: {}, + headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, + _label: 'POST /admin/settlement-batches/:id/reprocess duplicate' + }); + + const cancelTx = await reqAdmin('/admin/transactions', { + method: 'POST', + body: { + partner_reference: `PR-CANCEL-${ts}`, + merchant_id: merchantId, + outlet_id: outletId, + terminal_id: terminalId, + amount: 7700, + currency: 'IDR', + qr_mode: 'static', + initiation_mode: 'static', + status: 'initiated' + }, + _label: 'POST /admin/transactions cancel-settlement' + }); + const cancelCallback = { + partner_reference: `PR-CANCEL-${ts}`, + partner_txn_id: `PTX-CANCEL-${ts}`, + amount: 7700, + currency: 'IDR', + payment_status: 'paid', + status: 'paid', + paid_at: new Date().toISOString() + }; + const cancelSignature = createHmac('sha256', SECRET).update(JSON.stringify(cancelCallback)).digest('hex'); + await req('/integrations/qris/callback', { + method: 'POST', + headers: { 'X-Partner-Signature': cancelSignature }, + body: { ...cancelCallback, signature: cancelSignature }, + _label: 'POST /integrations/qris/callback cancel-settlement' + }); + const cancelBatchCreate = await reqAdmin('/admin/settlement-batches', { + method: 'POST', + body: { + merchant_id: merchantId, + cutoff_at: new Date().toISOString() + }, + _label: 'POST /admin/settlement-batches cancel-case' + }); + const cancelBatch = cancelBatchCreate?.data?.batches?.[0]; + if (!cancelBatch) { + throw new Error('cancel settlement case did not create a batch'); + } + await reqAdmin(`/admin/settlement-batches/${cancelBatch.id}/cancel`, { + method: 'POST', + body: { + reason: 'Smoke duplicate manual payout batch', + note: `cancel tx ${cancelTx?.data?.id}` + }, + _label: 'POST /admin/settlement-batches/:id/cancel' + }); + const cancelBatchDetail = await reqAdmin(`/admin/settlement-batches/${cancelBatch.id}`, { + _label: 'GET /admin/settlement-batches/:id cancelled' + }); + if ( + cancelBatchDetail?.data?.batch?.status !== 'cancelled' || + !cancelBatchDetail?.data?.events?.some((event) => event.event_type === 'cancelled') + ) { + throw new Error('settlement cancel lifecycle did not persist cancelled status/event'); + } + const reconciliationReport = await reqAdmin('/admin/reconciliation/settlement-batches?limit=50', { + _label: 'GET /admin/reconciliation/settlement-batches' + }); + const reconciliationData = reconciliationReport?.data || {}; + if ( + !Array.isArray(reconciliationData.rows) || + !Number.isFinite(Number(reconciliationData.total_batches)) || + !Number.isFinite(Number(reconciliationData.mismatch_batches)) || + Number(reconciliationData.total_batches) < 1 + ) { + throw new Error('settlement reconciliation report missing expected aggregate rows'); + } const dynamicOutlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, { method: 'POST', @@ -303,6 +642,21 @@ async function reqDevice(path, opts = {}) { }, _label: 'POST /device/transactions/dynamic-qr unsupported device' }); + await reqExpect('/device/heartbeat', 403, { + method: 'POST', + headers: { + 'X-Device-Id': deviceId, + 'X-Device-Secret': deviceSecret + }, + body: { + device_id: dynamicDeviceId, + timestamp: new Date().toISOString(), + network_strength: 80, + battery_level: 70, + state: 'wrong-device' + }, + _label: 'POST /device/heartbeat credential wrong device' + }); const dynamicRequestId = `DYN-REQ-${ts}`; const dynamicQr = await reqDevice('/device/transactions/dynamic-qr', { method: 'POST', diff --git a/scripts/ui-qa-check.mjs b/scripts/ui-qa-check.mjs new file mode 100644 index 0000000..f4937cf --- /dev/null +++ b/scripts/ui-qa-check.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const pages = [ + "ui/admin-reconciliation-management/index.html", + "ui/admin-system-audit-logs/index.html", + "ui/settlement-batch-management/index.html", + "ui/merchant-settlement-history/index.html", + "ui/device-technical-detail/index.html" +]; + +const checks = []; +const warnings = []; +const errors = []; +const scriptRe = new RegExp("]*)?>([\\s\\S]*?)", "gi"); + +function check(condition, message) { + checks.push({ ok: Boolean(condition), message }); + if (!condition) { + errors.push(message); + } +} + +function warn(condition, message) { + if (!condition) { + warnings.push(message); + } +} + +for (const page of pages) { + const filePath = path.resolve(process.cwd(), page); + const html = fs.readFileSync(filePath, "utf8"); + const scripts = [...html.matchAll(scriptRe)].map((match) => match[1].trim()).filter(Boolean); + for (const [index, script] of scripts.entries()) { + try { + new Function(script); + check(true, `${page} script ${index + 1} parses`); + } catch (error) { + check(false, `${page} script ${index + 1} parse failed: ${error.message}`); + } + } + warn(!/href="#"/.test(html), `${page} still has placeholder # links for manual navigation QA`); +} + +const reconciliation = fs.readFileSync("ui/admin-reconciliation-management/index.html", "utf8"); +check(reconciliation.includes("adjustment-export-history"), "reconciliation export history is present"); +check(!reconciliation.includes("/admin/settlement-adjustments/export.csv?"), "reconciliation uses async export instead of sync CSV URL"); + +const settlement = fs.readFileSync("ui/settlement-batch-management/index.html", "utf8"); +check(settlement.includes('data-admin-permission="settlement:export"'), "settlement export button is permission-aware"); +check(settlement.includes('data-admin-permission="settlement:pay"'), "settlement pay action is permission-aware"); + +const auditLogs = fs.readFileSync("ui/admin-system-audit-logs/index.html", "utf8"); +check(auditLogs.includes("audit-action-filter"), "audit logs page has action filter"); +check(auditLogs.includes("admin.login.failed"), "audit logs page supports admin login failed filter"); +check(auditLogs.includes("merchant.login.failed"), "audit logs page supports merchant login failed filter"); + +console.log(JSON.stringify({ ok: errors.length === 0, checks, warnings, errors }, null, 2)); +process.exit(errors.length ? 1 : 0); diff --git a/src/app.ts b/src/app.ts index e5f27c5..b5fa072 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,22 +1,45 @@ import express from "express"; import helmet from "helmet"; -import morgan from "morgan"; import { env } from "./config/env"; import { requestContext } from "./shared/middleware/requestContext"; +import { requestLogging } from "./shared/middleware/requestLogging"; +import { rateLimit } from "./shared/middleware/rateLimit"; import { handleErrors, successResponse } from "./shared/middleware/errorMiddleware"; import { NextFunction, Request, Response } from "express"; import adminRoutes from "./routes/admin"; import integrationRoutes from "./routes/integrations"; import deviceRoutes from "./routes/device"; +import merchantRoutes from "./routes/merchant"; import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator"; +import { startDynamicQrExpiryScheduler } from "./shared/services/dynamicQrExpiryScheduler"; +import { startExportJobWorker } from "./shared/services/exportJobWorker"; +import { startMqttSubscriber } from "./shared/services/mqttSubscriber"; +import { getDatabaseHealth } from "./shared/services/health"; import path from "node:path"; import fs from "node:fs"; const app = express(); +if (env.TRUST_PROXY === "true") { + app.set("trust proxy", 1); +} startNotificationOrchestrator(); +startDynamicQrExpiryScheduler(); +startExportJobWorker(); +startMqttSubscriber(); app.use( helmet({ + hidePoweredBy: true, + referrerPolicy: { + policy: "no-referrer" + }, + hsts: + process.env.NODE_ENV === "production" + ? { + maxAge: 15552000, + includeSubDomains: true + } + : false, crossOriginResourcePolicy: { policy: "cross-origin" }, @@ -35,14 +58,40 @@ app.use( } }) ); -app.use(express.json()); -app.use(morgan("dev")); +app.use(express.json({ limit: env.JSON_BODY_LIMIT })); app.use(requestContext); +app.use(requestLogging); + +const loginLimiter = rateLimit({ + name: "login", + windowMs: env.RATE_LIMIT_LOGIN_WINDOW_MS, + max: env.RATE_LIMIT_LOGIN_MAX +}); +const deviceLimiter = rateLimit({ + name: "device", + windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS, + max: env.RATE_LIMIT_DEVICE_MAX, + key: (req) => `${req.ip}:${req.header("x-device-id") || "legacy"}` +}); +const adminWriteLimiter = rateLimit({ + name: "admin_write", + windowMs: env.RATE_LIMIT_ADMIN_WRITE_WINDOW_MS, + max: env.RATE_LIMIT_ADMIN_WRITE_MAX, + key: (req) => `${req.ip}:${req.header("authorization") || "anonymous"}` +}); app.get("/", (_req, res) => { res.json(successResponse(_req, { status: "ok" })); }); +app.get("/favicon.ico", (_req, res) => { + res + .type("image/svg+xml") + .send( + `` + ); +}); + function resolveUiPageFile(slug: string) { const workspaceRoot = process.cwd(); const candidates = [ @@ -74,7 +123,22 @@ app.get("/ui/:page", (req, res, next) => { res.sendFile(filePath); }); +app.use("/admin/login", loginLimiter); +app.use("/merchant/login", loginLimiter); +app.use("/admin", (req, res, next) => { + if (req.path === "/login") { + return next(); + } + if (["POST", "PATCH", "PUT", "DELETE"].includes(req.method)) { + return adminWriteLimiter(req, res, next); + } + return next(); +}); +app.use("/device", deviceLimiter); +app.use("/integrations", rateLimit({ name: "integrations", windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS, max: env.RATE_LIMIT_DEVICE_MAX })); + app.use("/admin", adminRoutes); +app.use("/merchant", merchantRoutes); app.use("/integrations", integrationRoutes); app.use("/device", deviceRoutes); @@ -91,6 +155,20 @@ app.get("/health", (req, res) => { ); }); +app.get("/health/deep", async (req, res) => { + const database = await getDatabaseHealth(); + const healthy = database.status === "ok"; + res.status(healthy ? 200 : 503).json( + successResponse(req, { + status: healthy ? "healthy" : "degraded", + time: new Date().toISOString(), + checks: { + database + } + }) + ); +}); + app.use((req, res) => { res.status(404).json({ code: "NOT_FOUND", diff --git a/src/config/env.ts b/src/config/env.ts index 647137a..526e316 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,15 +1,59 @@ +import "dotenv/config"; + export const env = { PORT: Number(process.env.PORT || 3000), + TRUST_PROXY: process.env.TRUST_PROXY || "false", + JSON_BODY_LIMIT: process.env.JSON_BODY_LIMIT || "1mb", + LOG_FORMAT: process.env.LOG_FORMAT || "dev", + LOG_LEVEL: process.env.LOG_LEVEL || "info", ADMIN_TOKEN: process.env.ADMIN_TOKEN || "admin-dev-token", + ADMIN_AUTH_ALLOW_LEGACY_TOKEN: process.env.ADMIN_AUTH_ALLOW_LEGACY_TOKEN || "true", + ADMIN_DEV_LOGIN_ENABLED: process.env.ADMIN_DEV_LOGIN_ENABLED || "true", + ADMIN_SESSION_SECRET: process.env.ADMIN_SESSION_SECRET || process.env.ADMIN_TOKEN || "admin-dev-token", + ADMIN_SESSION_TTL_SECONDS: Number(process.env.ADMIN_SESSION_TTL_SECONDS || 28800), + MERCHANT_TOKEN: process.env.MERCHANT_TOKEN || "merchant-dev-token", + MERCHANT_PORTAL_PASSWORD: process.env.MERCHANT_PORTAL_PASSWORD || "merchant", + MERCHANT_AUTH_ALLOW_LEGACY_TOKEN: process.env.MERCHANT_AUTH_ALLOW_LEGACY_TOKEN || "true", + MERCHANT_DEV_LOGIN_ENABLED: process.env.MERCHANT_DEV_LOGIN_ENABLED || "true", + MERCHANT_SESSION_SECRET: process.env.MERCHANT_SESSION_SECRET || process.env.MERCHANT_TOKEN || "merchant-dev-token", + MERCHANT_SESSION_TTL_SECONDS: Number(process.env.MERCHANT_SESSION_TTL_SECONDS || 28800), DEVICE_TOKEN: process.env.DEVICE_TOKEN || "device-dev-token", + DEVICE_AUTH_ALLOW_LEGACY_TOKEN: process.env.DEVICE_AUTH_ALLOW_LEGACY_TOKEN || "true", TRACE_HEADER: process.env.TRACE_HEADER || "x-request-id", IDEMPOTENCY_TTL_MS: Number(process.env.IDEMPOTENCY_TTL_MS || 300000), INTEGRATION_WEBHOOK_SECRET: process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret", + MQTT_PUBLISH_MODE: process.env.MQTT_PUBLISH_MODE || "simulator", + MQTT_BROKER_URL: process.env.MQTT_BROKER_URL || "", + MQTT_USERNAME: process.env.MQTT_USERNAME || "", + MQTT_PASSWORD: process.env.MQTT_PASSWORD || "", + MQTT_CLIENT_ID: process.env.MQTT_CLIENT_ID || "qris-platform-backend", + MQTT_CONNECT_TIMEOUT_MS: Number(process.env.MQTT_CONNECT_TIMEOUT_MS || 5000), + MQTT_SUBSCRIBE_ENABLED: process.env.MQTT_SUBSCRIBE_ENABLED || "false", + MQTT_SUBSCRIBE_TOPICS: process.env.MQTT_SUBSCRIBE_TOPICS || "devices/+/uplink/#", MQTT_PUBLISH_FORCE_FAIL_ALL: process.env.MQTT_PUBLISH_FORCE_FAIL_ALL || "false", MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS: process.env.MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS || "", MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number( process.env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000 ), + DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true", + DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000), + DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100), + EXPORT_WORKER_ENABLED: process.env.EXPORT_WORKER_ENABLED || "true", + EXPORT_WORKER_INTERVAL_MS: Number(process.env.EXPORT_WORKER_INTERVAL_MS || 2000), + EXPORT_WORKER_BATCH_SIZE: Number(process.env.EXPORT_WORKER_BATCH_SIZE || 2), + EXPORT_JOB_STALE_RUNNING_MS: Number(process.env.EXPORT_JOB_STALE_RUNNING_MS || 900000), + EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS: Number(process.env.EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS || 5000), + EXPORT_STORAGE_DIR: process.env.EXPORT_STORAGE_DIR || "./storage/exports", + EXPORT_RETENTION_DAYS: Number(process.env.EXPORT_RETENTION_DAYS || 7), + RATE_LIMIT_ENABLED: process.env.RATE_LIMIT_ENABLED || "true", + RATE_LIMIT_LOGIN_WINDOW_MS: Number(process.env.RATE_LIMIT_LOGIN_WINDOW_MS || 60000), + RATE_LIMIT_LOGIN_MAX: Number(process.env.RATE_LIMIT_LOGIN_MAX || 20), + RATE_LIMIT_DEVICE_WINDOW_MS: Number(process.env.RATE_LIMIT_DEVICE_WINDOW_MS || 60000), + RATE_LIMIT_DEVICE_MAX: Number(process.env.RATE_LIMIT_DEVICE_MAX || 600), + RATE_LIMIT_ADMIN_WRITE_WINDOW_MS: Number(process.env.RATE_LIMIT_ADMIN_WRITE_WINDOW_MS || 60000), + RATE_LIMIT_ADMIN_WRITE_MAX: Number(process.env.RATE_LIMIT_ADMIN_WRITE_MAX || 300), + FINANCE_PLATFORM_FEE_BPS: Number(process.env.FINANCE_PLATFORM_FEE_BPS || 70), + SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL: process.env.SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL || "false", DATABASE_URL: process.env.DATABASE_URL || "", PGHOST: process.env.PGHOST || "127.0.0.1", PGPORT: Number(process.env.PGPORT || 5432), diff --git a/src/index.ts b/src/index.ts index ff4d8f3..ec8d228 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { createServer } from "node:http"; import app from "./app"; import { ensureSchema } from "./shared/db/pool"; import { env } from "./config/env"; +import { logger } from "./shared/services/logger"; const port = env.PORT; @@ -11,7 +12,9 @@ async function bootstrap() { await ensureSchema(); server.listen(port, () => { - console.log(`QRIS Soundbox Platform bootstrap ready on :${port}`); + logger.info("server_started", { + port + }); }); } diff --git a/src/routes/admin.ts b/src/routes/admin.ts index a2ff8a3..1398eef 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,9 +1,12 @@ import { Router, Request, Response, NextFunction } from "express"; import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; import { ApiError } from "../shared/errors"; -import { requireAdminToken } from "../shared/middleware/auth"; +import { requireAdminPermission, requireAdminToken, type RequestWithAdminAuth } from "../shared/middleware/auth"; import { successResponse } from "../shared/middleware/errorMiddleware"; import { env } from "../config/env"; +import { createSessionToken } from "../shared/services/sessionToken"; +import { getUserByEmail, verifyPassword } from "../shared/store/userStore"; import { idempotency } from "../shared/middleware/idempotency"; import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMerchantPayload, PayoutMode } from "../shared/store/merchantStore"; import { @@ -24,6 +27,7 @@ import { getDeviceById, listDevices, patchDevice, + rotateDeviceMqttCredential, DeviceEntity, toDevicePayload } from "../shared/store/deviceStore"; @@ -58,6 +62,33 @@ import { import { retryNotificationByTransactionId } from "../shared/orchestrators/notificationOrchestrator"; import { createAuditLog, listAuditLogs, toAuditLogPayload } from "../shared/store/auditLogStore"; import { listLedgerEntries, toLedgerEntryPayload } from "../shared/store/ledgerStore"; +import { + createSettlementBatches, + createSettlementBatchEvent, + getSettlementFinanceSummary, + getSettlementReconciliationReport, + getSettlementBatchReportRows, + getSettlementBatchById, + listSettlementAdjustments, + listSettlementBatchAdjustments, + listSettlementBatchEntries, + listSettlementBatchEvents, + listSettlementBatches, + markSettlementBatchPaid, + recordSettlementBatchAdjustment, + reprocessSettlementBatch, + settlementAdjustmentReportToCsv, + settlementBatchReportToBankGenericCsv, + settlementBatchReportToCsv, + SettlementBatchStatus, + transitionSettlementBatchAdjustmentApproval, + transitionSettlementBatchStatus, + toSettlementBatchEventPayload, + toSettlementBatchAdjustmentPayload, + toSettlementBatchEntryPayload, + toSettlementBatchPayload, + updateSettlementBatchReference +} from "../shared/store/settlementStore"; import { resolveDeviceCapabilitySummary } from "../shared/services/deviceCapabilityResolver"; import { getOrCreateDeviceConfig, @@ -67,9 +98,20 @@ import { upsertDeviceConfig } from "../shared/store/deviceConfigStore"; import { listMqttMessages, toMqttMessagePayload, createMqttMessage } from "../shared/store/mqttMessageStore"; -import { publishConfigPush } from "../shared/services/mqttPublisher"; +import { getMqttPublisherStatus, publishConfigPush } from "../shared/services/mqttPublisher"; +import { getMqttSubscriberStatus } from "../shared/services/mqttSubscriber"; +import { getDatabaseHealth, getServiceHealth } from "../shared/services/health"; +import { logger } from "../shared/services/logger"; +import { + countExportJobsByStatus, + getExportJobById, + listExportJobs, + toExportJobPayload +} from "../shared/store/exportJobStore"; import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus"; import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry"; +import { getDynamicQrExpirySchedulerStatus } from "../shared/services/dynamicQrExpiryScheduler"; +import { createSettlementAdjustmentExportJob, getExportJobWorkerStatus } from "../shared/services/exportJobWorker"; const router = Router(); @@ -156,6 +198,48 @@ type TransactionCreateInput = { expired_at?: string; }; +type SettlementBatchCreateInput = { + merchant_id?: string; + cutoff_at?: string; + limit_per_merchant?: number; +}; + +type SettlementBatchPaidInput = { + paid_at?: string; + paid_reference?: string; + paid_note?: string; +}; + +type SettlementBatchReferenceInput = { + paid_reference?: string; + paid_note?: string; +}; + +type SettlementBatchResolutionInput = { + reason?: string; + note?: string; +}; + +type SettlementBatchAdjustmentInput = { + adjustment_type?: "credit" | "debit"; + amount?: number; + reason?: string; + note?: string; +}; + +type SettlementAdjustmentApprovalInput = { + note?: string; +}; + +type SettlementAdjustmentExportJobInput = { + merchant_id?: string; + adjustment_type?: "credit" | "debit"; + approval_status?: "pending" | "approved" | "rejected"; + from?: string; + to?: string; + limit?: number; +}; + function parseIdempotentReplay(req: Request) { return (req.body as { __idempotentReplay?: boolean; __idempotentResponse?: unknown }).__idempotentReplay; } @@ -223,6 +307,37 @@ function parsePayoutMode(value: string | undefined): "merchant_direct" | "manual return undefined; } +function parseSettlementBatchStatus(value: string | undefined): SettlementBatchStatus | undefined { + if (value === "created" || value === "paid" || value === "failed" || value === "cancelled") { + return value; + } + return undefined; +} + +function parseSettlementExportFormat(value: string | undefined): "standard" | "bank_generic" | undefined { + if (!value || value === "standard" || value === "general") { + return "standard"; + } + if (value === "bank_generic") { + return "bank_generic"; + } + return undefined; +} + +function parseSettlementAdjustmentType(value: string | undefined): "credit" | "debit" | undefined { + if (value === "credit" || value === "debit") { + return value; + } + return undefined; +} + +function parseSettlementAdjustmentApprovalStatus(value: string | undefined): "pending" | "approved" | "rejected" | undefined { + if (value === "pending" || value === "approved" || value === "rejected") { + return value; + } + return undefined; +} + function parseOutletStatusFilter(value: string | undefined): "active" | "inactive" | undefined { if (value === "active" || value === "inactive") { return value; @@ -423,14 +538,92 @@ async function ensureMerchant(req: Request, next: NextFunction) { router.post("/login", async (req: Request, res: Response, next: NextFunction) => { const { username, password } = req.body as LoginInput; - if (username !== "admin" || password !== "admin") { + const loginId = (username || "").trim(); + const isDevLogin = env.ADMIN_DEV_LOGIN_ENABLED !== "false" && loginId === "admin" && password === "admin"; + const user = !isDevLogin && loginId ? await getUserByEmail(loginId) : null; + const validUser = user && user.status === "active" && verifyPassword(user.password_hash, password || ""); + + if (!isDevLogin && !validUser) { + await createAuditLog({ + actor_type: "admin", + actor_id: loginId || "unknown", + action: "admin.login.failed", + entity_type: "admin_session", + entity_id: loginId || "unknown", + after_json: { + reason: user && user.status !== "active" ? "inactive_user" : "invalid_credentials", + auth_mode: "session" + }, + source_ip: req.ip, + request_id: req.requestId, + trace_id: req.traceId + }); return next(new ApiError("UNAUTHORIZED", "Invalid credentials", 401)); } - const token = env.ADMIN_TOKEN; + const authenticatedUser = isDevLogin ? null : user; + if (!isDevLogin && !authenticatedUser) { + return next(new ApiError("UNAUTHORIZED", "Invalid credentials", 401)); + } + const sessionUser = authenticatedUser!; + + const token = isDevLogin + ? env.ADMIN_TOKEN + : createSessionToken( + { + typ: "admin", + sub: sessionUser.id, + name: sessionUser.name, + email: sessionUser.email, + role_id: sessionUser.role_id, + role_name: sessionUser.role_name, + permissions: sessionUser.permissions_json + }, + env.ADMIN_SESSION_SECRET, + env.ADMIN_SESSION_TTL_SECONDS + ); + await createAuditLog({ + actor_type: "admin", + actor_id: isDevLogin ? "legacy_admin" : sessionUser.id, + action: "admin.login.success", + entity_type: "admin_session", + entity_id: isDevLogin ? "legacy_admin" : sessionUser.id, + after_json: { + auth_mode: isDevLogin ? "legacy_token" : "session", + role_name: isDevLogin ? "admin" : sessionUser.role_name + }, + source_ip: req.ip, + request_id: req.requestId, + trace_id: req.traceId + }); res.json( successResponse(req, { - token + token, + auth_mode: isDevLogin ? "legacy_token" : "session", + user: isDevLogin + ? { + id: "legacy_admin", + name: "Dev Admin", + role_name: "admin" + } + : { + id: sessionUser.id, + name: sessionUser.name, + email: sessionUser.email, + role_name: sessionUser.role_name, + permissions: sessionUser.permissions_json + } + }) + ); +}); + +router.get("/me", requireAdminToken, async (req: RequestWithAdminAuth, res: Response) => { + res.json( + successResponse(req, { + user_id: req.adminAuth?.user_id, + role_name: req.adminAuth?.role_name, + auth_mode: req.adminAuth?.mode, + permissions: req.adminAuth?.permissions }) ); }); @@ -452,6 +645,16 @@ router.get("/health", requireAdminToken, async (_req: Request, res: Response) => ); }); +router.get("/health/deep", requireAdminToken, async (req: Request, res: Response) => { + const health = await getServiceHealth(); + res.status(health.status === "healthy" ? 200 : 503).json( + successResponse(req, { + ...health, + time: new Date().toISOString() + }) + ); +}); + router.post( "/sample-idempotent", requireAdminToken, @@ -1228,6 +1431,38 @@ router.patch("/devices/:deviceId", requireAdminToken, async (req: Request, res: } }); +router.post("/devices/:deviceId/credentials/rotate", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + try { + const existing = await getDeviceById(req.params.deviceId); + const rotated = await rotateDeviceMqttCredential(req.params.deviceId); + await auditAdminAction(req, { + action: "device.credentials.rotate", + entity_type: "device", + entity_id: rotated.device.id, + before_json: existing ? toDevicePayload(existing) : null, + after_json: toDevicePayload(rotated.device) + }); + + res.json( + successResponse(req, { + device: toDevicePayload(rotated.device), + credential: { + mqtt_username: rotated.username, + mqtt_password: rotated.password, + status: rotated.device.credential_status, + rotated_at: rotated.device.credential_rotated_at, + one_time_secret: true + } + }) + ); + } catch (err) { + if (err instanceof Error && err.message === "DEVICE_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + return next(err as Error); + } +}); + router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "device.bind", required: false }), async (req: Request, res: Response, next: NextFunction) => { if (parseIdempotentReplay(req)) { return res.status(200).json(getReplayResponse(req)); @@ -1674,6 +1909,10 @@ router.post("/transactions/expire-due", requireAdminToken, async (req: Request, res.json(successResponse(req, result)); }); +router.get("/transactions/expiry-scheduler", requireAdminToken, async (req: Request, res: Response) => { + res.json(successResponse(req, getDynamicQrExpirySchedulerStatus())); +}); + router.get( "/transactions/:transactionId", requireAdminToken, @@ -1859,9 +2098,19 @@ router.get("/dashboard/summary", requireAdminToken, async (req: Request, res: Re transactions_today: 0, success_rate_today: 0, active_devices: 0, + devices_online: 0, + devices_degraded: 0, pending_notifications: 0, devices_stale: 0, - devices_offline: 0 + devices_offline: 0, + settlement_pending_amount: 0, + settlement_paid_amount: 0, + settlement_adjustment_amount: 0, + settlement_adjusted_paid_amount: 0, + settlement_platform_fee_amount: 0, + settlement_created_batches: 0, + settlement_paid_batches: 0, + settlement_total_batches: 0 }; try { @@ -1878,6 +2127,8 @@ router.get("/dashboard/summary", requireAdminToken, async (req: Request, res: Re const successRateToday = transactionsToday > 0 ? (paidToday / transactionsToday) * 100 : 0; const statuses = await deriveDeviceStatusesForDashboard(); + const devicesOnline = statuses.filter((row) => row.status === "online").length; + const devicesDegraded = statuses.filter((row) => row.status === "degraded").length; const activeDevices = statuses.filter((row) => row.status !== "offline").length; const devicesStale = statuses.filter((row) => row.status === "stale").length; const devicesOffline = statuses.filter((row) => row.status === "offline").length; @@ -1886,21 +2137,94 @@ router.get("/dashboard/summary", requireAdminToken, async (req: Request, res: Re return notification.delivery_status === "queued" || notification.delivery_status === "retrying"; }).length; + const settlementFinanceSummary = await getSettlementFinanceSummary(); + dashboard = { transactions_today: transactionsToday, success_rate_today: Number(successRateToday.toFixed(2)), active_devices: activeDevices, + devices_online: devicesOnline, + devices_degraded: devicesDegraded, pending_notifications: pendingNotifications, devices_stale: devicesStale, - devices_offline: devicesOffline + devices_offline: devicesOffline, + settlement_pending_amount: settlementFinanceSummary.pending_amount, + settlement_paid_amount: settlementFinanceSummary.paid_amount, + settlement_adjustment_amount: settlementFinanceSummary.adjustment_amount, + settlement_adjusted_paid_amount: settlementFinanceSummary.adjusted_paid_amount, + settlement_platform_fee_amount: settlementFinanceSummary.platform_fee_amount, + settlement_created_batches: settlementFinanceSummary.created_batches, + settlement_paid_batches: settlementFinanceSummary.paid_batches, + settlement_total_batches: settlementFinanceSummary.total_batches }; } catch (error) { - console.error("[dashboard/summary] fallback due calculation error", error instanceof Error ? error.message : error); + logger.warn("dashboard_summary_fallback", { + request_id: req.requestId, + trace_id: req.traceId, + error + }); } res.json(successResponse(req, dashboard)); }); +router.get("/mqtt/status", requireAdminToken, async (req: Request, res: Response) => { + const limitRaw = req.query.limit; + const limit = limitRaw === undefined || limitRaw === "" ? 10 : Number(limitRaw); + const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 50) : 10; + + res.json( + successResponse(req, { + publisher: getMqttPublisherStatus(), + subscriber: getMqttSubscriberStatus(), + last_messages: (await listMqttMessages({ limit: safeLimit })).map(toMqttMessagePayload) + }) + ); +}); + +router.get("/observability/summary", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + try { + const [database, notifications, reconciliation, exportJobs] = await Promise.all([ + getDatabaseHealth(), + listNotifications(), + getSettlementReconciliationReport({ limit: 100, mismatches_only: true }), + countExportJobsByStatus() + ]); + const failedNotifications = notifications.filter((notification) => notification.delivery_status === "failed"); + const pendingNotifications = notifications.filter((notification) => + notification.delivery_status === "queued" || notification.delivery_status === "retrying" + ); + const mqttPublisher = getMqttPublisherStatus(); + const mqttSubscriber = getMqttSubscriberStatus(); + + res.json( + successResponse(req, { + generated_at: new Date().toISOString(), + database, + mqtt: { + publisher: mqttPublisher, + subscriber: mqttSubscriber + }, + notifications: { + failed_count: failedNotifications.length, + pending_count: pendingNotifications.length + }, + settlement_reconciliation: { + mismatch_batches: reconciliation.mismatch_batches, + issue_count: reconciliation.issue_count, + sampled_batches: reconciliation.rows.length + }, + export_jobs: { + worker: getExportJobWorkerStatus(), + status_counts: exportJobs + } + }) + ); + } catch (error) { + return next(error as Error); + } +}); + router.get("/notifications/failed", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { const deviceId = req.query.device_id as string | undefined; const from = req.query.from as string | undefined; @@ -1937,7 +2261,8 @@ router.get("/notifications/failed", requireAdminToken, async (req: Request, res: device_id: notification.device_id, delivery_status: notification.delivery_status, retry_count: notification.retry_count, - reason: notification.reason + reason: notification.reason, + created_at: notification.created_at })); res.json(successResponse(req, filtered)); @@ -1947,6 +2272,7 @@ router.get("/audit-logs", requireAdminToken, async (req: Request, res: Response, 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 actionContains = (req.query.action_contains 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; @@ -1968,6 +2294,7 @@ router.get("/audit-logs", requireAdminToken, async (req: Request, res: Response, entity_type: entityType || undefined, entity_id: entityId || undefined, action: action || undefined, + action_contains: actionContains || undefined, from, to, limit @@ -1976,6 +2303,674 @@ router.get("/audit-logs", requireAdminToken, async (req: Request, res: Response, res.json(successResponse(req, logs.map(toAuditLogPayload))); }); +router.post("/settlement-batches", requireAdminToken, requireAdminPermission("settlement:write"), async (req: Request, res: Response, next: NextFunction) => { + const payload = (req.body || {}) as SettlementBatchCreateInput; + const merchantId = payload.merchant_id?.trim(); + const cutoffAt = payload.cutoff_at?.trim(); + const limitPerMerchant = payload.limit_per_merchant ? Number(payload.limit_per_merchant) : undefined; + + if (merchantId && !(await getMerchantById(merchantId))) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + + if (cutoffAt && Number.isNaN(Date.parse(cutoffAt))) { + return next(new ApiError("BAD_REQUEST", "cutoff_at must be valid ISO datetime", 400)); + } + + if (limitPerMerchant !== undefined && (!Number.isFinite(limitPerMerchant) || limitPerMerchant <= 0)) { + return next(new ApiError("BAD_REQUEST", "limit_per_merchant must be a positive number", 400)); + } + + const batches = await createSettlementBatches({ + merchant_id: merchantId || undefined, + cutoff_at: cutoffAt || undefined, + limit_per_merchant: limitPerMerchant + }); + + for (const batch of batches) { + await createSettlementBatchEvent({ + batch_id: batch.id, + merchant_id: batch.merchant_id, + event_type: "created", + actor_type: "admin", + actor_id: "admin", + payload_json: { + batch_code: batch.batch_code, + cutoff_at: batch.cutoff_at, + entry_count: batch.entry_count, + net_payable_amount: batch.net_payable_amount + } + }); + await auditAdminAction(req, { + action: "settlement.batch.create", + entity_type: "settlement_batch", + entity_id: batch.id, + after_json: toSettlementBatchPayload(batch) + }); + } + + res.status(201).json( + successResponse(req, { + created_count: batches.length, + batches: batches.map(toSettlementBatchPayload) + }) + ); +}); + +router.get("/settlement-batches", requireAdminToken, requireAdminPermission("settlement:read"), async (req: Request, res: Response, next: NextFunction) => { + const merchantId = (req.query.merchant_id as string | undefined)?.trim(); + const statusRaw = req.query.status as string | undefined; + const status = parseSettlementBatchStatus(statusRaw); + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 100; + + if (statusRaw && !status) { + return next(new ApiError("BAD_REQUEST", "status must be created|paid|failed|cancelled", 400)); + } + + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + + const batches = await listSettlementBatches({ + merchant_id: merchantId || undefined, + status, + limit + }); + + res.json(successResponse(req, batches.map(toSettlementBatchPayload))); +}); + +router.get("/reconciliation/settlement-batches", requireAdminToken, requireAdminPermission("reconciliation:read"), async (req: Request, res: Response, next: NextFunction) => { + const merchantId = (req.query.merchant_id as string | undefined)?.trim(); + const statusRaw = req.query.status as string | undefined; + const status = parseSettlementBatchStatus(statusRaw); + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 100; + const mismatchesOnlyRaw = req.query.mismatches_only as string | undefined; + const mismatchesOnly = mismatchesOnlyRaw === "true" || mismatchesOnlyRaw === "1"; + + if (statusRaw && !status) { + return next(new ApiError("BAD_REQUEST", "status must be created|paid|failed|cancelled", 400)); + } + + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + + const report = await getSettlementReconciliationReport({ + merchant_id: merchantId || undefined, + status, + limit, + mismatches_only: mismatchesOnly + }); + + res.json(successResponse(req, report)); +}); + +router.get("/settlement-adjustments", requireAdminToken, requireAdminPermission("settlement:read"), async (req: Request, res: Response, next: NextFunction) => { + const merchantId = (req.query.merchant_id as string | undefined)?.trim(); + const adjustmentTypeRaw = req.query.adjustment_type as string | undefined; + const adjustmentType = parseSettlementAdjustmentType(adjustmentTypeRaw); + const approvalStatusRaw = req.query.approval_status as string | undefined; + const approvalStatus = parseSettlementAdjustmentApprovalStatus(approvalStatusRaw); + const from = (req.query.from as string | undefined)?.trim(); + const to = (req.query.to as string | undefined)?.trim(); + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 100; + + if (adjustmentTypeRaw && !adjustmentType) { + return next(new ApiError("BAD_REQUEST", "adjustment_type must be credit|debit", 400)); + } + if (approvalStatusRaw && !approvalStatus) { + return next(new ApiError("BAD_REQUEST", "approval_status must be pending|approved|rejected", 400)); + } + if (from && !isIsoDate(from)) { + return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); + } + if (to && !isIsoDate(to)) { + return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); + } + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + + const report = await listSettlementAdjustments({ + merchant_id: merchantId || undefined, + adjustment_type: adjustmentType, + approval_status: approvalStatus, + from: from || undefined, + to: to || undefined, + limit + }); + + res.json(successResponse(req, report)); +}); + +router.get("/settlement-adjustments/export.csv", requireAdminToken, requireAdminPermission("settlement:export"), async (req: Request, res: Response, next: NextFunction) => { + const merchantId = (req.query.merchant_id as string | undefined)?.trim(); + const adjustmentTypeRaw = req.query.adjustment_type as string | undefined; + const adjustmentType = parseSettlementAdjustmentType(adjustmentTypeRaw); + const approvalStatusRaw = req.query.approval_status as string | undefined; + const approvalStatus = parseSettlementAdjustmentApprovalStatus(approvalStatusRaw); + const from = (req.query.from as string | undefined)?.trim(); + const to = (req.query.to as string | undefined)?.trim(); + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 500; + + if (adjustmentTypeRaw && !adjustmentType) { + return next(new ApiError("BAD_REQUEST", "adjustment_type must be credit|debit", 400)); + } + if (approvalStatusRaw && !approvalStatus) { + return next(new ApiError("BAD_REQUEST", "approval_status must be pending|approved|rejected", 400)); + } + if (from && !isIsoDate(from)) { + return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); + } + if (to && !isIsoDate(to)) { + return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); + } + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + + const report = await listSettlementAdjustments({ + merchant_id: merchantId || undefined, + adjustment_type: adjustmentType, + approval_status: approvalStatus, + from: from || undefined, + to: to || undefined, + limit + }); + const csv = settlementAdjustmentReportToCsv(report); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", 'attachment; filename="settlement-adjustment-report.csv"'); + res.send(csv); +}); + +router.post( + "/exports/settlement-adjustments", + requireAdminToken, + requireAdminPermission("settlement:export"), + async (req: RequestWithAdminAuth, res: Response, next: NextFunction) => { + try { + const job = await createSettlementAdjustmentExportJob({ + requested_by: req.adminAuth?.user_id || "admin", + request: (req.body || {}) as SettlementAdjustmentExportJobInput + }); + await auditAdminAction(req, { + action: "settlement.adjustment.export_job_create", + entity_type: "export_job", + entity_id: job.id, + after_json: toExportJobPayload(job) + }); + res.status(202).json(successResponse(req, toExportJobPayload(job))); + } catch (error) { + return next(error as Error); + } + } +); + +router.get("/exports", requireAdminToken, requireAdminPermission("settlement:export"), async (req: Request, res: Response) => { + const statusRaw = req.query.status as string | undefined; + const status = + statusRaw === "pending" || statusRaw === "running" || statusRaw === "completed" || statusRaw === "failed" + ? statusRaw + : undefined; + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 20; + const jobs = await listExportJobs({ + job_type: (req.query.job_type as string | undefined) || undefined, + status, + limit: Number.isFinite(limit) ? limit : 20 + }); + res.json(successResponse(req, jobs.map(toExportJobPayload))); +}); + +router.get("/exports/:jobId", requireAdminToken, requireAdminPermission("settlement:export"), async (req: Request, res: Response, next: NextFunction) => { + const job = await getExportJobById(req.params.jobId); + if (!job) { + return next(new ApiError("NOT_FOUND", "export job not found", 404)); + } + res.json(successResponse(req, toExportJobPayload(job))); +}); + +router.get("/exports/:jobId/download", requireAdminToken, requireAdminPermission("settlement:export"), async (req: Request, res: Response, next: NextFunction) => { + const job = await getExportJobById(req.params.jobId); + if (!job) { + return next(new ApiError("NOT_FOUND", "export job not found", 404)); + } + if (job.status !== "completed" || (!job.result_body && !job.result_storage_path)) { + return next(new ApiError("CONFLICT", "export job is not completed", 409)); + } + const resultBody = job.result_storage_path ? await fs.readFile(job.result_storage_path, "utf8") : job.result_body; + res.setHeader("Content-Type", job.result_content_type || "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${job.result_filename || `${job.id}.csv`}"`); + res.send(resultBody); +}); + +async function resolveSettlementAdjustmentApproval( + req: RequestWithAdminAuth, + res: Response, + next: NextFunction, + approvalStatus: "approved" | "rejected" +) { + const payload = (req.body || {}) as SettlementAdjustmentApprovalInput; + const note = payload.note?.trim(); + if (note && note.length > 500) { + return next(new ApiError("BAD_REQUEST", "note must be 500 characters or less", 400)); + } + + try { + const result = await transitionSettlementBatchAdjustmentApproval(req.params.adjustmentId, { + approval_status: approvalStatus, + actor_id: req.adminAuth?.user_id || "admin", + note: note || undefined + }); + await createSettlementBatchEvent({ + batch_id: result.batch.id, + merchant_id: result.batch.merchant_id, + event_type: approvalStatus === "approved" ? "adjustment_approved" : "adjustment_rejected", + actor_type: "admin", + actor_id: req.adminAuth?.user_id || "admin", + payload_json: { + adjustment: toSettlementBatchAdjustmentPayload(result.adjustment), + total_adjustment_amount: result.batch.metadata_json.total_adjustment_amount || 0, + note: note || null + } + }); + await auditAdminAction(req, { + action: `settlement.adjustment.${approvalStatus}`, + entity_type: "settlement_batch_adjustment", + entity_id: result.adjustment.id, + after_json: toSettlementBatchAdjustmentPayload(result.adjustment) + }); + return res.json( + successResponse(req, { + adjustment: toSettlementBatchAdjustmentPayload(result.adjustment), + batch: toSettlementBatchPayload(result.batch) + }) + ); + } catch (error) { + if (error instanceof Error && error.message === "SETTLEMENT_ADJUSTMENT_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "settlement adjustment not found", 404)); + } + if (error instanceof Error && error.message === "SETTLEMENT_ADJUSTMENT_NOT_PENDING") { + return next(new ApiError("CONFLICT", "settlement adjustment is not pending approval", 409)); + } + return next(error as Error); + } +} + +router.post( + "/settlement-adjustments/:adjustmentId/approve", + requireAdminToken, + requireAdminPermission("settlement:adjust"), + async (req: RequestWithAdminAuth, res: Response, next: NextFunction) => + resolveSettlementAdjustmentApproval(req, res, next, "approved") +); + +router.post( + "/settlement-adjustments/:adjustmentId/reject", + requireAdminToken, + requireAdminPermission("settlement:adjust"), + async (req: RequestWithAdminAuth, res: Response, next: NextFunction) => + resolveSettlementAdjustmentApproval(req, res, next, "rejected") +); + +router.get("/settlement-batches/:batchId", requireAdminToken, requireAdminPermission("settlement:read"), async (req: Request, res: Response, next: NextFunction) => { + const batch = await getSettlementBatchById(req.params.batchId); + if (!batch) { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + + const entries = await listSettlementBatchEntries(batch.id); + const adjustments = await listSettlementBatchAdjustments(batch.id); + const events = await listSettlementBatchEvents(batch.id); + res.json( + successResponse(req, { + batch: toSettlementBatchPayload(batch), + entries: entries.map(toSettlementBatchEntryPayload), + adjustments: adjustments.map(toSettlementBatchAdjustmentPayload), + events: events.map(toSettlementBatchEventPayload) + }) + ); +}); + +router.get("/settlement-batches/:batchId/export.csv", requireAdminToken, requireAdminPermission("settlement:export"), async (req: Request, res: Response, next: NextFunction) => { + const batch = await getSettlementBatchById(req.params.batchId); + if (!batch) { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + const formatRaw = req.query.format as string | undefined; + const format = parseSettlementExportFormat(formatRaw); + if (!format) { + return next(new ApiError("BAD_REQUEST", "format must be standard|bank_generic", 400)); + } + + const rows = await getSettlementBatchReportRows(batch.id); + const csv = format === "bank_generic" ? settlementBatchReportToBankGenericCsv(rows) : settlementBatchReportToCsv(rows); + const filename = + format === "bank_generic" + ? `${batch.batch_code}-bank-generic-payout.csv` + : `${batch.batch_code}-payout-report.csv`; + await createSettlementBatchEvent({ + batch_id: batch.id, + merchant_id: batch.merchant_id, + event_type: "csv_exported", + actor_type: "admin", + actor_id: "admin", + payload_json: { + format, + report_rows: rows.length, + filename + } + }); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(csv); +}); + +router.post("/settlement-batches/:batchId/mark-paid", requireAdminToken, requireAdminPermission("settlement:pay"), async (req: Request, res: Response, next: NextFunction) => { + const payload = (req.body || {}) as SettlementBatchPaidInput; + const paidReference = payload.paid_reference?.trim(); + const paidNote = payload.paid_note?.trim(); + if (payload.paid_at && Number.isNaN(Date.parse(payload.paid_at))) { + return next(new ApiError("BAD_REQUEST", "paid_at must be valid ISO datetime", 400)); + } + if (paidReference && paidReference.length > 120) { + return next(new ApiError("BAD_REQUEST", "paid_reference must be 120 characters or less", 400)); + } + if (paidNote && paidNote.length > 500) { + return next(new ApiError("BAD_REQUEST", "paid_note must be 500 characters or less", 400)); + } + + try { + const before = await getSettlementBatchById(req.params.batchId); + const updated = await markSettlementBatchPaid(req.params.batchId, { + paid_at: payload.paid_at, + paid_reference: paidReference || undefined, + paid_note: paidNote || undefined + }); + await createSettlementBatchEvent({ + batch_id: updated.id, + merchant_id: updated.merchant_id, + event_type: "marked_paid", + actor_type: "admin", + actor_id: "admin", + payload_json: { + paid_at: updated.paid_at, + paid_reference: paidReference || null, + paid_note: paidNote || null + } + }); + await auditAdminAction(req, { + action: "settlement.batch.mark_paid", + entity_type: "settlement_batch", + entity_id: updated.id, + before_json: before ? toSettlementBatchPayload(before) : null, + after_json: toSettlementBatchPayload(updated) + }); + res.json(successResponse(req, toSettlementBatchPayload(updated))); + } catch (error) { + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_NOT_PAYABLE") { + return next(new ApiError("CONFLICT", "settlement batch is not in created status", 409)); + } + return next(error as Error); + } +}); + +router.patch("/settlement-batches/:batchId/reference", requireAdminToken, requireAdminPermission("settlement:pay"), async (req: Request, res: Response, next: NextFunction) => { + const payload = (req.body || {}) as SettlementBatchReferenceInput; + const paidReference = payload.paid_reference?.trim(); + const paidNote = payload.paid_note?.trim(); + + if (!paidReference) { + return next(new ApiError("BAD_REQUEST", "paid_reference is required", 400)); + } + if (paidReference.length > 120) { + return next(new ApiError("BAD_REQUEST", "paid_reference must be 120 characters or less", 400)); + } + if (paidNote && paidNote.length > 500) { + return next(new ApiError("BAD_REQUEST", "paid_note must be 500 characters or less", 400)); + } + + try { + const before = await getSettlementBatchById(req.params.batchId); + const previousMetadata = before?.metadata_json || {}; + const updated = await updateSettlementBatchReference(req.params.batchId, { + paid_reference: paidReference, + paid_note: paidNote || undefined + }); + await createSettlementBatchEvent({ + batch_id: updated.id, + merchant_id: updated.merchant_id, + event_type: "reference_updated", + actor_type: "admin", + actor_id: "admin", + payload_json: { + previous_paid_reference: previousMetadata.paid_reference || null, + paid_reference: paidReference, + previous_paid_note: previousMetadata.paid_note || null, + paid_note: paidNote || null + } + }); + await auditAdminAction(req, { + action: "settlement.batch.reference_update", + entity_type: "settlement_batch", + entity_id: updated.id, + before_json: before ? toSettlementBatchPayload(before) : null, + after_json: toSettlementBatchPayload(updated) + }); + res.json(successResponse(req, toSettlementBatchPayload(updated))); + } catch (error) { + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_REFERENCE_NOT_EDITABLE") { + return next(new ApiError("CONFLICT", "settlement reference can only be updated after batch is paid", 409)); + } + return next(error as Error); + } +}); + +router.post("/settlement-batches/:batchId/adjustments", requireAdminToken, requireAdminPermission("settlement:adjust"), async (req: Request, res: Response, next: NextFunction) => { + const payload = (req.body || {}) as SettlementBatchAdjustmentInput; + const adjustmentType = payload.adjustment_type; + const amount = Number(payload.amount); + const reason = payload.reason?.trim(); + const note = payload.note?.trim(); + + if (adjustmentType !== "credit" && adjustmentType !== "debit") { + return next(new ApiError("BAD_REQUEST", "adjustment_type must be credit|debit", 400)); + } + if (!Number.isFinite(amount) || amount <= 0) { + return next(new ApiError("BAD_REQUEST", "amount must be a positive number", 400)); + } + if (!reason) { + return next(new ApiError("BAD_REQUEST", "reason is required", 400)); + } + if (reason.length > 300) { + return next(new ApiError("BAD_REQUEST", "reason must be 300 characters or less", 400)); + } + if (note && note.length > 500) { + return next(new ApiError("BAD_REQUEST", "note must be 500 characters or less", 400)); + } + + try { + const before = await getSettlementBatchById(req.params.batchId); + const updated = await recordSettlementBatchAdjustment(req.params.batchId, { + adjustment_type: adjustmentType, + amount, + reason, + note: note || undefined, + actor_id: "admin" + }); + const adjustments = Array.isArray(updated.metadata_json.adjustments) ? updated.metadata_json.adjustments : []; + const latestAdjustment = adjustments[adjustments.length - 1] || null; + await createSettlementBatchEvent({ + batch_id: updated.id, + merchant_id: updated.merchant_id, + event_type: "adjustment_recorded", + actor_type: "admin", + actor_id: "admin", + payload_json: { + adjustment: latestAdjustment, + total_adjustment_amount: updated.metadata_json.total_adjustment_amount || 0 + } + }); + await auditAdminAction(req, { + action: "settlement.batch.adjustment_record", + entity_type: "settlement_batch", + entity_id: updated.id, + before_json: before ? toSettlementBatchPayload(before) : null, + after_json: toSettlementBatchPayload(updated) + }); + res.status(201).json(successResponse(req, toSettlementBatchPayload(updated))); + } catch (error) { + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_ADJUSTMENT_NOT_EDITABLE") { + return next(new ApiError("CONFLICT", "archived reprocessed settlement batch cannot be adjusted", 409)); + } + return next(error as Error); + } +}); + +async function resolveSettlementBatch( + req: Request, + res: Response, + next: NextFunction, + status: Extract +) { + const payload = (req.body || {}) as SettlementBatchResolutionInput; + const reason = payload.reason?.trim(); + const note = payload.note?.trim(); + + if (!reason) { + return next(new ApiError("BAD_REQUEST", "reason is required", 400)); + } + if (reason.length > 300) { + return next(new ApiError("BAD_REQUEST", "reason must be 300 characters or less", 400)); + } + if (note && note.length > 500) { + return next(new ApiError("BAD_REQUEST", "note must be 500 characters or less", 400)); + } + + try { + const before = await getSettlementBatchById(req.params.batchId); + const updated = await transitionSettlementBatchStatus(req.params.batchId, { + status, + reason, + note: note || undefined + }); + await createSettlementBatchEvent({ + batch_id: updated.id, + merchant_id: updated.merchant_id, + event_type: status, + actor_type: "admin", + actor_id: "admin", + payload_json: { + reason, + note: note || null + } + }); + await auditAdminAction(req, { + action: `settlement.batch.${status}`, + entity_type: "settlement_batch", + entity_id: updated.id, + before_json: before ? toSettlementBatchPayload(before) : null, + after_json: toSettlementBatchPayload(updated) + }); + return res.json(successResponse(req, toSettlementBatchPayload(updated))); + } catch (error) { + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_NOT_TRANSITIONABLE") { + return next(new ApiError("CONFLICT", "settlement batch is not in created status", 409)); + } + return next(error as Error); + } +} + +router.post("/settlement-batches/:batchId/mark-failed", requireAdminToken, requireAdminPermission("settlement:write"), async (req: Request, res: Response, next: NextFunction) => { + return resolveSettlementBatch(req, res, next, "failed"); +}); + +router.post("/settlement-batches/:batchId/cancel", requireAdminToken, requireAdminPermission("settlement:write"), async (req: Request, res: Response, next: NextFunction) => { + return resolveSettlementBatch(req, res, next, "cancelled"); +}); + +router.post("/settlement-batches/:batchId/reprocess", requireAdminToken, requireAdminPermission("settlement:write"), async (req: Request, res: Response, next: NextFunction) => { + try { + const before = await getSettlementBatchById(req.params.batchId); + const result = await reprocessSettlementBatch(req.params.batchId); + await createSettlementBatchEvent({ + batch_id: result.source_batch.id, + merchant_id: result.source_batch.merchant_id, + event_type: "reprocessed", + actor_type: "admin", + actor_id: "admin", + payload_json: { + reprocessed_to_batch_id: result.new_batch.id, + reprocessed_to_batch_code: result.new_batch.batch_code, + moved_entry_count: result.moved_entry_count + } + }); + await createSettlementBatchEvent({ + batch_id: result.new_batch.id, + merchant_id: result.new_batch.merchant_id, + event_type: "created", + actor_type: "admin", + actor_id: "admin", + payload_json: { + batch_code: result.new_batch.batch_code, + reprocessed_from_batch_id: result.source_batch.id, + reprocessed_from_batch_code: result.source_batch.batch_code, + entry_count: result.new_batch.entry_count, + net_payable_amount: result.new_batch.net_payable_amount + } + }); + await auditAdminAction(req, { + action: "settlement.batch.reprocess", + entity_type: "settlement_batch", + entity_id: result.source_batch.id, + before_json: before ? toSettlementBatchPayload(before) : null, + after_json: { + source_batch: toSettlementBatchPayload(result.source_batch), + new_batch: toSettlementBatchPayload(result.new_batch), + moved_entry_count: result.moved_entry_count + } + }); + return res.status(201).json( + successResponse(req, { + source_batch: toSettlementBatchPayload(result.source_batch), + new_batch: toSettlementBatchPayload(result.new_batch), + moved_entry_count: result.moved_entry_count + }) + ); + } catch (error) { + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_NOT_REPROCESSABLE") { + return next(new ApiError("CONFLICT", "settlement batch must be failed or cancelled", 409)); + } + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_ALREADY_REPROCESSED") { + return next(new ApiError("CONFLICT", "settlement batch was already reprocessed", 409)); + } + if (error instanceof Error && error.message === "SETTLEMENT_BATCH_EMPTY") { + return next(new ApiError("CONFLICT", "settlement batch has no entries to reprocess", 409)); + } + return next(error as Error); + } +}); + 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(); diff --git a/src/routes/device.ts b/src/routes/device.ts index 921f603..6e3419f 100644 --- a/src/routes/device.ts +++ b/src/routes/device.ts @@ -1,6 +1,6 @@ import { Router, Request, Response, NextFunction } from "express"; import { ApiError } from "../shared/errors"; -import { requireDeviceToken } from "../shared/middleware/auth"; +import { getDeviceScopeError, requireDeviceToken } from "../shared/middleware/auth"; import { successResponse } from "../shared/middleware/errorMiddleware"; import { getDeviceById, patchDevice } from "../shared/store/deviceStore"; import { createDeviceHeartbeat } from "../shared/store/heartbeatStore"; @@ -136,6 +136,10 @@ router.post("/heartbeat", requireDeviceToken, async (req: Request, res: Response if (!payload || !payload.device_id) { return next(new ApiError("BAD_REQUEST", "device_id is required", 400)); } + const scopeError = getDeviceScopeError(req, payload.device_id); + if (scopeError) { + return next(scopeError); + } const device = await getDeviceById(payload.device_id); if (!device) { @@ -199,6 +203,10 @@ router.post("/commands/ack", requireDeviceToken, async (req: Request, res: Respo if (!payload || !payload.command_id || !payload.device_id || !payload.status) { return next(new ApiError("BAD_REQUEST", "command_id, device_id, status are required", 400)); } + const scopeError = getDeviceScopeError(req, payload.device_id); + if (scopeError) { + return next(scopeError); + } if (!["delivered", "failed", "timeout"].includes(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be delivered, failed, or timeout", 400)); @@ -235,6 +243,10 @@ router.post("/transactions/dynamic-qr", requireDeviceToken, async (req: Request, 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 scopeError = getDeviceScopeError(req, payload.device_id); + if (scopeError) { + return next(scopeError); + } const amount = normalizePositiveAmount(payload.amount); if (amount === null) { @@ -310,6 +322,10 @@ router.post("/mqtt/uplink/dynamic-qr/request", requireDeviceToken, async (req: R 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 scopeError = getDeviceScopeError(req, payload.device_id); + if (scopeError) { + return next(scopeError); + } if (payload.message_type && payload.message_type !== "dynamic_qr_request") { return next(new ApiError("BAD_REQUEST", "message_type must be dynamic_qr_request", 400)); @@ -418,6 +434,10 @@ router.get("/config", requireDeviceToken, async (req: Request, res: Response, ne if (!deviceId) { return next(new ApiError("BAD_REQUEST", "device_id is required", 400)); } + const scopeError = getDeviceScopeError(req, deviceId); + if (scopeError) { + return next(scopeError); + } const device = await getDeviceById(deviceId); if (!device) { @@ -433,6 +453,10 @@ router.post("/config/ack", requireDeviceToken, async (req: Request, res: Respons if (!payload || !payload.device_id || !payload.status) { return next(new ApiError("BAD_REQUEST", "device_id, status are required", 400)); } + const scopeError = getDeviceScopeError(req, payload.device_id); + if (scopeError) { + return next(scopeError); + } if (!["applied", "failed"].includes(payload.status)) { return next(new ApiError("BAD_REQUEST", "status must be applied or failed", 400)); diff --git a/src/routes/merchant.ts b/src/routes/merchant.ts new file mode 100644 index 0000000..2c42c18 --- /dev/null +++ b/src/routes/merchant.ts @@ -0,0 +1,348 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { ApiError } from "../shared/errors"; +import { env } from "../config/env"; +import { successResponse } from "../shared/middleware/errorMiddleware"; +import { createMerchantSessionToken, verifyMerchantSessionToken, type MerchantSessionPayload } from "../shared/services/sessionToken"; +import { getMerchantByCode, getMerchantById, toMerchantPayload } from "../shared/store/merchantStore"; +import { + getMerchantUserByEmail, + toMerchantUserPayload, + verifyMerchantPassword +} from "../shared/store/merchantUserStore"; +import { + createSettlementBatchEvent, + getSettlementBatchByIdForMerchant, + getSettlementBatchReportRows, + getSettlementFinanceSummary, + listSettlementBatchAdjustments, + listSettlementBatchEntries, + listSettlementBatchEvents, + listSettlementBatches, + settlementBatchReportToBankGenericCsv, + settlementBatchReportToCsv, + SettlementBatchStatus, + toSettlementBatchAdjustmentPayload, + toSettlementBatchEntryPayload, + toSettlementBatchEventPayload, + toSettlementBatchPayload +} from "../shared/store/settlementStore"; +import { createAuditLog } from "../shared/store/auditLogStore"; + +const router = Router(); + +type MerchantRequest = Request & { + merchantId?: string; + merchantAuth?: { + mode: "legacy_token" | "session"; + merchant_id: string; + user_id?: string; + role_name?: string; + session?: MerchantSessionPayload; + }; +}; + +function extractToken(req: Request) { + const raw = req.header("authorization") || ""; + if (raw.startsWith("Bearer ")) { + return raw.slice(7); + } + return raw || req.header("x-merchant-token") || ""; +} + +function parseSettlementBatchStatus(value: string | undefined): SettlementBatchStatus | undefined { + if (value === "created" || value === "paid" || value === "failed" || value === "cancelled") { + return value; + } + return undefined; +} + +function parseSettlementExportFormat(value: string | undefined): "standard" | "bank_generic" | undefined { + if (!value || value === "standard" || value === "general") { + return "standard"; + } + if (value === "bank_generic") { + return "bank_generic"; + } + return undefined; +} + +async function requireMerchantToken(req: MerchantRequest, _res: Response, next: NextFunction) { + const token = extractToken(req); + const merchantId = req.header("x-merchant-id") || ""; + if (!token) { + return next(new ApiError("UNAUTHORIZED", "Missing merchant bearer token", 401)); + } + + const session = verifyMerchantSessionToken(token, env.MERCHANT_SESSION_SECRET); + if (session) { + if (merchantId && merchantId !== session.merchant_id) { + return next(new ApiError("FORBIDDEN", "merchant session cannot access another merchant", 403)); + } + const merchant = await getMerchantById(session.merchant_id); + if (!merchant) { + return next(new ApiError("UNAUTHORIZED", "Invalid merchant scope", 401)); + } + req.merchantId = merchant.id; + req.merchantAuth = { + mode: "session", + merchant_id: merchant.id, + user_id: session.sub, + role_name: session.role_name, + session + }; + return next(); + } + + const allowLegacyToken = env.MERCHANT_AUTH_ALLOW_LEGACY_TOKEN !== "false"; + if (!allowLegacyToken || token !== env.MERCHANT_TOKEN) { + return next(new ApiError("UNAUTHORIZED", "Invalid merchant token", 401)); + } + if (!merchantId) { + return next(new ApiError("UNAUTHORIZED", "Missing merchant scope", 401)); + } + const merchant = await getMerchantById(merchantId); + if (!merchant) { + return next(new ApiError("UNAUTHORIZED", "Invalid merchant scope", 401)); + } + req.merchantId = merchant.id; + req.merchantAuth = { + mode: "legacy_token", + merchant_id: merchant.id + }; + return next(); +} + +router.post("/login", async (req: Request, res: Response, next: NextFunction) => { + const payload = (req.body || {}) as { + merchant_id?: string; + merchant_code?: string; + username?: string; + password?: string; + }; + const loginId = (payload.merchant_id || payload.merchant_code || payload.username || "").trim(); + const password = payload.password || ""; + if (!loginId || !password) { + return next(new ApiError("BAD_REQUEST", "merchant id/code and password are required", 400)); + } + + const merchantUser = loginId.includes("@") ? await getMerchantUserByEmail(loginId) : null; + if (merchantUser) { + if (merchantUser.status !== "active" || !verifyMerchantPassword(merchantUser.password_hash, password)) { + await createAuditLog({ + actor_type: "merchant", + actor_id: merchantUser.id, + action: "merchant.login.failed", + entity_type: "merchant_session", + entity_id: merchantUser.merchant_id, + after_json: { + reason: merchantUser.status !== "active" ? "inactive_user" : "invalid_credentials", + auth_mode: "session" + }, + source_ip: req.ip, + request_id: req.requestId, + trace_id: req.traceId + }); + return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401)); + } + const merchant = await getMerchantById(merchantUser.merchant_id); + if (!merchant) { + return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401)); + } + const token = createMerchantSessionToken( + { + typ: "merchant", + sub: merchantUser.id, + merchant_id: merchant.id, + name: merchantUser.name, + email: merchantUser.email, + role_name: merchantUser.role_name + }, + env.MERCHANT_SESSION_SECRET, + env.MERCHANT_SESSION_TTL_SECONDS + ); + await createAuditLog({ + actor_type: "merchant", + actor_id: merchantUser.id, + action: "merchant.login.success", + entity_type: "merchant_session", + entity_id: merchant.id, + after_json: { + auth_mode: "session", + role_name: merchantUser.role_name + }, + source_ip: req.ip, + request_id: req.requestId, + trace_id: req.traceId + }); + return res.json( + successResponse(req, { + token, + auth_mode: "session", + merchant: toMerchantPayload(merchant), + user: toMerchantUserPayload(merchantUser) + }) + ); + } + + if (env.MERCHANT_DEV_LOGIN_ENABLED === "false" || password !== env.MERCHANT_PORTAL_PASSWORD) { + await createAuditLog({ + actor_type: "merchant", + actor_id: loginId || "unknown", + action: "merchant.login.failed", + entity_type: "merchant_session", + entity_id: loginId || "unknown", + after_json: { + reason: "invalid_legacy_credentials", + auth_mode: "legacy_token" + }, + source_ip: req.ip, + request_id: req.requestId, + trace_id: req.traceId + }); + return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401)); + } + + const merchant = (await getMerchantById(loginId)) || (await getMerchantByCode(loginId)); + if (!merchant) { + await createAuditLog({ + actor_type: "merchant", + actor_id: loginId || "unknown", + action: "merchant.login.failed", + entity_type: "merchant_session", + entity_id: loginId || "unknown", + after_json: { + reason: "merchant_not_found", + auth_mode: "legacy_token" + }, + source_ip: req.ip, + request_id: req.requestId, + trace_id: req.traceId + }); + return next(new ApiError("UNAUTHORIZED", "Invalid merchant credentials", 401)); + } + await createAuditLog({ + actor_type: "merchant", + actor_id: merchant.id, + action: "merchant.login.success", + entity_type: "merchant_session", + entity_id: merchant.id, + after_json: { + auth_mode: "legacy_token" + }, + source_ip: req.ip, + request_id: req.requestId, + trace_id: req.traceId + }); + res.json( + successResponse(req, { + token: env.MERCHANT_TOKEN, + auth_mode: "legacy_token", + merchant: toMerchantPayload(merchant) + }) + ); +}); + +router.get("/profile", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => { + const merchant = await getMerchantById(req.merchantId || ""); + if (!merchant) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + res.json( + successResponse(req, { + merchant: toMerchantPayload(merchant), + auth_mode: req.merchantAuth?.mode, + user: req.merchantAuth?.session + ? { + id: req.merchantAuth.session.sub, + name: req.merchantAuth.session.name, + email: req.merchantAuth.session.email, + role_name: req.merchantAuth.session.role_name + } + : null + }) + ); +}); + +router.get("/settlement-summary", requireMerchantToken, async (req: MerchantRequest, res: Response) => { + const summary = await getSettlementFinanceSummary({ merchant_id: req.merchantId }); + res.json(successResponse(req, summary)); +}); + +router.get("/settlement-batches", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => { + const statusRaw = req.query.status as string | undefined; + const status = parseSettlementBatchStatus(statusRaw); + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 100; + + if (statusRaw && !status) { + return next(new ApiError("BAD_REQUEST", "status must be created|paid|failed|cancelled", 400)); + } + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + + const batches = await listSettlementBatches({ + merchant_id: req.merchantId, + status, + limit + }); + res.json(successResponse(req, batches.map(toSettlementBatchPayload))); +}); + +router.get("/settlement-batches/:batchId", requireMerchantToken, async (req: MerchantRequest, res: Response, next: NextFunction) => { + const batch = await getSettlementBatchByIdForMerchant(req.params.batchId, req.merchantId || ""); + if (!batch) { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + const entries = await listSettlementBatchEntries(batch.id); + const adjustments = await listSettlementBatchAdjustments(batch.id); + const events = await listSettlementBatchEvents(batch.id); + res.json( + successResponse(req, { + batch: toSettlementBatchPayload(batch), + entries: entries.map(toSettlementBatchEntryPayload), + adjustments: adjustments.map(toSettlementBatchAdjustmentPayload), + events: events.map(toSettlementBatchEventPayload) + }) + ); +}); + +router.get( + "/settlement-batches/:batchId/export.csv", + requireMerchantToken, + async (req: MerchantRequest, res: Response, next: NextFunction) => { + const batch = await getSettlementBatchByIdForMerchant(req.params.batchId, req.merchantId || ""); + if (!batch) { + return next(new ApiError("NOT_FOUND", "settlement batch not found", 404)); + } + const formatRaw = req.query.format as string | undefined; + const format = parseSettlementExportFormat(formatRaw); + if (!format) { + return next(new ApiError("BAD_REQUEST", "format must be standard|bank_generic", 400)); + } + + const rows = await getSettlementBatchReportRows(batch.id); + const csv = format === "bank_generic" ? settlementBatchReportToBankGenericCsv(rows) : settlementBatchReportToCsv(rows); + const filename = + format === "bank_generic" + ? `${batch.batch_code}-bank-generic-payout.csv` + : `${batch.batch_code}-payout-report.csv`; + await createSettlementBatchEvent({ + batch_id: batch.id, + merchant_id: batch.merchant_id, + event_type: "csv_exported", + actor_type: "merchant", + actor_id: batch.merchant_id, + payload_json: { + format, + report_rows: rows.length, + filename + } + }); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(csv); + } +); + +export default router; diff --git a/src/shared/db/pool.ts b/src/shared/db/pool.ts index f369f99..4fc3c15 100644 --- a/src/shared/db/pool.ts +++ b/src/shared/db/pool.ts @@ -41,6 +41,13 @@ export function getPool(): Pool { return pool; } +export async function closePool() { + if (pool) { + await pool.end(); + pool = null; + } +} + export async function withClient(work: (client: PoolClient) => Promise): Promise { const client = await getPool().connect(); try { @@ -73,6 +80,41 @@ CREATE TABLE IF NOT EXISTS merchants ( updated_at TIMESTAMPTZ NOT NULL ); +CREATE TABLE IF NOT EXISTS merchant_users ( + id TEXT PRIMARY KEY, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role_name TEXT NOT NULL DEFAULT 'owner' CHECK (role_name IN ('owner', 'finance', 'ops', 'viewer')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_merchant_users_merchant ON merchant_users (merchant_id, status); + +CREATE TABLE IF NOT EXISTS export_jobs ( + id TEXT PRIMARY KEY, + job_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')), + requested_by TEXT, + request_json JSONB NOT NULL DEFAULT '{}'::jsonb, + result_content_type TEXT, + result_filename TEXT, + result_body TEXT, + result_storage_path TEXT, + result_size_bytes INTEGER, + expires_at TIMESTAMPTZ, + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_export_jobs_status ON export_jobs (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_export_jobs_expires_at ON export_jobs (expires_at); + CREATE TABLE IF NOT EXISTS outlets ( id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, @@ -104,6 +146,12 @@ CREATE TABLE IF NOT EXISTS devices ( communication_mode TEXT NOT NULL DEFAULT 'static' CHECK (communication_mode IN ('static', 'mqtt', 'api')), capability_profile_json JSONB NOT NULL DEFAULT '{}'::jsonb, auth_method TEXT, + mqtt_username TEXT, + credential_secret_fingerprint TEXT, + credential_status TEXT NOT NULL DEFAULT 'not_issued' CHECK (credential_status IN ('not_issued', 'active', 'rotation_required', 'revoked')), + credential_issued_at TIMESTAMPTZ, + credential_rotated_at TIMESTAMPTZ, + credential_revoked_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), last_seen_at TIMESTAMPTZ, firmware_version TEXT, @@ -111,6 +159,13 @@ CREATE TABLE IF NOT EXISTS devices ( updated_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC); +ALTER TABLE devices ADD COLUMN IF NOT EXISTS mqtt_username TEXT; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_secret_fingerprint TEXT; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_status TEXT NOT NULL DEFAULT 'not_issued'; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_issued_at TIMESTAMPTZ; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_rotated_at TIMESTAMPTZ; +ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_revoked_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_devices_credential_status ON devices (credential_status); CREATE TABLE IF NOT EXISTS device_bindings ( id TEXT PRIMARY KEY, @@ -263,6 +318,151 @@ CREATE TABLE IF NOT EXISTS ledger_entries ( 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 settlement_batches ( + id TEXT PRIMARY KEY, + batch_code TEXT NOT NULL UNIQUE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + currency TEXT NOT NULL DEFAULT 'IDR', + gross_amount NUMERIC(20,2) NOT NULL DEFAULT 0, + platform_fee_amount NUMERIC(20,2) NOT NULL DEFAULT 0, + net_payable_amount NUMERIC(20,2) NOT NULL DEFAULT 0, + entry_count INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'paid', 'failed', 'cancelled')), + cutoff_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + paid_at TIMESTAMPTZ, + failure_reason TEXT, + metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_settlement_batches_merchant_created ON settlement_batches (merchant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_settlement_batches_status_created ON settlement_batches (status, created_at DESC); + +CREATE TABLE IF NOT EXISTS settlement_batch_entries ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE, + ledger_entry_id TEXT NOT NULL REFERENCES ledger_entries (id) ON DELETE CASCADE, + transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + amount NUMERIC(20,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'IDR', + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT settlement_batch_entries_unique_ledger UNIQUE (ledger_entry_id) +); + +CREATE INDEX IF NOT EXISTS idx_settlement_batch_entries_batch ON settlement_batch_entries (batch_id); +CREATE INDEX IF NOT EXISTS idx_settlement_batch_entries_merchant ON settlement_batch_entries (merchant_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS settlement_batch_events ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + actor_type TEXT NOT NULL, + actor_id TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_settlement_batch_events_batch ON settlement_batch_events (batch_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_settlement_batch_events_merchant ON settlement_batch_events (merchant_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS settlement_batch_adjustments ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + adjustment_type TEXT NOT NULL CHECK (adjustment_type IN ('credit', 'debit')), + amount NUMERIC(20,2) NOT NULL, + signed_amount NUMERIC(20,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'IDR', + reason TEXT NOT NULL, + note TEXT, + approval_status TEXT NOT NULL DEFAULT 'approved' CHECK (approval_status IN ('pending', 'approved', 'rejected')), + approved_by TEXT, + approved_at TIMESTAMPTZ, + rejected_by TEXT, + rejected_at TIMESTAMPTZ, + actor_type TEXT NOT NULL DEFAULT 'admin', + actor_id TEXT, + metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL +); + +ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approval_status TEXT NOT NULL DEFAULT 'approved'; +ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approved_by TEXT; +ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; +ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS rejected_by TEXT; +ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_batch ON settlement_batch_adjustments (batch_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_merchant ON settlement_batch_adjustments (merchant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_approval ON settlement_batch_adjustments (approval_status, created_at DESC); + +INSERT INTO settlement_batch_adjustments ( + id, + batch_id, + merchant_id, + adjustment_type, + amount, + signed_amount, + currency, + reason, + note, + approval_status, + approved_by, + approved_at, + actor_type, + actor_id, + metadata_json, + created_at +) +SELECT + COALESCE(adjustment.item->>'id', 'adj_backfill_' || sb.id || '_' || adjustment.ordinality::text) AS id, + sb.id AS batch_id, + sb.merchant_id, + CASE + WHEN adjustment.item->>'adjustment_type' IN ('credit', 'debit') THEN adjustment.item->>'adjustment_type' + WHEN COALESCE(NULLIF(adjustment.item->>'signed_amount', '')::numeric, 0) >= 0 THEN 'credit' + ELSE 'debit' + END AS adjustment_type, + ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, NULLIF(adjustment.item->>'signed_amount', '')::numeric, 0)) AS amount, + COALESCE( + NULLIF(adjustment.item->>'signed_amount', '')::numeric, + CASE + WHEN adjustment.item->>'adjustment_type' = 'debit' THEN -ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, 0)) + ELSE ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, 0)) + END + ) AS signed_amount, + sb.currency, + COALESCE(NULLIF(adjustment.item->>'reason', ''), 'Backfilled settlement adjustment') AS reason, + NULLIF(adjustment.item->>'note', '') AS note, + 'approved' AS approval_status, + COALESCE(NULLIF(adjustment.item->>'actor_id', ''), 'metadata_backfill') AS approved_by, + COALESCE(NULLIF(adjustment.item->>'created_at', '')::timestamptz, sb.created_at) AS approved_at, + COALESCE(NULLIF(adjustment.item->>'actor_type', ''), 'admin') AS actor_type, + NULLIF(adjustment.item->>'actor_id', '') AS actor_id, + jsonb_build_object('source', 'metadata_backfill', 'original', adjustment.item) AS metadata_json, + COALESCE(NULLIF(adjustment.item->>'created_at', '')::timestamptz, sb.created_at) AS created_at +FROM settlement_batches sb +CROSS JOIN LATERAL jsonb_array_elements(sb.metadata_json->'adjustments') WITH ORDINALITY AS adjustment(item, ordinality) +WHERE jsonb_typeof(sb.metadata_json->'adjustments') = 'array' +ON CONFLICT (id) DO NOTHING; + +WITH adjustment_totals AS ( + SELECT batch_id, COALESCE(SUM(signed_amount), 0) AS total_adjustment_amount + FROM settlement_batch_adjustments + WHERE approval_status = 'approved' + GROUP BY batch_id +) +UPDATE settlement_batches sb + SET metadata_json = sb.metadata_json || jsonb_build_object( + 'total_adjustment_amount', adjustment_totals.total_adjustment_amount, + 'adjustment_source', 'settlement_batch_adjustments', + 'adjustment_backfilled_at', NOW() + ) + FROM adjustment_totals + WHERE adjustment_totals.batch_id = sb.id; + CREATE TABLE IF NOT EXISTS roles ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -302,6 +502,14 @@ INSERT INTO roles (id, name, permissions_json, created_at) VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING; +INSERT INTO roles (id, name, permissions_json, created_at) +VALUES + ('role_finance', 'finance', '{"admin":["read"],"merchant":["read"],"device":["read"],"transaction":["read"],"settlement":"*","reconciliation":"*","audit":["read"]}'::jsonb, NOW()), + ('role_ops', 'ops', '{"admin":["read"],"merchant":"*","outlet":"*","terminal":"*","device":"*","transaction":"*","notification":"*","settlement":["read"],"reconciliation":["read"],"audit":["read"]}'::jsonb, NOW()), + ('role_support', 'support', '{"admin":["read"],"merchant":["read"],"outlet":["read"],"terminal":["read"],"device":["read"],"transaction":["read"],"notification":["read"],"settlement":["read"],"audit":["read"]}'::jsonb, NOW()), + ('role_viewer', 'viewer', '{"admin":["read"],"merchant":["read"],"outlet":["read"],"terminal":["read"],"device":["read"],"transaction":["read"],"settlement":["read"],"reconciliation":["read"]}'::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; diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts index ff092b9..a06a349 100644 --- a/src/shared/errors/index.ts +++ b/src/shared/errors/index.ts @@ -4,6 +4,7 @@ export type ErrorCode = | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" + | "RATE_LIMITED" | "INTERNAL_ERROR" | "DEVICE_UNAUTHORIZED" | "DUPLICATE_REQUEST" diff --git a/src/shared/middleware/auth.ts b/src/shared/middleware/auth.ts index d200801..69885fb 100644 --- a/src/shared/middleware/auth.ts +++ b/src/shared/middleware/auth.ts @@ -1,6 +1,44 @@ import { NextFunction, Request, Response } from "express"; import { ApiError } from "../errors"; import { env } from "../../config/env"; +import { verifyDeviceSecret } from "../store/deviceStore"; +import { verifySessionToken, type SessionPayload } from "../services/sessionToken"; +import { hasPermission } from "../store/userStore"; + +export type DeviceAuthContext = + | { + mode: "legacy_token"; + } + | { + mode: "device_credential"; + device_id: string; + }; + +export type RequestWithDeviceAuth = Request & { + deviceAuth?: DeviceAuthContext; +}; + +export type AdminAuthContext = + | { + mode: "legacy_token"; + user_id: "legacy_admin"; + role_name: "admin"; + permissions: { admin: "*" }; + } + | { + mode: "session"; + user_id: string; + name: string; + email: string; + role_id: string; + role_name: string; + permissions: unknown; + session: SessionPayload; + }; + +export type RequestWithAdminAuth = Request & { + adminAuth?: AdminAuthContext; +}; function extractAdminToken(req: Request) { const raw = req.header("authorization") || ""; @@ -11,30 +49,106 @@ function extractAdminToken(req: Request) { return raw || req.header("x-admin-token") || ""; } -export function requireAdminToken(req: Request, _res: Response, next: NextFunction) { +export function requireAdminToken(req: RequestWithAdminAuth, _res: Response, next: NextFunction) { const token = extractAdminToken(req); if (!token) { return next(new ApiError("UNAUTHORIZED", "Missing admin bearer token", 401)); } - if (token !== env.ADMIN_TOKEN) { + const session = verifySessionToken(token, env.ADMIN_SESSION_SECRET); + if (session) { + req.adminAuth = { + mode: "session", + user_id: session.sub, + name: session.name, + email: session.email, + role_id: session.role_id, + role_name: session.role_name, + permissions: session.permissions, + session + }; + return next(); + } + + const allowLegacyToken = env.ADMIN_AUTH_ALLOW_LEGACY_TOKEN !== "false"; + if (!allowLegacyToken || token !== env.ADMIN_TOKEN) { return next(new ApiError("UNAUTHORIZED", "Invalid admin token", 401)); } + req.adminAuth = { + mode: "legacy_token", + user_id: "legacy_admin", + role_name: "admin", + permissions: { admin: "*" } + }; return next(); } -export function requireDeviceToken(req: Request, _res: Response, next: NextFunction) { +export function requireAdminPermission(permission: string) { + return (req: RequestWithAdminAuth, _res: Response, next: NextFunction) => { + if (!req.adminAuth) { + return requireAdminToken(req, _res, (error?: unknown) => { + if (error) { + return next(error); + } + if (!hasPermission(req.adminAuth?.permissions, permission)) { + return next(new ApiError("FORBIDDEN", "Admin role cannot perform this action", 403)); + } + return next(); + }); + } + + if (!hasPermission(req.adminAuth.permissions, permission)) { + return next(new ApiError("FORBIDDEN", "Admin role cannot perform this action", 403)); + } + return next(); + }; +} + +export function requireDeviceToken(req: RequestWithDeviceAuth, _res: Response, next: NextFunction) { const raw = req.header("authorization") || ""; const token = raw.startsWith("Bearer ") ? raw.slice(7) : raw; + const headerDeviceId = req.header("x-device-id") || ""; + const headerSecret = req.header("x-device-secret") || ""; + const allowLegacyToken = env.DEVICE_AUTH_ALLOW_LEGACY_TOKEN !== "false"; + + if (headerDeviceId && headerSecret) { + verifyDeviceSecret(headerDeviceId, headerSecret) + .then((device) => { + if (!device) { + return next(new ApiError("UNAUTHORIZED", "Invalid device credential", 401)); + } + req.deviceAuth = { + mode: "device_credential", + device_id: device.id + }; + return next(); + }) + .catch((error) => next(error)); + return; + } + + if (!allowLegacyToken) { + return next(new ApiError("UNAUTHORIZED", "Device bearer token fallback is disabled", 401)); + } if (!token) { - return next(new ApiError("UNAUTHORIZED", "Missing device bearer token", 401)); + return next(new ApiError("UNAUTHORIZED", "Missing device credential", 401)); } if (token !== env.DEVICE_TOKEN) { return next(new ApiError("UNAUTHORIZED", "Invalid device token", 401)); } + req.deviceAuth = { + mode: "legacy_token" + }; return next(); } + +export function getDeviceScopeError(req: RequestWithDeviceAuth, deviceId: string): ApiError | null { + if (req.deviceAuth?.mode === "device_credential" && req.deviceAuth.device_id !== deviceId) { + return new ApiError("FORBIDDEN", "device credential cannot access another device", 403); + } + return null; +} diff --git a/src/shared/middleware/errorMiddleware.ts b/src/shared/middleware/errorMiddleware.ts index 65e7a55..161491c 100644 --- a/src/shared/middleware/errorMiddleware.ts +++ b/src/shared/middleware/errorMiddleware.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from "express"; import { ApiError, errorEnvelope } from "../errors"; +import { logger } from "../services/logger"; export interface EnvelopeSuccess { data: T; @@ -17,6 +18,13 @@ export function successResponse(req: Request, data: T): EnvelopeSuccess { export function handleErrors(err: Error, req: Request, res: Response, _next: NextFunction) { if (err instanceof ApiError) { + logger.warn("api_error", { + request_id: req.requestId, + trace_id: req.traceId, + code: err.code, + status_code: err.statusCode, + message: err.message + }); res.status(err.statusCode).json( errorEnvelope( { @@ -28,6 +36,11 @@ export function handleErrors(err: Error, req: Request, res: Response, _next: Nex return; } + logger.error("unhandled_error", { + request_id: req.requestId, + trace_id: req.traceId, + error: err + }); res.status(500).json({ code: "INTERNAL_ERROR", message: err.message || "Unexpected server error", diff --git a/src/shared/middleware/rateLimit.ts b/src/shared/middleware/rateLimit.ts new file mode 100644 index 0000000..b886399 --- /dev/null +++ b/src/shared/middleware/rateLimit.ts @@ -0,0 +1,66 @@ +import { NextFunction, Request, Response } from "express"; +import { env } from "../../config/env"; +import { ApiError } from "../errors"; + +type RateLimitOptions = { + name: string; + windowMs: number; + max: number; + key?: (req: Request) => string; +}; + +type Bucket = { + count: number; + resetAt: number; +}; + +const buckets = new Map(); + +function clientIp(req: Request) { + const forwarded = String(req.headers["x-forwarded-for"] || ""); + return forwarded.split(",")[0]?.trim() || req.ip || req.socket.remoteAddress || "unknown"; +} + +function defaultKey(req: Request) { + return clientIp(req); +} + +setInterval(() => { + const now = Date.now(); + for (const [key, bucket] of buckets.entries()) { + if (bucket.resetAt <= now) { + buckets.delete(key); + } + } +}, 60000).unref?.(); + +export function rateLimit(options: RateLimitOptions) { + const enabled = String(env.RATE_LIMIT_ENABLED).toLowerCase() !== "false"; + const windowMs = Math.max(options.windowMs || 60000, 1000); + const max = Math.max(options.max || 60, 1); + + return (req: Request, res: Response, next: NextFunction) => { + if (!enabled) { + return next(); + } + + const now = Date.now(); + const key = `${options.name}:${(options.key || defaultKey)(req)}`; + const existing = buckets.get(key); + const bucket = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + windowMs }; + bucket.count += 1; + buckets.set(key, bucket); + + const remaining = Math.max(max - bucket.count, 0); + res.setHeader("RateLimit-Limit", String(max)); + res.setHeader("RateLimit-Remaining", String(remaining)); + res.setHeader("RateLimit-Reset", String(Math.ceil(bucket.resetAt / 1000))); + + if (bucket.count > max) { + res.setHeader("Retry-After", String(Math.ceil((bucket.resetAt - now) / 1000))); + return next(new ApiError("RATE_LIMITED", "Too many requests, please retry later", 429)); + } + + return next(); + }; +} diff --git a/src/shared/middleware/requestLogging.ts b/src/shared/middleware/requestLogging.ts new file mode 100644 index 0000000..2673312 --- /dev/null +++ b/src/shared/middleware/requestLogging.ts @@ -0,0 +1,34 @@ +import { NextFunction, Request, Response } from "express"; +import { logger } from "../services/logger"; + +function classifyLevel(statusCode: number) { + if (statusCode >= 500) { + return "error" as const; + } + if (statusCode >= 400) { + return "warn" as const; + } + return "info" as const; +} + +export function requestLogging(req: Request, res: Response, next: NextFunction) { + const startedAt = process.hrtime.bigint(); + + res.on("finish", () => { + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + const level = classifyLevel(res.statusCode); + logger[level]("http_request", { + request_id: req.requestId, + trace_id: req.traceId, + method: req.method, + path: req.originalUrl || req.url, + status_code: res.statusCode, + duration_ms: Number(durationMs.toFixed(2)), + content_length: res.getHeader("content-length") || null, + user_agent: req.header("user-agent") || null, + ip: req.ip + }); + }); + + next(); +} diff --git a/src/shared/services/dynamicQrExpiryScheduler.ts b/src/shared/services/dynamicQrExpiryScheduler.ts new file mode 100644 index 0000000..9e43e7f --- /dev/null +++ b/src/shared/services/dynamicQrExpiryScheduler.ts @@ -0,0 +1,73 @@ +import { env } from "../../config/env"; +import { expireDueDynamicQrTransactions } from "./dynamicQrExpiry"; + +type SchedulerRunResult = Awaited>; + +type SchedulerStatus = { + enabled: boolean; + running: boolean; + interval_ms: number; + limit: number; + last_started_at: string | null; + last_finished_at: string | null; + last_result: SchedulerRunResult | null; + last_error: string | null; +}; + +const enabled = String(env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED).toLowerCase() !== "false"; +const intervalMs = Math.max(env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000, 5000); +const limit = Math.min(Math.max(env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100, 1), 500); + +let timer: NodeJS.Timeout | null = null; +let running = false; +let lastStartedAt: string | null = null; +let lastFinishedAt: string | null = null; +let lastResult: SchedulerRunResult | null = null; +let lastError: string | null = null; + +async function runSweep() { + if (running) { + return; + } + + running = true; + lastStartedAt = new Date().toISOString(); + lastError = null; + + try { + lastResult = await expireDueDynamicQrTransactions({ + limit, + source: "system", + request_id: `dynamic_qr_expiry_scheduler_${lastStartedAt}` + }); + } catch (error) { + lastError = error instanceof Error ? error.message : "UNKNOWN_ERROR"; + } finally { + running = false; + lastFinishedAt = new Date().toISOString(); + } +} + +export function startDynamicQrExpiryScheduler() { + if (!enabled || timer) { + return; + } + + timer = setInterval(() => { + void runSweep(); + }, intervalMs); + timer.unref?.(); +} + +export function getDynamicQrExpirySchedulerStatus(): SchedulerStatus { + return { + enabled, + running, + interval_ms: intervalMs, + limit, + last_started_at: lastStartedAt, + last_finished_at: lastFinishedAt, + last_result: lastResult, + last_error: lastError + }; +} diff --git a/src/shared/services/exportJobWorker.ts b/src/shared/services/exportJobWorker.ts new file mode 100644 index 0000000..818cc92 --- /dev/null +++ b/src/shared/services/exportJobWorker.ts @@ -0,0 +1,277 @@ +import { env } from "../../config/env"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { ApiError } from "../errors"; +import { + claimNextPendingExportJob, + completeExportJob, + createExportJob, + failExportJob, + markExpiredExportJobs, + resetStaleRunningExportJobs, + type ExportJobEntity +} from "../store/exportJobStore"; +import { listSettlementAdjustments, settlementAdjustmentReportToCsv } from "../store/settlementStore"; +import { logger } from "./logger"; + +export type SettlementAdjustmentExportRequest = { + merchant_id?: string; + adjustment_type?: string; + approval_status?: string; + from?: string; + to?: string; + limit?: number; +}; + +type WorkerStatus = { + enabled: boolean; + running: boolean; + interval_ms: number; + batch_size: number; + max_settlement_adjustment_rows: number; + storage_dir: string; + retention_days: number; + last_started_at: string | null; + last_finished_at: string | null; + last_error: string | null; + last_processed_job_id: string | null; + last_processed_count: number; + stale_reset_count: number; + expired_cleanup_count: number; +}; + +const enabled = String(env.EXPORT_WORKER_ENABLED).toLowerCase() !== "false"; +const intervalMs = Math.max(env.EXPORT_WORKER_INTERVAL_MS || 2000, 500); +const batchSize = Math.min(Math.max(env.EXPORT_WORKER_BATCH_SIZE || 2, 1), 10); +const staleRunningMs = Math.max(env.EXPORT_JOB_STALE_RUNNING_MS || 900000, 60000); +const maxSettlementAdjustmentRows = Math.min(Math.max(env.EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS || 5000, 1), 10000); +const exportStorageDir = path.resolve(process.cwd(), env.EXPORT_STORAGE_DIR || "./storage/exports"); +const retentionDays = Math.max(env.EXPORT_RETENTION_DAYS || 7, 1); + +let timer: NodeJS.Timeout | null = null; +let running = false; +let lastStartedAt: string | null = null; +let lastFinishedAt: string | null = null; +let lastError: string | null = null; +let lastProcessedJobId: string | null = null; +let lastProcessedCount = 0; +let staleResetCount = 0; +let expiredCleanupCount = 0; +let staleResetDone = false; + +function exportExpiresAt() { + return new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000).toISOString(); +} + +function safeFilename(value: string) { + return value.replace(/[^a-zA-Z0-9._-]/g, "-"); +} + +async function writeExportResult(job: ExportJobEntity, filename: string, body: string) { + await fs.mkdir(exportStorageDir, { recursive: true }); + const safeName = safeFilename(filename); + const filePath = path.join(exportStorageDir, `${job.id}-${safeName}`); + await fs.writeFile(filePath, body, "utf8"); + return { + path: filePath, + size: Buffer.byteLength(body, "utf8") + }; +} + +function parseSettlementAdjustmentType(value?: string): "credit" | "debit" | undefined { + if (!value) { + return undefined; + } + return value === "credit" || value === "debit" ? value : undefined; +} + +function parseSettlementAdjustmentApprovalStatus(value?: string): "pending" | "approved" | "rejected" | undefined { + if (!value) { + return undefined; + } + return value === "pending" || value === "approved" || value === "rejected" ? value : undefined; +} + +function isIsoDate(value: string) { + const date = new Date(value); + return !Number.isNaN(date.getTime()); +} + +function normalizeSettlementAdjustmentExportRequest(payload: SettlementAdjustmentExportRequest) { + const merchantId = payload.merchant_id?.trim(); + const adjustmentType = parseSettlementAdjustmentType(payload.adjustment_type); + const approvalStatus = parseSettlementAdjustmentApprovalStatus(payload.approval_status); + const from = payload.from?.trim(); + const to = payload.to?.trim(); + const limit = payload.limit ? Number(payload.limit) : 500; + + if (payload.adjustment_type && !adjustmentType) { + throw new ApiError("BAD_REQUEST", "adjustment_type must be credit|debit", 400); + } + if (payload.approval_status && !approvalStatus) { + throw new ApiError("BAD_REQUEST", "approval_status must be pending|approved|rejected", 400); + } + if (from && !isIsoDate(from)) { + throw new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400); + } + if (to && !isIsoDate(to)) { + throw new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400); + } + if (!Number.isFinite(limit) || limit <= 0) { + throw new ApiError("BAD_REQUEST", "limit must be a positive number", 400); + } + + return { + merchant_id: merchantId || undefined, + adjustment_type: adjustmentType, + approval_status: approvalStatus, + from: from || undefined, + to: to || undefined, + limit: Math.min(limit, maxSettlementAdjustmentRows), + max_limit: maxSettlementAdjustmentRows + }; +} + +export async function createSettlementAdjustmentExportJob(payload: { + requested_by?: string; + request: SettlementAdjustmentExportRequest; +}) { + const requestJson = normalizeSettlementAdjustmentExportRequest(payload.request); + return createExportJob({ + job_type: "settlement_adjustments_csv", + requested_by: payload.requested_by || "admin", + request_json: requestJson + }); +} + +async function processSettlementAdjustmentExport(job: ExportJobEntity) { + const report = await listSettlementAdjustments({ + ...(job.request_json || {}), + max_limit: maxSettlementAdjustmentRows + }); + const csv = settlementAdjustmentReportToCsv(report); + const filename = `settlement-adjustment-report-${job.id}.csv`; + const stored = await writeExportResult(job, filename, csv); + return completeExportJob(job.id, { + result_content_type: "text/csv; charset=utf-8", + result_filename: filename, + result_storage_path: stored.path, + result_size_bytes: stored.size, + expires_at: exportExpiresAt() + }); +} + +async function processExportJob(job: ExportJobEntity) { + if (job.job_type === "settlement_adjustments_csv") { + return processSettlementAdjustmentExport(job); + } + throw new Error(`unsupported export job type: ${job.job_type}`); +} + +async function runWorkerOnce() { + if (running) { + return; + } + + running = true; + lastStartedAt = new Date().toISOString(); + lastError = null; + lastProcessedCount = 0; + + try { + if (!staleResetDone) { + await resetStaleRunningJobs(); + await cleanupExpiredExports(); + staleResetDone = true; + } + + for (let i = 0; i < batchSize; i += 1) { + const job = await claimNextPendingExportJob(); + if (!job) { + break; + } + + try { + await processExportJob(job); + lastProcessedJobId = job.id; + lastProcessedCount += 1; + logger.info("export_job_completed", { job_id: job.id, job_type: job.job_type }); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown"; + await failExportJob(job.id, message); + lastError = message; + logger.error("export_job_failed", { job_id: job.id, job_type: job.job_type, error }); + } + } + } catch (error) { + lastError = error instanceof Error ? error.message : "unknown"; + logger.error("export_worker_run_failed", { error }); + } finally { + running = false; + lastFinishedAt = new Date().toISOString(); + } +} + +async function resetStaleRunningJobs() { + const cutoffIso = new Date(Date.now() - staleRunningMs).toISOString(); + const resetCount = await resetStaleRunningExportJobs(cutoffIso); + staleResetCount += resetCount; + if (resetCount > 0) { + logger.warn("export_jobs_reset_stale_running", { reset_count: resetCount, cutoff_at: cutoffIso }); + } +} + +async function cleanupExpiredExports() { + const expired = await markExpiredExportJobs(new Date().toISOString()); + for (const job of expired) { + if (!job.result_storage_path) { + continue; + } + try { + await fs.unlink(job.result_storage_path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + logger.warn("export_result_file_delete_failed", { + job_id: job.id, + path: job.result_storage_path, + error + }); + } + } + } + expiredCleanupCount += expired.length; + if (expired.length > 0) { + logger.info("export_results_expired", { count: expired.length }); + } +} + +export function startExportJobWorker() { + if (!enabled || timer) { + return; + } + + timer = setInterval(() => { + void runWorkerOnce(); + }, intervalMs); + timer.unref?.(); +} + +export function getExportJobWorkerStatus(): WorkerStatus { + return { + enabled, + running, + interval_ms: intervalMs, + batch_size: batchSize, + max_settlement_adjustment_rows: maxSettlementAdjustmentRows, + storage_dir: exportStorageDir, + retention_days: retentionDays, + last_started_at: lastStartedAt, + last_finished_at: lastFinishedAt, + last_error: lastError, + last_processed_job_id: lastProcessedJobId, + last_processed_count: lastProcessedCount, + stale_reset_count: staleResetCount, + expired_cleanup_count: expiredCleanupCount + }; +} diff --git a/src/shared/services/health.ts b/src/shared/services/health.ts new file mode 100644 index 0000000..0cbe8a4 --- /dev/null +++ b/src/shared/services/health.ts @@ -0,0 +1,42 @@ +import { getPool } from "../db/pool"; +import { getMqttPublisherStatus } from "./mqttPublisher"; +import { getMqttSubscriberStatus } from "./mqttSubscriber"; + +export async function getDatabaseHealth() { + const startedAt = process.hrtime.bigint(); + try { + const result = await getPool().query("SELECT NOW() AS now"); + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + return { + status: "ok" as const, + latency_ms: Number(durationMs.toFixed(2)), + server_time: result.rows[0]?.now || null + }; + } catch (error) { + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + return { + status: "error" as const, + latency_ms: Number(durationMs.toFixed(2)), + error: error instanceof Error ? error.message : "unknown" + }; + } +} + +export async function getServiceHealth() { + const database = await getDatabaseHealth(); + const mqttPublisher = getMqttPublisherStatus(); + const mqttSubscriber = getMqttSubscriberStatus(); + const brokerRequired = mqttPublisher.mode === "broker"; + const mqttOk = !brokerRequired || mqttPublisher.connected || mqttPublisher.publish_attempt_count === 0; + + return { + status: database.status === "ok" && mqttOk ? "healthy" : "degraded", + checks: { + database, + mqtt: { + publisher: mqttPublisher, + subscriber: mqttSubscriber + } + } + }; +} diff --git a/src/shared/services/logger.ts b/src/shared/services/logger.ts new file mode 100644 index 0000000..20e5278 --- /dev/null +++ b/src/shared/services/logger.ts @@ -0,0 +1,76 @@ +import { env } from "../../config/env"; + +type LogLevel = "debug" | "info" | "warn" | "error"; + +const levelRank: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40 +}; + +function normalizeLevel(value: string): LogLevel { + if (value === "debug" || value === "info" || value === "warn" || value === "error") { + return value; + } + return "info"; +} + +function shouldLog(level: LogLevel) { + return levelRank[level] >= levelRank[normalizeLevel(env.LOG_LEVEL)]; +} + +function serializeError(error: unknown) { + if (!(error instanceof Error)) { + return error; + } + return { + name: error.name, + message: error.message, + stack: env.LOG_FORMAT === "json" ? error.stack : undefined + }; +} + +export function log(level: LogLevel, event: string, fields?: Record) { + if (!shouldLog(level)) { + return; + } + + const payload = { + timestamp: new Date().toISOString(), + level, + event, + ...(fields || {}) + }; + + if (env.LOG_FORMAT === "json") { + const normalized = Object.fromEntries( + Object.entries(payload).map(([key, value]) => [key, key === "error" ? serializeError(value) : value]) + ); + const line = JSON.stringify(normalized); + if (level === "error") { + console.error(line); + } else if (level === "warn") { + console.warn(line); + } else { + console.log(line); + } + return; + } + + const suffix = fields ? ` ${JSON.stringify(fields, (_key, value) => (value instanceof Error ? serializeError(value) : value))}` : ""; + if (level === "error") { + console.error(`[${level}] ${event}${suffix}`); + } else if (level === "warn") { + console.warn(`[${level}] ${event}${suffix}`); + } else { + console.log(`[${level}] ${event}${suffix}`); + } +} + +export const logger = { + debug: (event: string, fields?: Record) => log("debug", event, fields), + info: (event: string, fields?: Record) => log("info", event, fields), + warn: (event: string, fields?: Record) => log("warn", event, fields), + error: (event: string, fields?: Record) => log("error", event, fields) +}; diff --git a/src/shared/services/mqttPublisher.ts b/src/shared/services/mqttPublisher.ts index 43983f0..d7be4a4 100644 --- a/src/shared/services/mqttPublisher.ts +++ b/src/shared/services/mqttPublisher.ts @@ -1,4 +1,5 @@ import { env } from "../../config/env"; +import mqtt, { type IClientOptions, type MqttClient } from "mqtt"; type PaymentSuccessPayload = { message_type: "payment_success"; @@ -47,11 +48,102 @@ const forcedFailDevices = new Set( .map((item) => item.trim()) .filter(Boolean) ); +const publishMode = String(env.MQTT_PUBLISH_MODE || "simulator").toLowerCase(); +let brokerClientPromise: Promise | null = null; +let brokerClientRef: MqttClient | null = null; +let lastConnectedAt: string | null = null; +let lastDisconnectedAt: string | null = null; +let lastError: { message: string; at: string } | null = null; +let publishAttemptCount = 0; +let publishSuccessCount = 0; +let publishFailureCount = 0; function shouldForceFail(deviceId: string): boolean { return forcedFailAll || forcedFailDevices.has(deviceId); } +function getBrokerClient(): Promise { + if (brokerClientPromise) { + return brokerClientPromise; + } + + brokerClientPromise = new Promise((resolve, reject) => { + if (!env.MQTT_BROKER_URL) { + reject(new Error("MQTT_BROKER_URL_MISSING")); + return; + } + + const options: IClientOptions = { + clientId: env.MQTT_CLIENT_ID, + username: env.MQTT_USERNAME || undefined, + password: env.MQTT_PASSWORD || undefined, + connectTimeout: env.MQTT_CONNECT_TIMEOUT_MS, + reconnectPeriod: 5000, + clean: true + }; + const client = mqtt.connect(env.MQTT_BROKER_URL, options); + const timeout = setTimeout(() => { + client.end(true); + brokerClientPromise = null; + reject(new Error("MQTT_BROKER_CONNECT_TIMEOUT")); + }, env.MQTT_CONNECT_TIMEOUT_MS + 1000); + + const failBeforeConnect = (error: Error) => { + clearTimeout(timeout); + brokerClientPromise = null; + lastError = { message: error.message, at: new Date().toISOString() }; + client.end(true); + reject(error); + }; + + client.once("connect", () => { + clearTimeout(timeout); + client.removeListener("error", failBeforeConnect); + brokerClientRef = client; + lastConnectedAt = new Date().toISOString(); + resolve(client); + }); + + client.once("error", failBeforeConnect); + client.on("error", (error) => { + lastError = { message: error.message, at: new Date().toISOString() }; + }); + client.on("close", () => { + if (!client.connected) { + brokerClientPromise = null; + brokerClientRef = null; + lastDisconnectedAt = new Date().toISOString(); + } + }); + }); + + return brokerClientPromise; +} + +async function publishToBroker( + topic: string, + payload: TPayload +): Promise { + const client = await getBrokerClient(); + await new Promise((resolve, reject) => { + client.publish( + topic, + JSON.stringify(payload), + { + qos: 1, + retain: false + }, + (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + } + ); + }); +} + export function buildPaymentSuccessPayload( input: { transaction_id: string; @@ -101,8 +193,10 @@ async function publishMqttPayload( payload: TPayload ): Promise> { const publishedAt = new Date().toISOString(); + publishAttemptCount += 1; if (shouldForceFail(deviceId)) { + publishFailureCount += 1; return { ok: false, topic, @@ -114,6 +208,25 @@ async function publishMqttPayload( }; } + if (publishMode === "broker") { + try { + await publishToBroker(topic, payload); + } catch (error) { + const message = error instanceof Error ? error.message : "unknown"; + publishFailureCount += 1; + return { + ok: false, + topic, + qos: 1, + retained: false, + publishedAt, + reason: `MQTT_BROKER_PUBLISH_FAILED:${message}`, + payload + }; + } + } + + publishSuccessCount += 1; return { ok: true, topic, @@ -135,3 +248,26 @@ export async function publishDynamicQrResponse(deviceId: string, payload: Dynami export async function publishConfigPush(deviceId: string, payload: ConfigPushPayload) { return publishMqttPayload(deviceId, makeConfigPushTopic(deviceId), payload); } + +export function getMqttPublisherStatus() { + const mode = publishMode === "broker" ? "broker" : "simulator"; + const brokerUrl = env.MQTT_BROKER_URL + ? env.MQTT_BROKER_URL.replace(/(mqtts?:\/\/)([^:@/]+):([^@/]+)@/i, "$1***:***@") + : ""; + + return { + mode, + broker_url: mode === "broker" ? brokerUrl : null, + client_id: mode === "broker" ? env.MQTT_CLIENT_ID : null, + username: mode === "broker" ? env.MQTT_USERNAME || null : null, + connected: mode === "broker" ? Boolean(brokerClientRef?.connected) : true, + last_connected_at: lastConnectedAt, + last_disconnected_at: lastDisconnectedAt, + last_error: lastError, + publish_attempt_count: publishAttemptCount, + publish_success_count: publishSuccessCount, + publish_failure_count: publishFailureCount, + forced_fail_all: forcedFailAll, + forced_fail_device_count: forcedFailDevices.size + }; +} diff --git a/src/shared/services/mqttSubscriber.ts b/src/shared/services/mqttSubscriber.ts new file mode 100644 index 0000000..3dc0fdd --- /dev/null +++ b/src/shared/services/mqttSubscriber.ts @@ -0,0 +1,140 @@ +import mqtt, { type IClientOptions, type MqttClient } from "mqtt"; +import { env } from "../../config/env"; +import { createMqttMessage } from "../store/mqttMessageStore"; + +type SubscriberStatus = { + enabled: boolean; + connected: boolean; + topics: string[]; + client_id: string | null; + last_connected_at: string | null; + last_disconnected_at: string | null; + last_message_at: string | null; + last_error: { message: string; at: string } | null; + received_count: number; + recorded_count: number; + failed_count: number; +}; + +const status: SubscriberStatus = { + enabled: String(env.MQTT_SUBSCRIBE_ENABLED).toLowerCase() === "true", + connected: false, + topics: env.MQTT_SUBSCRIBE_TOPICS.split(",").map((topic) => topic.trim()).filter(Boolean), + client_id: null, + last_connected_at: null, + last_disconnected_at: null, + last_message_at: null, + last_error: null, + received_count: 0, + recorded_count: 0, + failed_count: 0 +}; + +let clientRef: MqttClient | null = null; +let started = false; + +function parseTopic(topic: string) { + const match = topic.match(/^devices\/([^/]+)\/uplink\/(.+)$/); + if (!match) { + return null; + } + + return { + device_id: match[1], + message_type: match[2].replace(/\//g, "_") + }; +} + +function parsePayload(raw: Buffer): Record { + const text = raw.toString("utf8"); + try { + const parsed = JSON.parse(text) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : { raw_text: text }; + } catch (_error) { + return { raw_text: text }; + } +} + +export function startMqttSubscriber() { + if (started || !status.enabled) { + return; + } + started = true; + + if (!env.MQTT_BROKER_URL) { + status.last_error = { message: "MQTT_BROKER_URL_MISSING", at: new Date().toISOString() }; + return; + } + + const clientId = `${env.MQTT_CLIENT_ID}-subscriber`; + status.client_id = clientId; + const options: IClientOptions = { + clientId, + username: env.MQTT_USERNAME || undefined, + password: env.MQTT_PASSWORD || undefined, + connectTimeout: env.MQTT_CONNECT_TIMEOUT_MS, + reconnectPeriod: 5000, + clean: true + }; + + const client = mqtt.connect(env.MQTT_BROKER_URL, options); + clientRef = client; + + client.on("connect", () => { + status.connected = true; + status.last_connected_at = new Date().toISOString(); + for (const topic of status.topics) { + client.subscribe(topic, { qos: 1 }, (error) => { + if (error) { + status.last_error = { message: error.message, at: new Date().toISOString() }; + } + }); + } + }); + + client.on("message", (topic, raw) => { + status.received_count += 1; + status.last_message_at = new Date().toISOString(); + const parsedTopic = parseTopic(topic); + if (!parsedTopic) { + status.failed_count += 1; + status.last_error = { message: `UNSUPPORTED_TOPIC:${topic}`, at: new Date().toISOString() }; + return; + } + + const payload = parsePayload(raw); + createMqttMessage({ + direction: "uplink", + device_id: parsedTopic.device_id, + topic, + message_type: String(payload.message_type || parsedTopic.message_type), + correlation_id: typeof payload.correlation_id === "string" ? payload.correlation_id : undefined, + payload_json: payload, + publish_status: "recorded" + }) + .then(() => { + status.recorded_count += 1; + }) + .catch((error: unknown) => { + status.failed_count += 1; + const message = error instanceof Error ? error.message : "unknown"; + status.last_error = { message, at: new Date().toISOString() }; + }); + }); + + client.on("error", (error) => { + status.last_error = { message: error.message, at: new Date().toISOString() }; + }); + + client.on("close", () => { + status.connected = false; + status.last_disconnected_at = new Date().toISOString(); + }); +} + +export function getMqttSubscriberStatus() { + return { + ...status, + connected: status.enabled ? Boolean(clientRef?.connected) : false + }; +} diff --git a/src/shared/services/sessionToken.ts b/src/shared/services/sessionToken.ts new file mode 100644 index 0000000..ede1afc --- /dev/null +++ b/src/shared/services/sessionToken.ts @@ -0,0 +1,124 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export type SessionPayload = { + typ: "admin"; + sub: string; + name: string; + email: string; + role_id: string; + role_name: string; + permissions: unknown; + iat: number; + exp: number; +}; + +export type MerchantSessionPayload = { + typ: "merchant"; + sub: string; + merchant_id: string; + name: string; + email: string; + role_name: string; + iat: number; + exp: number; +}; + +function base64UrlEncode(value: string | Buffer) { + return Buffer.from(value).toString("base64url"); +} + +function base64UrlDecode(value: string) { + return Buffer.from(value, "base64url").toString("utf8"); +} + +function sign(unsigned: string, secret: string) { + return createHmac("sha256", secret).update(unsigned).digest("base64url"); +} + +export function createSessionToken(payload: Omit, secret: string, ttlSeconds: number) { + const now = Math.floor(Date.now() / 1000); + const body: SessionPayload = { + ...payload, + iat: now, + exp: now + ttlSeconds + }; + const encoded = base64UrlEncode(JSON.stringify(body)); + const signature = sign(encoded, secret); + return `${encoded}.${signature}`; +} + +export function verifySessionToken(token: string, secret: string): SessionPayload | null { + const [encoded, signature] = token.split("."); + if (!encoded || !signature) { + return null; + } + + const expected = sign(encoded, secret); + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(signature); + if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) { + return null; + } + + let payload: SessionPayload; + try { + payload = JSON.parse(base64UrlDecode(encoded)) as SessionPayload; + } catch (_error) { + return null; + } + + if (payload.typ !== "admin" || !payload.sub || !payload.role_id || !payload.exp) { + return null; + } + if (payload.exp <= Math.floor(Date.now() / 1000)) { + return null; + } + + return payload; +} + +export function createMerchantSessionToken( + payload: Omit, + secret: string, + ttlSeconds: number +) { + const now = Math.floor(Date.now() / 1000); + const body: MerchantSessionPayload = { + ...payload, + iat: now, + exp: now + ttlSeconds + }; + const encoded = base64UrlEncode(JSON.stringify(body)); + const signature = sign(encoded, secret); + return `${encoded}.${signature}`; +} + +export function verifyMerchantSessionToken(token: string, secret: string): MerchantSessionPayload | null { + const [encoded, signature] = token.split("."); + if (!encoded || !signature) { + return null; + } + + const expected = sign(encoded, secret); + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(signature); + if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) { + return null; + } + + let payload: MerchantSessionPayload; + try { + payload = JSON.parse(base64UrlDecode(encoded)) as MerchantSessionPayload; + } catch (_error) { + return null; + } + + if (payload.typ !== "merchant" || !payload.sub || !payload.merchant_id || !payload.exp) { + return null; + } + if (payload.exp <= Math.floor(Date.now() / 1000)) { + return null; + } + + return payload; +} diff --git a/src/shared/store/auditLogStore.ts b/src/shared/store/auditLogStore.ts index 5455e09..f9609e2 100644 --- a/src/shared/store/auditLogStore.ts +++ b/src/shared/store/auditLogStore.ts @@ -3,7 +3,7 @@ import { getPool } from "../db/pool"; export interface AuditLogEntity { id: string; - actor_type: "admin" | "device" | "webhook" | "system"; + actor_type: "admin" | "merchant" | "device" | "webhook" | "system"; actor_id?: string; action: string; entity_type: string; @@ -88,6 +88,7 @@ export async function listAuditLogs(filter?: { entity_type?: string; entity_id?: string; action?: string; + action_contains?: string; from?: string; to?: string; limit?: number; @@ -111,6 +112,11 @@ export async function listAuditLogs(filter?: { params.push(filter.action); } + if (filter?.action_contains) { + clauses.push(`action ILIKE $${i++}`); + params.push(`%${filter.action_contains}%`); + } + if (filter?.from) { clauses.push(`created_at >= $${i++}`); params.push(filter.from); diff --git a/src/shared/store/deviceStore.ts b/src/shared/store/deviceStore.ts index ec56f93..600990e 100644 --- a/src/shared/store/deviceStore.ts +++ b/src/shared/store/deviceStore.ts @@ -1,6 +1,8 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { getPool } from "../db/pool"; +export type DeviceCredentialStatus = "not_issued" | "active" | "rotation_required" | "revoked"; + export interface DeviceEntity { id: string; device_code: string; @@ -10,6 +12,12 @@ export interface DeviceEntity { communication_mode?: "static" | "mqtt" | "api"; capability_profile_json?: Record; auth_method?: string; + mqtt_username?: string; + credential_secret_fingerprint?: string; + credential_status: DeviceCredentialStatus; + credential_issued_at?: string; + credential_rotated_at?: string; + credential_revoked_at?: string; status: "active" | "inactive"; last_seen_at?: string; firmware_version?: string; @@ -35,6 +43,12 @@ function mapDevice(row: any): DeviceEntity { communication_mode: row.communication_mode, capability_profile_json: row.capability_profile_json || {}, auth_method: row.auth_method || undefined, + mqtt_username: row.mqtt_username || undefined, + credential_secret_fingerprint: row.credential_secret_fingerprint || undefined, + credential_status: row.credential_status || "not_issued", + credential_issued_at: row.credential_issued_at || undefined, + credential_rotated_at: row.credential_rotated_at || undefined, + credential_revoked_at: row.credential_revoked_at || undefined, status: row.status, last_seen_at: row.last_seen_at || undefined, firmware_version: row.firmware_version || undefined, @@ -51,6 +65,12 @@ export async function createDevice(payload: { communication_mode?: DeviceEntity["communication_mode"]; capability_profile_json?: Record; auth_method?: string; + mqtt_username?: string; + credential_secret_fingerprint?: string; + credential_status?: DeviceCredentialStatus; + credential_issued_at?: string; + credential_rotated_at?: string; + credential_revoked_at?: string; status?: DeviceEntity["status"]; firmware_version?: string; last_seen_at?: string; @@ -68,12 +88,18 @@ export async function createDevice(payload: { communication_mode, capability_profile_json, auth_method, + mqtt_username, + credential_secret_fingerprint, + credential_status, + credential_issued_at, + credential_rotated_at, + credential_revoked_at, status, last_seen_at, firmware_version, created_at, updated_at - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) RETURNING *`, [ id, @@ -84,6 +110,12 @@ export async function createDevice(payload: { payload.communication_mode || "static", payload.capability_profile_json || {}, payload.auth_method || "token", + payload.mqtt_username || null, + payload.credential_secret_fingerprint || null, + payload.credential_status || "not_issued", + payload.credential_issued_at || null, + payload.credential_rotated_at || null, + payload.credential_revoked_at || null, payload.status || "active", payload.last_seen_at || null, payload.firmware_version, @@ -122,10 +154,16 @@ export async function patchDevice(id: string, patch: Partial): Pro communication_mode = $6, capability_profile_json = $7, auth_method = $8, - status = $9, - firmware_version = $10, - last_seen_at = $11, - updated_at = $12 + mqtt_username = $9, + credential_secret_fingerprint = $10, + credential_status = $11, + credential_issued_at = $12, + credential_rotated_at = $13, + credential_revoked_at = $14, + status = $15, + firmware_version = $16, + last_seen_at = $17, + updated_at = $18 WHERE id = $1 RETURNING *`, [ @@ -137,6 +175,12 @@ export async function patchDevice(id: string, patch: Partial): Pro merged.communication_mode || "static", merged.capability_profile_json || {}, merged.auth_method, + merged.mqtt_username || null, + merged.credential_secret_fingerprint || null, + merged.credential_status || "not_issued", + merged.credential_issued_at || null, + merged.credential_rotated_at || null, + merged.credential_revoked_at || null, merged.status, merged.firmware_version, merged.last_seen_at || null, @@ -147,6 +191,58 @@ export async function patchDevice(id: string, patch: Partial): Pro return mapDevice(rows[0]); } -export function toDevicePayload(device: DeviceEntity) { - return { ...device }; +function fingerprintSecret(secret: string) { + return createHash("sha256").update(secret).digest("hex"); +} + +function safeEqualHex(a: string, b: string) { + const left = Buffer.from(a, "hex"); + const right = Buffer.from(b, "hex"); + return left.length === right.length && timingSafeEqual(left, right); +} + +function normalizeMqttUsername(device: DeviceEntity) { + return device.id; +} + +export async function rotateDeviceMqttCredential(id: string): Promise<{ + device: DeviceEntity; + username: string; + password: string; +}> { + const existing = await getDeviceById(id); + if (!existing) { + throw new Error("DEVICE_NOT_FOUND"); + } + + const password = randomBytes(24).toString("base64url"); + const username = normalizeMqttUsername(existing); + const now = nowIso(); + const updated = await patchDevice(id, { + communication_mode: "mqtt", + auth_method: "mqtt_username_password", + mqtt_username: username, + credential_secret_fingerprint: fingerprintSecret(password), + credential_status: "active", + credential_issued_at: existing.credential_issued_at || now, + credential_rotated_at: now, + credential_revoked_at: undefined + }); + + return { device: updated, username, password }; +} + +export async function verifyDeviceSecret(id: string, secret: string): Promise { + const device = await getDeviceById(id); + if (!device || device.credential_status !== "active" || !device.credential_secret_fingerprint) { + return null; + } + + const candidate = fingerprintSecret(secret); + return safeEqualHex(candidate, device.credential_secret_fingerprint) ? device : null; +} + +export function toDevicePayload(device: DeviceEntity) { + const { credential_secret_fingerprint, ...payload } = device; + return { ...payload }; } diff --git a/src/shared/store/exportJobStore.ts b/src/shared/store/exportJobStore.ts new file mode 100644 index 0000000..d89babe --- /dev/null +++ b/src/shared/store/exportJobStore.ts @@ -0,0 +1,235 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export type ExportJobEntity = { + id: string; + job_type: string; + status: "pending" | "running" | "completed" | "failed"; + requested_by?: string; + request_json: Record; + result_content_type?: string; + result_filename?: string; + result_body?: string; + result_storage_path?: string; + result_size_bytes?: number; + expires_at?: string; + error_message?: string; + created_at: string; + started_at?: string; + completed_at?: string; +}; + +function nowIso() { + return new Date().toISOString(); +} + +function mapExportJob(row: any): ExportJobEntity { + return { + id: row.id, + job_type: row.job_type, + status: row.status, + requested_by: row.requested_by || undefined, + request_json: row.request_json || {}, + result_content_type: row.result_content_type || undefined, + result_filename: row.result_filename || undefined, + result_body: row.result_body || undefined, + result_storage_path: row.result_storage_path || undefined, + result_size_bytes: row.result_size_bytes === null || row.result_size_bytes === undefined ? undefined : Number(row.result_size_bytes), + expires_at: row.expires_at || undefined, + error_message: row.error_message || undefined, + created_at: row.created_at, + started_at: row.started_at || undefined, + completed_at: row.completed_at || undefined + }; +} + +export async function createExportJob(payload: { + job_type: string; + requested_by?: string; + request_json?: Record; +}): Promise { + const { rows } = await getPool().query( + `INSERT INTO export_jobs (id, job_type, status, requested_by, request_json, created_at) + VALUES ($1,$2,'pending',$3,$4,$5) + RETURNING *`, + [randomUUID(), payload.job_type, payload.requested_by || null, payload.request_json || {}, nowIso()] + ); + return mapExportJob(rows[0]); +} + +export async function getExportJobById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM export_jobs WHERE id = $1", [id]); + return rows[0] ? mapExportJob(rows[0]) : null; +} + +export async function listExportJobs(filter?: { + job_type?: string; + status?: ExportJobEntity["status"]; + limit?: number; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + if (filter?.job_type) { + clauses.push(`job_type = $${i++}`); + params.push(filter.job_type); + } + if (filter?.status) { + clauses.push(`status = $${i++}`); + params.push(filter.status); + } + const limit = Math.min(Math.max(filter?.limit || 20, 1), 100); + params.push(limit); + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query( + `SELECT * FROM export_jobs ${where} ORDER BY created_at DESC LIMIT $${i}`, + params + ); + return rows.map(mapExportJob); +} + +export async function markExportJobRunning(id: string): Promise { + const { rows } = await getPool().query( + `UPDATE export_jobs + SET status = 'running', + started_at = COALESCE(started_at, $2) + WHERE id = $1 + RETURNING *`, + [id, nowIso()] + ); + return mapExportJob(rows[0]); +} + +export async function claimNextPendingExportJob(): Promise { + const { rows } = await getPool().query( + `UPDATE export_jobs + SET status = 'running', + started_at = COALESCE(started_at, $1), + error_message = NULL + WHERE id = ( + SELECT id + FROM export_jobs + WHERE status = 'pending' + ORDER BY created_at ASC + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + RETURNING *`, + [nowIso()] + ); + return rows[0] ? mapExportJob(rows[0]) : null; +} + +export async function resetStaleRunningExportJobs(cutoffIso: string): Promise { + const { rowCount } = await getPool().query( + `UPDATE export_jobs + SET status = 'pending', + error_message = 'reset from stale running state' + WHERE status = 'running' + AND started_at IS NOT NULL + AND started_at < $1`, + [cutoffIso] + ); + return rowCount || 0; +} + +export async function countExportJobsByStatus(): Promise> { + const { rows } = await getPool().query("SELECT status, COUNT(*) AS count FROM export_jobs GROUP BY status"); + const counts: Record = { + pending: 0, + running: 0, + completed: 0, + failed: 0 + }; + for (const row of rows) { + if (row.status in counts) { + counts[row.status as ExportJobEntity["status"]] = Number(row.count || 0); + } + } + return counts; +} + +export async function completeExportJob( + id: string, + payload: { + result_content_type: string; + result_filename: string; + result_body?: string | null; + result_storage_path?: string | null; + result_size_bytes?: number; + expires_at?: string; + } +): Promise { + const { rows } = await getPool().query( + `UPDATE export_jobs + SET status = 'completed', + result_content_type = $2, + result_filename = $3, + result_body = $4, + result_storage_path = $5, + result_size_bytes = $6, + expires_at = $7, + completed_at = $8 + WHERE id = $1 + RETURNING *`, + [ + id, + payload.result_content_type, + payload.result_filename, + payload.result_body || null, + payload.result_storage_path || null, + payload.result_size_bytes || null, + payload.expires_at || null, + nowIso() + ] + ); + return mapExportJob(rows[0]); +} + +export async function markExpiredExportJobs(cutoffIso: string): Promise { + const { rows } = await getPool().query( + `UPDATE export_jobs + SET result_body = NULL, + result_storage_path = NULL, + error_message = COALESCE(error_message, 'export result expired') + WHERE status = 'completed' + AND expires_at IS NOT NULL + AND expires_at < $1 + AND (result_body IS NOT NULL OR result_storage_path IS NOT NULL) + RETURNING *`, + [cutoffIso] + ); + return rows.map(mapExportJob); +} + +export async function failExportJob(id: string, error: string): Promise { + const { rows } = await getPool().query( + `UPDATE export_jobs + SET status = 'failed', + error_message = $2, + completed_at = $3 + WHERE id = $1 + RETURNING *`, + [id, error, nowIso()] + ); + return mapExportJob(rows[0]); +} + +export function toExportJobPayload(job: ExportJobEntity) { + return { + id: job.id, + job_type: job.job_type, + status: job.status, + requested_by: job.requested_by, + request_json: job.request_json, + result_content_type: job.result_content_type, + result_filename: job.result_filename, + result_size_bytes: job.result_size_bytes, + expires_at: job.expires_at, + error_message: job.error_message, + created_at: job.created_at, + started_at: job.started_at, + completed_at: job.completed_at, + download_url: job.status === "completed" && (job.result_body || job.result_storage_path) ? `/admin/exports/${job.id}/download` : null + }; +} diff --git a/src/shared/store/ledgerStore.ts b/src/shared/store/ledgerStore.ts index 895ecd7..fd459fc 100644 --- a/src/shared/store/ledgerStore.ts +++ b/src/shared/store/ledgerStore.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { env } from "../../config/env"; import { getPool } from "../db/pool"; import type { TransactionEntity } from "./transactionStore"; @@ -34,7 +35,29 @@ function mapLedgerEntry(row: any): LedgerEntryEntity { }; } -export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promise { +function roundMoney(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function calculatePlatformFee(amount: number) { + const feeBps = Math.max(0, env.FINANCE_PLATFORM_FEE_BPS); + const feeAmount = roundMoney((amount * feeBps) / 10000); + return { + fee_bps: feeBps, + platform_fee_amount: feeAmount, + merchant_payable_amount: roundMoney(amount - feeAmount) + }; +} + +async function upsertLedgerEntry(payload: { + transaction_id: string; + merchant_id: string; + entry_type: LedgerEntryEntity["entry_type"]; + amount: number; + currency: string; + direction: LedgerEntryEntity["direction"]; + metadata_json: Record; +}): Promise { const { rows } = await getPool().query( `INSERT INTO ledger_entries ( id, @@ -51,22 +74,19 @@ export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promis ON CONFLICT (transaction_id, entry_type) DO UPDATE SET amount = EXCLUDED.amount, currency = EXCLUDED.currency, + direction = EXCLUDED.direction, metadata_json = ledger_entries.metadata_json || EXCLUDED.metadata_json RETURNING *`, [ randomUUID(), - tx.id, - tx.merchant_id, - "gross_income", - tx.amount, - tx.currency, - "credit", + payload.transaction_id, + payload.merchant_id, + payload.entry_type, + payload.amount, + payload.currency, + payload.direction, "posted", - { - placeholder: true, - source: "fase1_paid_transaction", - partner_reference: tx.partner_reference - }, + payload.metadata_json, nowIso() ] ); @@ -74,6 +94,61 @@ export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promis return mapLedgerEntry(rows[0]); } +export async function createPaidLedgerEntries(tx: TransactionEntity): Promise { + const fee = calculatePlatformFee(tx.amount); + const baseMetadata = { + source: "fase1_finance_light", + partner_reference: tx.partner_reference, + fee_bps: fee.fee_bps + }; + + const gross = await upsertLedgerEntry({ + transaction_id: tx.id, + merchant_id: tx.merchant_id, + entry_type: "gross_income", + amount: tx.amount, + currency: tx.currency, + direction: "credit", + metadata_json: { + ...baseMetadata, + note: "gross paid amount" + } + }); + + const platformFee = await upsertLedgerEntry({ + transaction_id: tx.id, + merchant_id: tx.merchant_id, + entry_type: "platform_fee", + amount: fee.platform_fee_amount, + currency: tx.currency, + direction: "debit", + metadata_json: { + ...baseMetadata, + note: "platform fee deducted from merchant payable" + } + }); + + const merchantPayable = await upsertLedgerEntry({ + transaction_id: tx.id, + merchant_id: tx.merchant_id, + entry_type: "merchant_payable", + amount: fee.merchant_payable_amount, + currency: tx.currency, + direction: "credit", + metadata_json: { + ...baseMetadata, + note: "net merchant payable after platform fee" + } + }); + + return [gross, platformFee, merchantPayable]; +} + +export async function createPaidLedgerPlaceholder(tx: TransactionEntity): Promise { + const [gross] = await createPaidLedgerEntries(tx); + return gross; +} + export async function listLedgerEntries(filter?: { transaction_id?: string; merchant_id?: string; diff --git a/src/shared/store/merchantStore.ts b/src/shared/store/merchantStore.ts index 7d8ade5..cd88608 100644 --- a/src/shared/store/merchantStore.ts +++ b/src/shared/store/merchantStore.ts @@ -104,6 +104,14 @@ export async function getMerchantById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM merchants WHERE merchant_code = $1", [code]); + if (rows.length === 0) { + return null; + } + return mapRowToMerchant(rows[0]); +} + export async function listMerchants(): Promise { const { rows } = await getPool().query("SELECT * FROM merchants ORDER BY created_at DESC"); return rows.map(mapRowToMerchant); diff --git a/src/shared/store/merchantUserStore.ts b/src/shared/store/merchantUserStore.ts new file mode 100644 index 0000000..e40557d --- /dev/null +++ b/src/shared/store/merchantUserStore.ts @@ -0,0 +1,72 @@ +import { scryptSync, timingSafeEqual } from "node:crypto"; +import { getPool } from "../db/pool"; + +export type MerchantUserEntity = { + id: string; + merchant_id: string; + name: string; + email: string; + password_hash: string; + role_name: "owner" | "finance" | "ops" | "viewer"; + status: "active" | "inactive"; + created_at: string; + updated_at: string; +}; + +function mapMerchantUser(row: any): MerchantUserEntity { + return { + id: row.id, + merchant_id: row.merchant_id, + name: row.name, + email: row.email, + password_hash: row.password_hash, + role_name: row.role_name, + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +export async function getMerchantUserByEmail(email: string): Promise { + const { rows } = await getPool().query( + "SELECT * FROM merchant_users WHERE LOWER(email) = LOWER($1) LIMIT 1", + [email] + ); + return rows[0] ? mapMerchantUser(rows[0]) : null; +} + +function verifyScryptPassword(stored: string, password: string) { + const parts = stored.split("$"); + if (parts.length !== 4 || parts[0] !== "scrypt") { + return false; + } + const [, salt, keyLengthRaw, expectedHex] = parts; + const keyLength = Number(keyLengthRaw); + if (!salt || !Number.isFinite(keyLength) || keyLength <= 0 || !expectedHex) { + return false; + } + const expected = Buffer.from(expectedHex, "hex"); + const actual = scryptSync(password, salt, keyLength); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +export function verifyMerchantPassword(stored: string, password: string) { + if (stored.startsWith("scrypt$")) { + return verifyScryptPassword(stored, password); + } + + return stored === password; +} + +export function toMerchantUserPayload(user: MerchantUserEntity) { + return { + id: user.id, + merchant_id: user.merchant_id, + name: user.name, + email: user.email, + role_name: user.role_name, + status: user.status, + created_at: user.created_at, + updated_at: user.updated_at + }; +} diff --git a/src/shared/store/settlementStore.ts b/src/shared/store/settlementStore.ts new file mode 100644 index 0000000..ea16603 --- /dev/null +++ b/src/shared/store/settlementStore.ts @@ -0,0 +1,1417 @@ +import { randomUUID } from "node:crypto"; +import { getPool, withClient } from "../db/pool"; +import { env } from "../../config/env"; + +export type SettlementBatchStatus = "created" | "paid" | "failed" | "cancelled"; + +export interface SettlementBatchEntity { + id: string; + batch_code: string; + merchant_id: string; + currency: string; + gross_amount: number; + platform_fee_amount: number; + net_payable_amount: number; + entry_count: number; + status: SettlementBatchStatus; + cutoff_at: string; + created_at: string; + paid_at?: string; + failure_reason?: string; + metadata_json: Record; +} + +export interface SettlementBatchEntryEntity { + id: string; + batch_id: string; + ledger_entry_id: string; + transaction_id: string; + merchant_id: string; + amount: number; + currency: string; + created_at: string; +} + +export interface SettlementBatchAdjustmentEntity { + id: string; + batch_id: string; + merchant_id: string; + adjustment_type: "credit" | "debit"; + amount: number; + signed_amount: number; + currency: string; + reason: string; + note?: string; + approval_status: "pending" | "approved" | "rejected"; + approved_by?: string; + approved_at?: string; + rejected_by?: string; + rejected_at?: string; + actor_type: "admin" | "merchant" | "system"; + actor_id?: string; + metadata_json: Record; + created_at: string; +} + +export interface SettlementBatchReportRow { + batch_code: string; + batch_status: SettlementBatchStatus; + merchant_id: string; + merchant_name?: string; + settlement_account_reference?: string; + settlement_account_type?: string; + transaction_id: string; + transaction_code: string; + partner_reference: string; + paid_at?: string; + gross_amount: number; + platform_fee_amount: number; + net_payable_amount: number; + currency: string; +} + +export interface SettlementFinanceSummary { + pending_amount: number; + paid_amount: number; + adjustment_amount: number; + adjusted_paid_amount: number; + platform_fee_amount: number; + created_batches: number; + paid_batches: number; + total_batches: number; +} + +export interface SettlementReconciliationIssue { + type: + | "entry_count_mismatch" + | "gross_amount_mismatch" + | "platform_fee_mismatch" + | "net_payable_mismatch" + | "missing_transaction" + | "missing_gross_ledger" + | "missing_fee_ledger" + | "missing_payable_ledger" + | "transaction_status_mismatch"; + severity: "warning" | "critical"; + message: string; + expected?: number | string; + actual?: number | string; +} + +export interface SettlementReconciliationRow { + batch: SettlementBatchEntity; + computed: { + entry_count: number; + gross_amount: number; + platform_fee_amount: number; + net_payable_amount: number; + paid_transaction_count: number; + missing_transaction_count: number; + missing_gross_count: number; + missing_fee_count: number; + missing_payable_count: number; + archived_reprocessed: boolean; + }; + issue_count: number; + status: "matched" | "mismatch"; + issues: SettlementReconciliationIssue[]; +} + +export interface SettlementReconciliationReport { + generated_at: string; + total_batches: number; + matched_batches: number; + mismatch_batches: number; + issue_count: number; + rows: SettlementReconciliationRow[]; +} + +export interface SettlementAdjustmentReportRow extends SettlementBatchAdjustmentEntity { + batch_code: string; + batch_status: SettlementBatchStatus; + batch_created_at: string; + batch_paid_at?: string; + net_payable_amount: number; +} + +export interface SettlementAdjustmentReport { + generated_at: string; + total_count: number; + credit_amount: number; + debit_amount: number; + signed_amount: number; + rows: SettlementAdjustmentReportRow[]; +} + +export interface SettlementBatchEventEntity { + id: string; + batch_id: string; + merchant_id: string; + event_type: + | "created" + | "csv_exported" + | "marked_paid" + | "failed" + | "cancelled" + | "reprocessed" + | "reference_updated" + | "adjustment_recorded" + | "adjustment_approved" + | "adjustment_rejected"; + actor_type: "admin" | "merchant" | "system"; + actor_id?: string; + payload_json: Record; + created_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function makeBatchCode(id: string) { + const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + return `SET-${stamp}-${id.slice(0, 8)}`; +} + +function roundMoney(value: number) { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function moneyDiffers(expected: number, actual: number) { + return Math.abs(roundMoney(expected) - roundMoney(actual)) > 0.01; +} + +function mapBatch(row: any): SettlementBatchEntity { + return { + id: row.id, + batch_code: row.batch_code, + merchant_id: row.merchant_id, + currency: row.currency, + gross_amount: Number(row.gross_amount), + platform_fee_amount: Number(row.platform_fee_amount), + net_payable_amount: Number(row.net_payable_amount), + entry_count: Number(row.entry_count), + status: row.status, + cutoff_at: row.cutoff_at, + created_at: row.created_at, + paid_at: row.paid_at || undefined, + failure_reason: row.failure_reason || undefined, + metadata_json: row.metadata_json || {} + }; +} + +function mapBatchEntry(row: any): SettlementBatchEntryEntity { + return { + id: row.id, + batch_id: row.batch_id, + ledger_entry_id: row.ledger_entry_id, + transaction_id: row.transaction_id, + merchant_id: row.merchant_id, + amount: Number(row.amount), + currency: row.currency, + created_at: row.created_at + }; +} + +function mapBatchAdjustment(row: any): SettlementBatchAdjustmentEntity { + return { + id: row.id, + batch_id: row.batch_id, + merchant_id: row.merchant_id, + adjustment_type: row.adjustment_type, + amount: Number(row.amount), + signed_amount: Number(row.signed_amount), + currency: row.currency, + reason: row.reason, + note: row.note || undefined, + approval_status: row.approval_status || "approved", + approved_by: row.approved_by || undefined, + approved_at: row.approved_at || undefined, + rejected_by: row.rejected_by || undefined, + rejected_at: row.rejected_at || undefined, + actor_type: row.actor_type, + actor_id: row.actor_id || undefined, + metadata_json: row.metadata_json || {}, + created_at: row.created_at + }; +} + +function mapAdjustmentReportRow(row: any): SettlementAdjustmentReportRow { + return { + ...mapBatchAdjustment(row), + batch_code: row.batch_code, + batch_status: row.batch_status, + batch_created_at: row.batch_created_at, + batch_paid_at: row.batch_paid_at || undefined, + net_payable_amount: Number(row.net_payable_amount || 0) + }; +} + +function mapBatchEvent(row: any): SettlementBatchEventEntity { + return { + id: row.id, + batch_id: row.batch_id, + merchant_id: row.merchant_id, + event_type: row.event_type, + actor_type: row.actor_type, + actor_id: row.actor_id || undefined, + payload_json: row.payload_json || {}, + created_at: row.created_at + }; +} + +export async function createSettlementBatches(input?: { + merchant_id?: string; + cutoff_at?: string; + limit_per_merchant?: number; +}): Promise { + const cutoffAt = input?.cutoff_at || nowIso(); + const limitPerMerchant = Math.min(Math.max(input?.limit_per_merchant || 500, 1), 1000); + + return withClient(async (client) => { + await client.query("BEGIN"); + try { + const merchantParams: unknown[] = [cutoffAt]; + const merchantFilter = input?.merchant_id ? "AND le.merchant_id = $2" : ""; + if (input?.merchant_id) { + merchantParams.push(input.merchant_id); + } + + const { rows: merchants } = await client.query( + `SELECT le.merchant_id + FROM ledger_entries le + LEFT JOIN settlement_batch_entries sbe ON sbe.ledger_entry_id = le.id + WHERE le.entry_type = 'merchant_payable' + AND le.status = 'posted' + AND le.created_at <= $1 + AND sbe.id IS NULL + ${merchantFilter} + GROUP BY le.merchant_id + ORDER BY le.merchant_id ASC`, + merchantParams + ); + + const batches: SettlementBatchEntity[] = []; + for (const row of merchants) { + const merchantId = row.merchant_id as string; + const { rows: entries } = await client.query( + `SELECT le.* + FROM ledger_entries le + LEFT JOIN settlement_batch_entries sbe ON sbe.ledger_entry_id = le.id + WHERE le.entry_type = 'merchant_payable' + AND le.status = 'posted' + AND le.merchant_id = $1 + AND le.created_at <= $2 + AND sbe.id IS NULL + ORDER BY le.created_at ASC + LIMIT ${limitPerMerchant}`, + [merchantId, cutoffAt] + ); + + if (!entries.length) { + continue; + } + + const transactionIds = entries.map((entry) => entry.transaction_id); + const { rows: totals } = await client.query( + `SELECT + COALESCE(SUM(CASE WHEN entry_type = 'gross_income' THEN amount ELSE 0 END), 0) AS gross_amount, + COALESCE(SUM(CASE WHEN entry_type = 'platform_fee' THEN amount ELSE 0 END), 0) AS platform_fee_amount + FROM ledger_entries + WHERE merchant_id = $1 + AND transaction_id = ANY($2::text[]) + AND status = 'posted'`, + [merchantId, transactionIds] + ); + + const id = randomUUID(); + const now = nowIso(); + const netPayable = entries.reduce((sum, entry) => sum + Number(entry.amount), 0); + const currency = entries[0].currency || "IDR"; + const { rows: batchRows } = await client.query( + `INSERT INTO settlement_batches ( + id, + batch_code, + merchant_id, + currency, + gross_amount, + platform_fee_amount, + net_payable_amount, + entry_count, + status, + cutoff_at, + created_at, + metadata_json + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, + [ + id, + makeBatchCode(id), + merchantId, + currency, + Number(totals[0]?.gross_amount || 0), + Number(totals[0]?.platform_fee_amount || 0), + netPayable, + entries.length, + "created", + cutoffAt, + now, + { + source: "settlement_batch_light", + limit_per_merchant: limitPerMerchant + } + ] + ); + + for (const entry of entries) { + await client.query( + `INSERT INTO settlement_batch_entries ( + id, + batch_id, + ledger_entry_id, + transaction_id, + merchant_id, + amount, + currency, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (ledger_entry_id) DO NOTHING`, + [ + randomUUID(), + id, + entry.id, + entry.transaction_id, + entry.merchant_id, + Number(entry.amount), + entry.currency, + now + ] + ); + } + + batches.push(mapBatch(batchRows[0])); + } + + await client.query("COMMIT"); + return batches; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); +} + +export async function listSettlementBatches(filter?: { + merchant_id?: string; + status?: SettlementBatchStatus; + limit?: number; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + + if (filter?.merchant_id) { + clauses.push(`merchant_id = $${i++}`); + params.push(filter.merchant_id); + } + + if (filter?.status) { + clauses.push(`status = $${i++}`); + params.push(filter.status); + } + + 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 settlement_batches ${where} ORDER BY created_at DESC LIMIT ${limit}`, + params + ); + + return rows.map(mapBatch); +} + +export async function getSettlementFinanceSummary(filter?: { merchant_id?: string }): Promise { + const params: unknown[] = []; + const where = filter?.merchant_id ? "WHERE sb.merchant_id = $1" : ""; + if (filter?.merchant_id) { + params.push(filter.merchant_id); + } + + const { rows } = await getPool().query( + `WITH adjustment_totals AS ( + SELECT batch_id, COALESCE(SUM(signed_amount), 0) AS adjustment_amount + FROM settlement_batch_adjustments + WHERE approval_status = 'approved' + GROUP BY batch_id + ) + SELECT + COALESCE(SUM(CASE WHEN sb.status = 'created' THEN sb.net_payable_amount ELSE 0 END), 0) AS pending_amount, + COALESCE(SUM(CASE WHEN sb.status = 'paid' THEN sb.net_payable_amount ELSE 0 END), 0) AS paid_amount, + COALESCE(SUM(COALESCE(at.adjustment_amount, 0)), 0) AS adjustment_amount, + COALESCE(SUM(sb.platform_fee_amount), 0) AS platform_fee_amount, + COUNT(*) FILTER (WHERE sb.status = 'created') AS created_batches, + COUNT(*) FILTER (WHERE sb.status = 'paid') AS paid_batches, + COUNT(*) AS total_batches + FROM settlement_batches sb + LEFT JOIN adjustment_totals at ON at.batch_id = sb.id + ${where}`, + params + ); + const row = rows[0] || {}; + return { + pending_amount: Number(row.pending_amount || 0), + paid_amount: Number(row.paid_amount || 0), + adjustment_amount: Number(row.adjustment_amount || 0), + adjusted_paid_amount: Number(row.paid_amount || 0) + Number(row.adjustment_amount || 0), + platform_fee_amount: Number(row.platform_fee_amount || 0), + created_batches: Number(row.created_batches || 0), + paid_batches: Number(row.paid_batches || 0), + total_batches: Number(row.total_batches || 0) + }; +} + +export async function getSettlementReconciliationReport(filter?: { + merchant_id?: string; + status?: SettlementBatchStatus; + limit?: number; + mismatches_only?: boolean; +}): Promise { + const batches = await listSettlementBatches({ + merchant_id: filter?.merchant_id, + status: filter?.status, + limit: filter?.limit || 100 + }); + + const rows: SettlementReconciliationRow[] = []; + + for (const batch of batches) { + const { rows: entryRows } = await getPool().query( + `SELECT + sbe.id, + sbe.amount AS settlement_amount, + sbe.transaction_id, + tx.id AS tx_id, + tx.status AS tx_status, + gross.id AS gross_ledger_id, + COALESCE(gross.amount, 0) AS gross_amount, + fee.id AS fee_ledger_id, + COALESCE(fee.amount, 0) AS platform_fee_amount, + payable.id AS payable_ledger_id, + COALESCE(payable.amount, sbe.amount, 0) AS merchant_payable_amount + FROM settlement_batch_entries sbe + LEFT JOIN transactions tx ON tx.id = sbe.transaction_id + LEFT JOIN ledger_entries gross + ON gross.transaction_id = sbe.transaction_id + AND gross.entry_type = 'gross_income' + AND gross.status = 'posted' + LEFT JOIN ledger_entries fee + ON fee.transaction_id = sbe.transaction_id + AND fee.entry_type = 'platform_fee' + AND fee.status = 'posted' + LEFT JOIN ledger_entries payable + ON payable.id = sbe.ledger_entry_id + AND payable.entry_type = 'merchant_payable' + AND payable.status = 'posted' + WHERE sbe.batch_id = $1 + ORDER BY sbe.created_at ASC`, + [batch.id] + ); + + const computed = entryRows.reduce( + (acc, row) => { + acc.entry_count += 1; + acc.gross_amount += Number(row.gross_amount || 0); + acc.platform_fee_amount += Number(row.platform_fee_amount || 0); + acc.net_payable_amount += Number(row.merchant_payable_amount || row.settlement_amount || 0); + if (!row.tx_id) { + acc.missing_transaction_count += 1; + } + if (row.tx_status === "paid") { + acc.paid_transaction_count += 1; + } + if (!row.gross_ledger_id) { + acc.missing_gross_count += 1; + } + if (!row.fee_ledger_id) { + acc.missing_fee_count += 1; + } + if (!row.payable_ledger_id) { + acc.missing_payable_count += 1; + } + return acc; + }, + { + entry_count: 0, + gross_amount: 0, + platform_fee_amount: 0, + net_payable_amount: 0, + paid_transaction_count: 0, + missing_transaction_count: 0, + missing_gross_count: 0, + missing_fee_count: 0, + missing_payable_count: 0, + archived_reprocessed: Boolean(batch.metadata_json?.reprocessed_to_batch_id) + } + ); + + computed.gross_amount = roundMoney(computed.gross_amount); + computed.platform_fee_amount = roundMoney(computed.platform_fee_amount); + computed.net_payable_amount = roundMoney(computed.net_payable_amount); + + const issues: SettlementReconciliationIssue[] = []; + const shouldCompareAmounts = !computed.archived_reprocessed; + + if (shouldCompareAmounts && computed.entry_count !== batch.entry_count) { + issues.push({ + type: "entry_count_mismatch", + severity: "critical", + message: "Settlement entry count does not match batch aggregate", + expected: batch.entry_count, + actual: computed.entry_count + }); + } + if (shouldCompareAmounts && moneyDiffers(batch.gross_amount, computed.gross_amount)) { + issues.push({ + type: "gross_amount_mismatch", + severity: "critical", + message: "Gross amount does not match posted gross ledger entries", + expected: batch.gross_amount, + actual: computed.gross_amount + }); + } + if (shouldCompareAmounts && moneyDiffers(batch.platform_fee_amount, computed.platform_fee_amount)) { + issues.push({ + type: "platform_fee_mismatch", + severity: "critical", + message: "Platform fee amount does not match posted fee ledger entries", + expected: batch.platform_fee_amount, + actual: computed.platform_fee_amount + }); + } + if (shouldCompareAmounts && moneyDiffers(batch.net_payable_amount, computed.net_payable_amount)) { + issues.push({ + type: "net_payable_mismatch", + severity: "critical", + message: "Net payable amount does not match merchant payable ledger entries", + expected: batch.net_payable_amount, + actual: computed.net_payable_amount + }); + } + if (computed.missing_transaction_count > 0) { + issues.push({ + type: "missing_transaction", + severity: "critical", + message: "One or more settlement entries no longer have a transaction record", + expected: 0, + actual: computed.missing_transaction_count + }); + } + if (computed.missing_gross_count > 0) { + issues.push({ + type: "missing_gross_ledger", + severity: "critical", + message: "One or more settlement entries are missing gross ledger entries", + expected: 0, + actual: computed.missing_gross_count + }); + } + if (computed.missing_fee_count > 0) { + issues.push({ + type: "missing_fee_ledger", + severity: "critical", + message: "One or more settlement entries are missing platform fee ledger entries", + expected: 0, + actual: computed.missing_fee_count + }); + } + if (computed.missing_payable_count > 0) { + issues.push({ + type: "missing_payable_ledger", + severity: "critical", + message: "One or more settlement entries are missing merchant payable ledger entries", + expected: 0, + actual: computed.missing_payable_count + }); + } + + const nonPaidCount = entryRows.filter((row) => row.tx_id && row.tx_status !== "paid").length; + if (nonPaidCount > 0) { + issues.push({ + type: "transaction_status_mismatch", + severity: "critical", + message: "One or more settlement entries are linked to non-paid transactions", + expected: "paid", + actual: `${nonPaidCount} non-paid transaction(s)` + }); + } + + rows.push({ + batch, + computed, + issue_count: issues.length, + status: issues.length > 0 ? "mismatch" : "matched", + issues + }); + } + + const visibleRows = filter?.mismatches_only ? rows.filter((row) => row.status === "mismatch") : rows; + const mismatchBatches = rows.filter((row) => row.status === "mismatch").length; + const issueCount = rows.reduce((sum, row) => sum + row.issue_count, 0); + + return { + generated_at: nowIso(), + total_batches: rows.length, + matched_batches: rows.length - mismatchBatches, + mismatch_batches: mismatchBatches, + issue_count: issueCount, + rows: visibleRows + }; +} + +export async function getSettlementBatchById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM settlement_batches WHERE id = $1", [id]); + return rows[0] ? mapBatch(rows[0]) : null; +} + +export async function getSettlementBatchByIdForMerchant( + id: string, + merchantId: string +): Promise { + const { rows } = await getPool().query("SELECT * FROM settlement_batches WHERE id = $1 AND merchant_id = $2", [ + id, + merchantId + ]); + return rows[0] ? mapBatch(rows[0]) : null; +} + +export async function listSettlementBatchEntries(batchId: string): Promise { + const { rows } = await getPool().query( + "SELECT * FROM settlement_batch_entries WHERE batch_id = $1 ORDER BY created_at ASC", + [batchId] + ); + return rows.map(mapBatchEntry); +} + +export async function listSettlementBatchAdjustments(batchId: string): Promise { + const { rows } = await getPool().query( + "SELECT * FROM settlement_batch_adjustments WHERE batch_id = $1 ORDER BY created_at ASC", + [batchId] + ); + return rows.map(mapBatchAdjustment); +} + +export async function listSettlementAdjustments(filter?: { + merchant_id?: string; + adjustment_type?: "credit" | "debit"; + approval_status?: "pending" | "approved" | "rejected"; + from?: string; + to?: string; + limit?: number; + max_limit?: number; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + + if (filter?.merchant_id) { + clauses.push(`sba.merchant_id = $${i++}`); + params.push(filter.merchant_id); + } + if (filter?.adjustment_type) { + clauses.push(`sba.adjustment_type = $${i++}`); + params.push(filter.adjustment_type); + } + if (filter?.approval_status) { + clauses.push(`sba.approval_status = $${i++}`); + params.push(filter.approval_status); + } + if (filter?.from) { + clauses.push(`sba.created_at >= $${i++}`); + params.push(filter.from); + } + if (filter?.to) { + clauses.push(`sba.created_at <= $${i++}`); + params.push(filter.to); + } + + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const maxLimit = Math.min(Math.max(filter?.max_limit || 500, 1), 10000); + const limit = Math.min(Math.max(filter?.limit || 100, 1), maxLimit); + const summaryParams = [...params]; + const rowParams = [...params, limit]; + + const [summaryResult, rowResult] = await Promise.all([ + getPool().query( + `SELECT + COUNT(*) AS total_count, + COALESCE(SUM(CASE WHEN sba.adjustment_type = 'credit' THEN sba.amount ELSE 0 END), 0) AS credit_amount, + COALESCE(SUM(CASE WHEN sba.adjustment_type = 'debit' THEN sba.amount ELSE 0 END), 0) AS debit_amount, + COALESCE(SUM(sba.signed_amount), 0) AS signed_amount + FROM settlement_batch_adjustments sba + JOIN settlement_batches sb ON sb.id = sba.batch_id + ${where}`, + summaryParams + ), + getPool().query( + `SELECT + sba.*, + sb.batch_code, + sb.status AS batch_status, + sb.created_at AS batch_created_at, + sb.paid_at AS batch_paid_at, + sb.net_payable_amount + FROM settlement_batch_adjustments sba + JOIN settlement_batches sb ON sb.id = sba.batch_id + ${where} + ORDER BY sba.created_at DESC + LIMIT $${i}`, + rowParams + ) + ]); + + const summary = summaryResult.rows[0] || {}; + return { + generated_at: nowIso(), + total_count: Number(summary.total_count || 0), + credit_amount: Number(summary.credit_amount || 0), + debit_amount: Number(summary.debit_amount || 0), + signed_amount: Number(summary.signed_amount || 0), + rows: rowResult.rows.map(mapAdjustmentReportRow) + }; +} + +export async function createSettlementBatchEvent(payload: { + batch_id: string; + merchant_id: string; + event_type: SettlementBatchEventEntity["event_type"]; + actor_type: SettlementBatchEventEntity["actor_type"]; + actor_id?: string; + payload_json?: Record; +}): Promise { + const { rows } = await getPool().query( + `INSERT INTO settlement_batch_events ( + id, + batch_id, + merchant_id, + event_type, + actor_type, + actor_id, + payload_json, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING *`, + [ + randomUUID(), + payload.batch_id, + payload.merchant_id, + payload.event_type, + payload.actor_type, + payload.actor_id || null, + payload.payload_json || {}, + nowIso() + ] + ); + + return mapBatchEvent(rows[0]); +} + +export async function listSettlementBatchEvents(batchId: string): Promise { + const { rows } = await getPool().query( + "SELECT * FROM settlement_batch_events WHERE batch_id = $1 ORDER BY created_at ASC", + [batchId] + ); + return rows.map(mapBatchEvent); +} + +export async function getSettlementBatchReportRows(batchId: string): Promise { + const { rows } = await getPool().query( + `SELECT + sb.batch_code, + sb.status AS batch_status, + sb.merchant_id, + COALESCE(m.brand_name, m.legal_name) AS merchant_name, + m.settlement_account_reference, + m.settlement_account_type, + sbe.transaction_id, + tx.transaction_code, + tx.partner_reference, + tx.paid_at, + COALESCE(gross.amount, 0) AS gross_amount, + COALESCE(fee.amount, 0) AS platform_fee_amount, + sbe.amount AS net_payable_amount, + sbe.currency + FROM settlement_batch_entries sbe + JOIN settlement_batches sb ON sb.id = sbe.batch_id + JOIN merchants m ON m.id = sbe.merchant_id + JOIN transactions tx ON tx.id = sbe.transaction_id + LEFT JOIN ledger_entries gross + ON gross.transaction_id = sbe.transaction_id + AND gross.entry_type = 'gross_income' + LEFT JOIN ledger_entries fee + ON fee.transaction_id = sbe.transaction_id + AND fee.entry_type = 'platform_fee' + WHERE sbe.batch_id = $1 + ORDER BY tx.paid_at ASC NULLS LAST, tx.created_at ASC`, + [batchId] + ); + + return rows.map((row) => ({ + batch_code: row.batch_code, + batch_status: row.batch_status, + merchant_id: row.merchant_id, + merchant_name: row.merchant_name || undefined, + settlement_account_reference: row.settlement_account_reference || undefined, + settlement_account_type: row.settlement_account_type || undefined, + transaction_id: row.transaction_id, + transaction_code: row.transaction_code, + partner_reference: row.partner_reference, + paid_at: row.paid_at || undefined, + gross_amount: Number(row.gross_amount), + platform_fee_amount: Number(row.platform_fee_amount), + net_payable_amount: Number(row.net_payable_amount), + currency: row.currency + })); +} + +export async function markSettlementBatchPaid( + id: string, + input?: { + paid_at?: string; + paid_reference?: string; + paid_note?: string; + } +): Promise { + const existing = await getSettlementBatchById(id); + if (!existing) { + throw new Error("SETTLEMENT_BATCH_NOT_FOUND"); + } + if (existing.status !== "created") { + throw new Error("SETTLEMENT_BATCH_NOT_PAYABLE"); + } + + const { rows } = await getPool().query( + `UPDATE settlement_batches + SET status = 'paid', + paid_at = $2, + failure_reason = NULL, + metadata_json = metadata_json || $3::jsonb + WHERE id = $1 + RETURNING *`, + [ + id, + input?.paid_at || nowIso(), + { + paid_source: "admin_mark_paid", + paid_reference: input?.paid_reference || null, + paid_note: input?.paid_note || null, + paid_marked_at: nowIso() + } + ] + ); + + return mapBatch(rows[0]); +} + +export async function updateSettlementBatchReference( + id: string, + input: { + paid_reference: string; + paid_note?: string; + } +): Promise { + const existing = await getSettlementBatchById(id); + if (!existing) { + throw new Error("SETTLEMENT_BATCH_NOT_FOUND"); + } + if (existing.status !== "paid") { + throw new Error("SETTLEMENT_BATCH_REFERENCE_NOT_EDITABLE"); + } + + const { rows } = await getPool().query( + `UPDATE settlement_batches + SET metadata_json = metadata_json || $2::jsonb + WHERE id = $1 + RETURNING *`, + [ + id, + { + paid_reference: input.paid_reference, + paid_note: input.paid_note || null, + reference_update_source: "admin", + reference_updated_at: nowIso() + } + ] + ); + + return mapBatch(rows[0]); +} + +export async function recordSettlementBatchAdjustment( + id: string, + input: { + adjustment_type: "credit" | "debit"; + amount: number; + reason: string; + note?: string; + actor_id?: string; + } +): Promise { + return withClient(async (client) => { + await client.query("BEGIN"); + try { + const { rows: batchRows } = await client.query("SELECT * FROM settlement_batches WHERE id = $1 FOR UPDATE", [id]); + if (!batchRows.length) { + throw new Error("SETTLEMENT_BATCH_NOT_FOUND"); + } + + const existing = mapBatch(batchRows[0]); + if (existing.metadata_json?.reprocessed_to_batch_id) { + throw new Error("SETTLEMENT_BATCH_ADJUSTMENT_NOT_EDITABLE"); + } + + const adjustmentAmount = roundMoney(Math.abs(input.amount)); + const signedAmount = input.adjustment_type === "credit" ? adjustmentAmount : -adjustmentAmount; + const adjustmentId = randomUUID(); + const createdAt = nowIso(); + const approvalRequired = String(env.SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL).toLowerCase() === "true"; + const approvalStatus = approvalRequired ? "pending" : "approved"; + await client.query( + `INSERT INTO settlement_batch_adjustments ( + id, + batch_id, + merchant_id, + adjustment_type, + amount, + signed_amount, + currency, + reason, + note, + approval_status, + approved_by, + approved_at, + actor_type, + actor_id, + metadata_json, + created_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, + [ + adjustmentId, + existing.id, + existing.merchant_id, + input.adjustment_type, + adjustmentAmount, + signedAmount, + existing.currency, + input.reason, + input.note || null, + approvalStatus, + approvalRequired ? null : input.actor_id || "admin", + approvalRequired ? null : createdAt, + "admin", + input.actor_id || null, + { source: "settlement_adjustment_formal", approval_required: approvalRequired }, + createdAt + ] + ); + + const { rows: adjustmentRows } = await client.query( + "SELECT * FROM settlement_batch_adjustments WHERE batch_id = $1 ORDER BY created_at ASC", + [existing.id] + ); + const adjustments = adjustmentRows.map(mapBatchAdjustment); + const totalAdjustmentAmount = adjustments + .filter((item) => item.approval_status === "approved") + .reduce((sum, item) => sum + Number(item.signed_amount || 0), 0); + const { rows: updatedRows } = await client.query( + `UPDATE settlement_batches + SET metadata_json = metadata_json || $2::jsonb + WHERE id = $1 + RETURNING *`, + [ + existing.id, + { + adjustments, + total_adjustment_amount: roundMoney(totalAdjustmentAmount), + last_adjustment_id: adjustmentId, + last_adjustment_at: createdAt, + last_adjustment_approval_status: approvalStatus, + adjustment_source: "settlement_batch_adjustments" + } + ] + ); + + await client.query("COMMIT"); + return mapBatch(updatedRows[0]); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); +} + +export async function transitionSettlementBatchAdjustmentApproval( + adjustmentId: string, + input: { + approval_status: "approved" | "rejected"; + actor_id?: string; + note?: string; + } +): Promise<{ adjustment: SettlementBatchAdjustmentEntity; batch: SettlementBatchEntity }> { + return withClient(async (client) => { + await client.query("BEGIN"); + try { + const { rows: adjustmentRows } = await client.query( + "SELECT * FROM settlement_batch_adjustments WHERE id = $1 FOR UPDATE", + [adjustmentId] + ); + if (!adjustmentRows.length) { + throw new Error("SETTLEMENT_ADJUSTMENT_NOT_FOUND"); + } + + const existing = mapBatchAdjustment(adjustmentRows[0]); + if (existing.approval_status !== "pending") { + throw new Error("SETTLEMENT_ADJUSTMENT_NOT_PENDING"); + } + + const now = nowIso(); + const metadataPatch = + input.approval_status === "approved" + ? { + approval_note: input.note || null, + approved_source: "admin" + } + : { + rejection_note: input.note || null, + rejected_source: "admin" + }; + const { rows: updatedAdjustmentRows } = await client.query( + `UPDATE settlement_batch_adjustments + SET approval_status = $2, + approved_by = CASE WHEN $2 = 'approved' THEN $3 ELSE approved_by END, + approved_at = CASE WHEN $2 = 'approved' THEN $4 ELSE approved_at END, + rejected_by = CASE WHEN $2 = 'rejected' THEN $3 ELSE rejected_by END, + rejected_at = CASE WHEN $2 = 'rejected' THEN $4 ELSE rejected_at END, + metadata_json = metadata_json || $5::jsonb + WHERE id = $1 + RETURNING *`, + [adjustmentId, input.approval_status, input.actor_id || "admin", now, metadataPatch] + ); + + const { rows: allAdjustmentRows } = await client.query( + "SELECT * FROM settlement_batch_adjustments WHERE batch_id = $1 ORDER BY created_at ASC", + [existing.batch_id] + ); + const adjustments = allAdjustmentRows.map(mapBatchAdjustment); + const totalAdjustmentAmount = adjustments + .filter((item) => item.approval_status === "approved") + .reduce((sum, item) => sum + Number(item.signed_amount || 0), 0); + const { rows: batchRows } = await client.query( + `UPDATE settlement_batches + SET metadata_json = metadata_json || $2::jsonb + WHERE id = $1 + RETURNING *`, + [ + existing.batch_id, + { + adjustments, + total_adjustment_amount: roundMoney(totalAdjustmentAmount), + last_adjustment_id: adjustmentId, + last_adjustment_approval_status: input.approval_status, + last_adjustment_approval_at: now, + adjustment_source: "settlement_batch_adjustments" + } + ] + ); + + await client.query("COMMIT"); + return { + adjustment: mapBatchAdjustment(updatedAdjustmentRows[0]), + batch: mapBatch(batchRows[0]) + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); +} + +export async function transitionSettlementBatchStatus( + id: string, + input: { + status: Extract; + reason: string; + note?: string; + } +): Promise { + const existing = await getSettlementBatchById(id); + if (!existing) { + throw new Error("SETTLEMENT_BATCH_NOT_FOUND"); + } + if (existing.status !== "created") { + throw new Error("SETTLEMENT_BATCH_NOT_TRANSITIONABLE"); + } + + const { rows } = await getPool().query( + `UPDATE settlement_batches + SET status = $2, + paid_at = NULL, + failure_reason = $3, + metadata_json = metadata_json || $4::jsonb + WHERE id = $1 + RETURNING *`, + [ + id, + input.status, + input.reason, + { + resolution_source: "admin", + resolution_status: input.status, + resolution_reason: input.reason, + resolution_note: input.note || null, + resolved_at: nowIso() + } + ] + ); + + return mapBatch(rows[0]); +} + +export async function reprocessSettlementBatch(id: string): Promise<{ + source_batch: SettlementBatchEntity; + new_batch: SettlementBatchEntity; + moved_entry_count: number; +}> { + return withClient(async (client) => { + await client.query("BEGIN"); + try { + const { rows: sourceRows } = await client.query("SELECT * FROM settlement_batches WHERE id = $1 FOR UPDATE", [id]); + if (!sourceRows.length) { + throw new Error("SETTLEMENT_BATCH_NOT_FOUND"); + } + + const source = mapBatch(sourceRows[0]); + if (source.status !== "failed" && source.status !== "cancelled") { + throw new Error("SETTLEMENT_BATCH_NOT_REPROCESSABLE"); + } + if (source.metadata_json?.reprocessed_to_batch_id) { + throw new Error("SETTLEMENT_BATCH_ALREADY_REPROCESSED"); + } + + const { rows: entryRows } = await client.query( + "SELECT * FROM settlement_batch_entries WHERE batch_id = $1 ORDER BY created_at ASC FOR UPDATE", + [id] + ); + if (!entryRows.length) { + throw new Error("SETTLEMENT_BATCH_EMPTY"); + } + + const newId = randomUUID(); + const now = nowIso(); + const { rows: newBatchRows } = await client.query( + `INSERT INTO settlement_batches ( + id, + batch_code, + merchant_id, + currency, + gross_amount, + platform_fee_amount, + net_payable_amount, + entry_count, + status, + cutoff_at, + created_at, + metadata_json + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, + [ + newId, + makeBatchCode(newId), + source.merchant_id, + source.currency, + source.gross_amount, + source.platform_fee_amount, + source.net_payable_amount, + entryRows.length, + "created", + now, + now, + { + source: "settlement_reprocess", + reprocessed_from_batch_id: source.id, + reprocessed_from_batch_code: source.batch_code, + previous_status: source.status + } + ] + ); + + await client.query( + "UPDATE settlement_batch_entries SET batch_id = $2, created_at = $3 WHERE batch_id = $1", + [id, newId, now] + ); + + const { rows: updatedSourceRows } = await client.query( + `UPDATE settlement_batches + SET metadata_json = metadata_json || $2::jsonb + WHERE id = $1 + RETURNING *`, + [ + id, + { + reprocessed_to_batch_id: newId, + reprocessed_at: now + } + ] + ); + + await client.query("COMMIT"); + return { + source_batch: mapBatch(updatedSourceRows[0]), + new_batch: mapBatch(newBatchRows[0]), + moved_entry_count: entryRows.length + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); +} + +export function toSettlementBatchPayload(batch: SettlementBatchEntity) { + return { ...batch }; +} + +export function toSettlementBatchEntryPayload(entry: SettlementBatchEntryEntity) { + return { ...entry }; +} + +export function toSettlementBatchAdjustmentPayload(adjustment: SettlementBatchAdjustmentEntity) { + return { ...adjustment }; +} + +export function toSettlementBatchEventPayload(event: SettlementBatchEventEntity) { + return { ...event }; +} + +function csvCell(value: unknown) { + const text = value === null || value === undefined ? "" : String(value); + return `"${text.replace(/"/g, '""')}"`; +} + +export function settlementBatchReportToCsv(rows: SettlementBatchReportRow[]) { + const headers = [ + "batch_code", + "batch_status", + "merchant_id", + "merchant_name", + "settlement_account_reference", + "settlement_account_type", + "transaction_id", + "transaction_code", + "partner_reference", + "paid_at", + "gross_amount", + "platform_fee_amount", + "net_payable_amount", + "currency" + ]; + + const lines = rows.map((row) => + headers + .map((header) => csvCell(row[header as keyof SettlementBatchReportRow])) + .join(",") + ); + + return [headers.join(","), ...lines].join("\n") + "\n"; +} + +export function settlementBatchReportToBankGenericCsv(rows: SettlementBatchReportRow[]) { + const headers = [ + "transfer_type", + "beneficiary_account_reference", + "beneficiary_account_type", + "beneficiary_name", + "amount", + "currency", + "remark", + "batch_code", + "transaction_count" + ]; + + if (!rows.length) { + return headers.join(",") + "\n"; + } + + const first = rows[0]; + const totalAmount = rows.reduce((sum, row) => sum + Number(row.net_payable_amount || 0), 0); + const line = [ + "merchant_payout", + first.settlement_account_reference || "", + first.settlement_account_type || "", + first.merchant_name || first.merchant_id, + Math.round((totalAmount + Number.EPSILON) * 100) / 100, + first.currency || "IDR", + `Settlement ${first.batch_code}`, + first.batch_code, + rows.length + ]; + + return [headers.join(","), line.map(csvCell).join(",")].join("\n") + "\n"; +} + +export function settlementAdjustmentReportToCsv(report: SettlementAdjustmentReport) { + const headers = [ + "adjustment_id", + "batch_id", + "batch_code", + "batch_status", + "merchant_id", + "adjustment_type", + "amount", + "signed_amount", + "currency", + "approval_status", + "reason", + "note", + "actor_type", + "actor_id", + "batch_net_payable_amount", + "batch_created_at", + "batch_paid_at", + "created_at" + ]; + + const lines = report.rows.map((row) => + [ + row.id, + row.batch_id, + row.batch_code, + row.batch_status, + row.merchant_id, + row.adjustment_type, + row.amount, + row.signed_amount, + row.currency, + row.approval_status, + row.reason, + row.note || "", + row.actor_type, + row.actor_id || "", + row.net_payable_amount, + row.batch_created_at, + row.batch_paid_at || "", + row.created_at + ] + .map(csvCell) + .join(",") + ); + + const summary = [ + ["generated_at", report.generated_at], + ["total_count", report.total_count], + ["credit_amount", report.credit_amount], + ["debit_amount", report.debit_amount], + ["signed_amount", report.signed_amount] + ].map(([key, value]) => [key, value].map(csvCell).join(",")); + + return [...summary, "", headers.join(","), ...lines].join("\n") + "\n"; +} diff --git a/src/shared/store/transactionStore.ts b/src/shared/store/transactionStore.ts index c03a3eb..a4e5692 100644 --- a/src/shared/store/transactionStore.ts +++ b/src/shared/store/transactionStore.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { getPool } from "../db/pool"; -import { createPaidLedgerPlaceholder } from "./ledgerStore"; +import { createPaidLedgerEntries } from "./ledgerStore"; export type TransactionStatus = | "initiated" @@ -269,7 +269,7 @@ export async function updateTransactionStatus( const updated = mapTransaction(rows[0]); if (to === "paid") { - await createPaidLedgerPlaceholder(updated); + await createPaidLedgerEntries(updated); } return updated; diff --git a/src/shared/store/userStore.ts b/src/shared/store/userStore.ts new file mode 100644 index 0000000..37184fa --- /dev/null +++ b/src/shared/store/userStore.ts @@ -0,0 +1,107 @@ +import { scryptSync, timingSafeEqual } from "node:crypto"; +import { getPool } from "../db/pool"; + +export type RoleEntity = { + id: string; + name: string; + permissions_json: unknown; + created_at: string; +}; + +export type UserEntity = { + id: string; + name: string; + email: string; + password_hash: string; + role_id: string; + status: "active" | "inactive"; + created_at: string; + role_name: string; + permissions_json: unknown; +}; + +function mapUser(row: any): UserEntity { + return { + id: row.id, + name: row.name, + email: row.email, + password_hash: row.password_hash, + role_id: row.role_id, + status: row.status, + created_at: row.created_at, + role_name: row.role_name, + permissions_json: row.permissions_json + }; +} + +export async function getUserByEmail(email: string): Promise { + const { rows } = await getPool().query( + `SELECT users.*, roles.name AS role_name, roles.permissions_json + FROM users + JOIN roles ON roles.id = users.role_id + WHERE LOWER(users.email) = LOWER($1) + LIMIT 1`, + [email] + ); + return rows[0] ? mapUser(rows[0]) : null; +} + +function verifyScryptPassword(stored: string, password: string) { + const parts = stored.split("$"); + if (parts.length !== 4 || parts[0] !== "scrypt") { + return false; + } + const [, salt, keyLengthRaw, expectedHex] = parts; + const keyLength = Number(keyLengthRaw); + if (!salt || !Number.isFinite(keyLength) || keyLength <= 0 || !expectedHex) { + return false; + } + const expected = Buffer.from(expectedHex, "hex"); + const actual = scryptSync(password, salt, keyLength); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +export function verifyPassword(stored: string, password: string) { + if (stored.startsWith("scrypt$")) { + return verifyScryptPassword(stored, password); + } + + return stored === password; +} + +export function hasPermission(permissions: unknown, permission: string) { + if (!permission) { + return true; + } + if (Array.isArray(permissions)) { + return permissions.includes("*") || permissions.includes(permission); + } + if (!permissions || typeof permissions !== "object") { + return false; + } + + const adminValue = (permissions as Record).admin; + if (adminValue === "*" || adminValue === true) { + return true; + } + + const value = (permissions as Record)[permission]; + if (value === true || value === "*") { + return true; + } + + const [domain, action] = permission.split(":"); + const domainValue = (permissions as Record)[domain]; + if (domainValue === "*" || domainValue === true) { + return true; + } + if (Array.isArray(domainValue)) { + return domainValue.includes("*") || domainValue.includes(action); + } + if (domainValue && typeof domainValue === "object") { + const scoped = domainValue as Record; + return scoped[action] === true || scoped[action] === "*"; + } + + return false; +} diff --git a/ui/admin-dashboard-overview/index.html b/ui/admin-dashboard-overview/index.html index a562d10..c89ed79 100644 --- a/ui/admin-dashboard-overview/index.html +++ b/ui/admin-dashboard-overview/index.html @@ -130,66 +130,66 @@ -