Prepare QF100 pilot and Debian app deploy
This commit is contained in:
@ -20,7 +20,7 @@ TRACE_HEADER=x-request-id
|
|||||||
IDEMPOTENCY_TTL_MS=300000
|
IDEMPOTENCY_TTL_MS=300000
|
||||||
INTEGRATION_WEBHOOK_SECRET=dev-callback-secret
|
INTEGRATION_WEBHOOK_SECRET=dev-callback-secret
|
||||||
MQTT_PUBLISH_MODE=simulator
|
MQTT_PUBLISH_MODE=simulator
|
||||||
MQTT_BROKER_URL=mqtts://mqtt.iptek.co:8883
|
MQTT_BROKER_URL=mqtts://broker.bizone.id:8883
|
||||||
MQTT_USERNAME=qris-backend
|
MQTT_USERNAME=qris-backend
|
||||||
MQTT_PASSWORD=change-me
|
MQTT_PASSWORD=change-me
|
||||||
MQTT_CLIENT_ID=qris-platform-backend
|
MQTT_CLIENT_ID=qris-platform-backend
|
||||||
@ -30,6 +30,11 @@ MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/#
|
|||||||
MQTT_PUBLISH_FORCE_FAIL_ALL=false
|
MQTT_PUBLISH_FORCE_FAIL_ALL=false
|
||||||
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS=
|
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS=
|
||||||
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000
|
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000
|
||||||
|
QF100_MQTT_BROKER_HOST=
|
||||||
|
QF100_MQTT_BROKER_PORT=0
|
||||||
|
QF100_MQTT_USERNAME=
|
||||||
|
QF100_MQTT_PASSWORD=
|
||||||
|
QF100_MQTT_KEEP_ALIVE_SECONDS=60
|
||||||
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true
|
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true
|
||||||
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS=60000
|
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS=60000
|
||||||
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT=100
|
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT=100
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
.env
|
||||||
|
QF100-60s-l511-SecondApp-260107/
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# MQTT Broker Mosquitto on Debian 13
|
# MQTT Broker Mosquitto on Debian 13
|
||||||
|
|
||||||
Panduan operasional untuk menyiapkan broker MQTT awal platform QRIS Soundbox di Debian 13 dengan subdomain `mqtt.iptek.co`.
|
Panduan operasional untuk menyiapkan broker MQTT awal platform QRIS Soundbox di Debian 13 dengan subdomain `broker.bizone.id`.
|
||||||
|
|
||||||
Keputusan arsitektur terkait:
|
Keputusan arsitektur terkait:
|
||||||
- `D-026`: broker MQTT sungguhan ditunda sampai infrastruktur siap; simulator/outbox tetap dipakai selama transisi.
|
- `D-026`: broker MQTT sungguhan ditunda sampai infrastruktur siap; simulator/outbox tetap dipakai selama transisi.
|
||||||
@ -9,7 +9,7 @@ Keputusan arsitektur terkait:
|
|||||||
## Target Setup
|
## Target Setup
|
||||||
|
|
||||||
- Broker: Eclipse Mosquitto.
|
- Broker: Eclipse Mosquitto.
|
||||||
- Domain: `mqtt.iptek.co`.
|
- Domain: `broker.bizone.id`.
|
||||||
- MQTT TLS publik: `8883/tcp`.
|
- MQTT TLS publik: `8883/tcp`.
|
||||||
- MQTT local-only: `1883/tcp` pada `127.0.0.1`.
|
- MQTT local-only: `1883/tcp` pada `127.0.0.1`.
|
||||||
- TLS: Let's Encrypt.
|
- TLS: Let's Encrypt.
|
||||||
@ -19,10 +19,10 @@ Keputusan arsitektur terkait:
|
|||||||
|
|
||||||
## DNS dan Paket
|
## DNS dan Paket
|
||||||
|
|
||||||
Pastikan DNS `mqtt.iptek.co` sudah mengarah ke public IP server.
|
Pastikan DNS `broker.bizone.id` sudah mengarah ke public IP server.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dig +short mqtt.iptek.co
|
dig +short broker.bizone.id
|
||||||
curl -4 ifconfig.me
|
curl -4 ifconfig.me
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -55,7 +55,7 @@ Jangan buka `1883/tcp` ke internet. Listener `1883` hanya untuk localhost/intern
|
|||||||
Ambil sertifikat Let's Encrypt:
|
Ambil sertifikat Let's Encrypt:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo certbot certonly --standalone -d mqtt.iptek.co
|
sudo certbot certonly --standalone -d broker.bizone.id
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy sertifikat ke lokasi yang bisa dibaca Mosquitto:
|
Copy sertifikat ke lokasi yang bisa dibaca Mosquitto:
|
||||||
@ -64,11 +64,11 @@ Copy sertifikat ke lokasi yang bisa dibaca Mosquitto:
|
|||||||
sudo install -d -o root -g mosquitto -m 750 /etc/mosquitto/certs
|
sudo install -d -o root -g mosquitto -m 750 /etc/mosquitto/certs
|
||||||
|
|
||||||
sudo install -o root -g mosquitto -m 640 \
|
sudo install -o root -g mosquitto -m 640 \
|
||||||
/etc/letsencrypt/live/mqtt.iptek.co/fullchain.pem \
|
/etc/letsencrypt/live/broker.bizone.id/fullchain.pem \
|
||||||
/etc/mosquitto/certs/fullchain.pem
|
/etc/mosquitto/certs/fullchain.pem
|
||||||
|
|
||||||
sudo install -o root -g mosquitto -m 640 \
|
sudo install -o root -g mosquitto -m 640 \
|
||||||
/etc/letsencrypt/live/mqtt.iptek.co/privkey.pem \
|
/etc/letsencrypt/live/broker.bizone.id/privkey.pem \
|
||||||
/etc/mosquitto/certs/privkey.pem
|
/etc/mosquitto/certs/privkey.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -84,7 +84,7 @@ Isi:
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DOMAIN="mqtt.iptek.co"
|
DOMAIN="broker.bizone.id"
|
||||||
|
|
||||||
install -o root -g mosquitto -m 640 \
|
install -o root -g mosquitto -m 640 \
|
||||||
"/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" \
|
"/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" \
|
||||||
@ -206,7 +206,7 @@ Terminal 1, subscribe sebagai backend:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosquitto_sub \
|
mosquitto_sub \
|
||||||
-h mqtt.iptek.co \
|
-h broker.bizone.id \
|
||||||
-p 8883 \
|
-p 8883 \
|
||||||
-u qris-backend \
|
-u qris-backend \
|
||||||
-P 'PASSWORD_BACKEND' \
|
-P 'PASSWORD_BACKEND' \
|
||||||
@ -218,7 +218,7 @@ Terminal 2, publish sebagai device:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosquitto_pub \
|
mosquitto_pub \
|
||||||
-h mqtt.iptek.co \
|
-h broker.bizone.id \
|
||||||
-p 8883 \
|
-p 8883 \
|
||||||
-u DEVICE_UUID_FROM_PLATFORM \
|
-u DEVICE_UUID_FROM_PLATFORM \
|
||||||
-P 'PASSWORD_DEVICE' \
|
-P 'PASSWORD_DEVICE' \
|
||||||
@ -230,7 +230,7 @@ Test ACL negatif:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosquitto_pub \
|
mosquitto_pub \
|
||||||
-h mqtt.iptek.co \
|
-h broker.bizone.id \
|
||||||
-p 8883 \
|
-p 8883 \
|
||||||
-u DEVICE_UUID_FROM_PLATFORM \
|
-u DEVICE_UUID_FROM_PLATFORM \
|
||||||
-P 'PASSWORD_DEVICE' \
|
-P 'PASSWORD_DEVICE' \
|
||||||
@ -260,7 +260,7 @@ Saat adapter broker sungguhan dipasang ke platform:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
MQTT_PUBLISH_MODE=broker
|
MQTT_PUBLISH_MODE=broker
|
||||||
MQTT_BROKER_URL=mqtts://mqtt.iptek.co:8883
|
MQTT_BROKER_URL=mqtts://broker.bizone.id:8883
|
||||||
MQTT_USERNAME=qris-backend
|
MQTT_USERNAME=qris-backend
|
||||||
MQTT_PASSWORD=...
|
MQTT_PASSWORD=...
|
||||||
MQTT_CLIENT_ID=qris-platform-backend
|
MQTT_CLIENT_ID=qris-platform-backend
|
||||||
|
|||||||
459
CODEX_HANDOFF.md
459
CODEX_HANDOFF.md
@ -1,251 +1,316 @@
|
|||||||
# Codex Handoff - QRIS Soundbox Platform
|
# Codex Handoff - QRIS Soundbox Platform
|
||||||
|
|
||||||
Tanggal update: 2026-05-29, Asia/Jakarta.
|
Tanggal update: 2026-06-03, Asia/Jakarta.
|
||||||
|
|
||||||
Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat.
|
Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat.
|
||||||
|
|
||||||
## Status Terakhir
|
## Status Terakhir
|
||||||
|
|
||||||
- Estimasi MVP / early pilot: 92-94%.
|
- Fokus hari ini bergeser dari production readiness umum ke integrasi device soundbox QF100.
|
||||||
- Estimasi production-ready penuh: 82-85%.
|
- Folder SDK lokal `QF100-60s-l511-SecondApp-260107/` ditemukan dan dianalisis. Folder ini sengaja tidak dimasukkan git.
|
||||||
- Platform sudah bukan prototype docs-only. Backend, UI operasional, migration, smoke test, rate limiting, audit logging, async export, runbook, dan script deployment sudah tersedia.
|
- Backend sekarang sudah punya adapter awal untuk firmware QF100 sample:
|
||||||
- Fokus terakhir yang selesai: rate limiting + security polish, login audit, admin audit UI real-data, placeholder nav cleanup, dan runbook/checklist produksi.
|
- config server vendor-compatible;
|
||||||
- Worktree kemungkinan masih dirty karena banyak perubahan aktif. Jangan revert perubahan yang tidak eksplisit diminta.
|
- payload MQTT payment success format QF100;
|
||||||
|
- smoke script backend untuk static + dynamic QF100.
|
||||||
|
- Target milestone berikutnya: dua soundbox real, satu static dan satu dynamic, bisa ambil config dari backend, connect MQTT, lalu static payment test bunyi dan tercatat di dashboard.
|
||||||
|
- Worktree aktif/dirty karena perubahan backend QF100 dan update handoff. Jangan revert perubahan yang tidak eksplisit diminta.
|
||||||
|
|
||||||
## Verifikasi Terakhir
|
## Verifikasi Terakhir
|
||||||
|
|
||||||
- `npm run typecheck`: pass.
|
- `npm run typecheck`: pass setelah perubahan QF100.
|
||||||
- `npm run db:migrate`: pass dan idempotent sampai migration `003_export_job_storage.sql`.
|
- `npm install`: sudah dijalankan untuk memasang dependency lokal; perubahan lockfile accidental sudah dibalik.
|
||||||
- `npm audit --json`: pass, 0 vulnerability.
|
- `npm run smoke:qf100`: script sudah dibuat, belum dijalankan end-to-end di DB/server lokal pada turn ini.
|
||||||
- `npm run ui:qa`: pass setelah cleanup placeholder navigation.
|
- Verifikasi lama yang masih relevan:
|
||||||
- `npm run smoke:e2e`: pass setelah rate limiting dan login audit.
|
- `npm run db:migrate`: sebelumnya pass dan idempotent sampai migration `003_export_job_storage.sql`.
|
||||||
- Quick rate limit test: pass. Login admin salah pertama menghasilkan `401` dengan `RateLimit-Remaining: 0`, request berikutnya menghasilkan `429 RATE_LIMITED`.
|
- `npm audit --json`: sebelumnya pass, 0 vulnerability.
|
||||||
- Quick login audit test: pass. Event `admin.login.success`, `admin.login.failed`, `merchant.login.success`, dan `merchant.login.failed` tercatat.
|
- `npm run ui:qa`: sebelumnya pass.
|
||||||
- Quick audit UI API test: pass. `GET /admin/audit-logs?action_contains=.login.&limit=10` mengembalikan event login.
|
- `npm run smoke:e2e`: sebelumnya pass.
|
||||||
- Production-like env check dummy: pass via `npm run deploy:check-env`, hanya warning opsional untuk `MQTT_SUBSCRIBE`.
|
- Real MQTT smoke sebelumnya pernah pass terhadap `mqtts://broker.bizone.id:8883`.
|
||||||
- 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
|
## SDK QF100 Yang Ditemukan
|
||||||
|
|
||||||
### 1. Auth, RBAC, dan Security
|
- Folder lokal: `QF100-60s-l511-SecondApp-260107/`.
|
||||||
|
- Sudah ditambahkan ke `.gitignore`:
|
||||||
|
- `QF100-60s-l511-SecondApp-260107/`
|
||||||
|
- Struktur penting:
|
||||||
|
- `docs/Config Server.docx`: kontrak config server vendor.
|
||||||
|
- `docs/Cloud Speaker API Spec V2.8.7.pdf`: API spec cloud speaker.
|
||||||
|
- `app/source/MainApp/globalDefine.h`: device model, demo mode, `CONFIG_ADDR`.
|
||||||
|
- `app/source/MainApp/demo.c`: alur config server, MQTT connect/subscribe, parse payment payload.
|
||||||
|
- `app/source/MainApp/demo.h`: MQTT TLS/QoS/clean session/cert config.
|
||||||
|
- `app/source/MainApp/main.c`: boot flow dan monitor network/MQTT.
|
||||||
|
- `app/inc/MercuryMqtt.h`: header MQTT SDK.
|
||||||
|
- `app/release/user_app.bin`: firmware build existing.
|
||||||
|
|
||||||
- Admin session login tersedia lewat `/admin/login`, `/admin/logout`, `/admin/me`.
|
## Kesimpulan SDK QF100
|
||||||
- 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
|
- SDK ini memungkinkan develop/patch aplikasi firmware sendiri, tetapi bentuknya embedded C firmware app, bukan SDK backend.
|
||||||
|
- Strategi dipilih: firmware hanya hardcode URL config server backend kita, bukan hardcode broker MQTT.
|
||||||
|
- Firmware sample boot lalu call `CONFIG_ADDR` ke endpoint vendor `/speaker/dev-config`.
|
||||||
|
- Request config berisi field seperti:
|
||||||
|
- `dev-model`
|
||||||
|
- `item-number`
|
||||||
|
- `dev-sn`
|
||||||
|
- `fw-version`
|
||||||
|
- `fw-build`
|
||||||
|
- `app-config-version`
|
||||||
|
- `imei`
|
||||||
|
- `imsi`
|
||||||
|
- `iccid`
|
||||||
|
- Response yang firmware cari memiliki blok:
|
||||||
|
- `mqtt.broker-ip`
|
||||||
|
- `mqtt.broker-port`
|
||||||
|
- `mqtt.client-id`
|
||||||
|
- `mqtt.user-name`
|
||||||
|
- `mqtt.password`
|
||||||
|
- `mqtt.subscribe-topic`
|
||||||
|
- `mqtt.keep-alive`
|
||||||
|
- Payment success payload yang firmware sample bisa bunyikan:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"category": 1
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"pay-amount": 15000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `MQTT_TLS_ENABLE` di SDK sample masih `0`. Jika broker production memakai `mqtts://...:8883`, firmware perlu patch TLS atau pilot perlu listener non-TLS terbatas.
|
||||||
|
|
||||||
- Audit logging login admin:
|
## Implementasi QF100 Backend Hari Ini
|
||||||
- `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
|
### 1. Config Server Vendor-Compatible
|
||||||
|
|
||||||
- MQTT worker dan policy production sudah diperketat.
|
- Route baru:
|
||||||
- Wildcard subscribe default production dicegah oleh env check.
|
- `GET /speaker/dev-config`
|
||||||
- MQTT ACL tooling tersedia:
|
- File:
|
||||||
- `scripts/check-mqtt-acl.mjs`
|
- `src/routes/speaker.ts`
|
||||||
- `scripts/smoke-mqtt-acl.mjs`
|
- mounted di `src/app.ts`
|
||||||
- `scripts/provision-mqtt-device.mjs`
|
- Perilaku:
|
||||||
- Package scripts:
|
- menerima query/body vendor;
|
||||||
- `npm run mqtt:provision-device`
|
- lookup device memakai `dev-sn` -> `devices.serial_number`;
|
||||||
- `npm run mqtt:check-acl`
|
- jika device aktif, update:
|
||||||
- `npm run smoke:mqtt-acl`
|
- `vendor`
|
||||||
- `npm run smoke:mqtt-real`
|
- `model`
|
||||||
- Real MQTT smoke pernah pass dengan broker `mqtts://mqtt.iptek.co:8883`.
|
- `communication_mode`
|
||||||
|
- `last_seen_at`
|
||||||
|
- `firmware_version`
|
||||||
|
- mencatat heartbeat `state: config_pull`;
|
||||||
|
- balas JSON vendor top-level, bukan wrapper internal `successResponse`.
|
||||||
|
- Error vendor style:
|
||||||
|
- `1001`: `dev-sn` kosong;
|
||||||
|
- `1002`: device tidak terdaftar atau inactive;
|
||||||
|
- `1003`: broker MQTT belum dikonfigurasi.
|
||||||
|
|
||||||
### 4. Settlement, Reconciliation, dan Finance Ops
|
### 2. Env QF100
|
||||||
|
|
||||||
- Settlement batch, merchant settlement history, reconciliation management, adjustment approval, dan device technical detail UI sudah tersedia.
|
- Env baru di `src/config/env.ts` dan `.env.example`:
|
||||||
- Admin reconciliation UI sudah menggunakan async export flow dan export history.
|
- `QF100_MQTT_BROKER_HOST`
|
||||||
- Key UI pages:
|
- `QF100_MQTT_BROKER_PORT`
|
||||||
- [ui/admin-reconciliation-management/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/admin-reconciliation-management/index.html)
|
- `QF100_MQTT_USERNAME`
|
||||||
- [ui/settlement-batch-management/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/settlement-batch-management/index.html)
|
- `QF100_MQTT_PASSWORD`
|
||||||
- [ui/merchant-settlement-history/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/merchant-settlement-history/index.html)
|
- `QF100_MQTT_KEEP_ALIVE_SECONDS`
|
||||||
- [ui/device-technical-detail/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/device-technical-detail/index.html)
|
- Jika host/port QF100 kosong, endpoint mencoba parse dari `MQTT_BROKER_URL`.
|
||||||
- Placeholder `href="#"` sudah dibersihkan dari UI yang masuk QA.
|
- Catatan penting: password MQTT yang dikirim ke QF100 diambil dari `QF100_MQTT_PASSWORD` atau fallback `MQTT_PASSWORD`. Sistem saat ini menyimpan credential device sebagai fingerprint, jadi plaintext per-device tidak bisa dibaca ulang dari DB.
|
||||||
|
|
||||||
### 5. Async Export dan Storage
|
### 3. Payload MQTT QF100
|
||||||
|
|
||||||
- Async export job sudah tersedia untuk settlement adjustment export.
|
- File:
|
||||||
- Export job worker:
|
- `src/shared/services/mqttPublisher.ts`
|
||||||
- [src/shared/services/exportJobWorker.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/exportJobWorker.ts)
|
- `src/shared/orchestrators/notificationOrchestrator.ts`
|
||||||
- Export job store:
|
- Topic QF100:
|
||||||
- [src/shared/store/exportJobStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/exportJobStore.ts)
|
- `devices/{deviceId}/downlink/qf100`
|
||||||
- Migration:
|
- Adapter memilih format QF100 jika:
|
||||||
- [migrations/002_export_jobs.sql](/home/wira/work/codex/qris-soundbox-platform/migrations/002_export_jobs.sql)
|
- `device.model` mengandung `QF100`; atau
|
||||||
- [migrations/003_export_job_storage.sql](/home/wira/work/codex/qris-soundbox-platform/migrations/003_export_job_storage.sql)
|
- `capability_profile_json.mqtt_payload_profile`, `protocol_profile`, atau `vendor_protocol` bernilai `qf100`.
|
||||||
- Export file storage memakai `EXPORT_STORAGE_DIR`, dengan metadata path/size/expiry.
|
- Downlink payment sekarang juga dicatat ke `mqtt_messages`, sehingga dashboard device detail bisa melihat topic/payload yang dikirim.
|
||||||
- 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
|
### 4. Device Store dan Schema
|
||||||
|
|
||||||
- Production env checker diperketat:
|
- Lookup baru:
|
||||||
- [scripts/check-production-env.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/check-production-env.mjs)
|
- `getDeviceBySerialNumber(serialNumber)` di `src/shared/store/deviceStore.ts`.
|
||||||
- Backup/restore tooling:
|
- Schema bootstrap menambahkan index:
|
||||||
- [scripts/backup-production.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/backup-production.mjs)
|
- `idx_devices_serial_number`.
|
||||||
- [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
|
### 5. Smoke QF100
|
||||||
|
|
||||||
|
- Script baru:
|
||||||
|
- `scripts/smoke-qf100-adapter.mjs`
|
||||||
|
- Package script baru:
|
||||||
|
- `npm run smoke:qf100`
|
||||||
|
- Script ini:
|
||||||
|
- create merchant/outlet/terminal/device static;
|
||||||
|
- create merchant/outlet/terminal/device dynamic MQTT;
|
||||||
|
- set `model: QF100` dan `capability_profile_json.mqtt_payload_profile: qf100`;
|
||||||
|
- hit `/speaker/dev-config` untuk dua SN;
|
||||||
|
- validasi MQTT config vendor;
|
||||||
|
- trigger static QRIS paid callback;
|
||||||
|
- validasi `mqtt_messages` downlink QF100 payload `category: 1`;
|
||||||
|
- trigger backend dynamic QR MQTT flow untuk device dynamic.
|
||||||
|
- Untuk memakai SN real:
|
||||||
|
```bash
|
||||||
|
QF100_STATIC_SN=<sn-static> \
|
||||||
|
QF100_DYNAMIC_SN=<sn-dynamic> \
|
||||||
|
BASE_URL=http://127.0.0.1:3000 \
|
||||||
|
npm run smoke:qf100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Device Flow Yang Disepakati
|
||||||
|
|
||||||
|
### Static Soundbox
|
||||||
|
|
||||||
|
1. Device boot.
|
||||||
|
2. Firmware call backend `/speaker/dev-config`.
|
||||||
|
3. Backend balas MQTT config.
|
||||||
|
4. Device connect MQTT dan subscribe `devices/{deviceId}/downlink/qf100`.
|
||||||
|
5. QRIS callback paid masuk backend.
|
||||||
|
6. Backend publish payload QF100 `category: 1`.
|
||||||
|
7. Device bunyi nominal.
|
||||||
|
8. Dashboard melihat transaction, notification, mqtt message, last seen/config pull.
|
||||||
|
|
||||||
|
### Dynamic Soundbox
|
||||||
|
|
||||||
|
- Prinsip: trigger tetap via MQTT, bukan polling terus-menerus.
|
||||||
|
- HTTP polling dynamic QR dianggap tidak cocok untuk skala 20 ribu device karena `gap=30s` berarti sekitar 40 ribu request/menit.
|
||||||
|
- Desain lanjutan:
|
||||||
|
1. Backend/admin/merchant membuat dynamic QR request.
|
||||||
|
2. Backend publish MQTT command ke device.
|
||||||
|
3. Device menampilkan QR langsung atau HTTP fetch QR detail dari URL.
|
||||||
|
4. Payment callback tetap jadi source of truth.
|
||||||
|
5. Payment success tetap via MQTT `category: 1`.
|
||||||
|
- Firmware dynamic handler belum dipatch. Perlu tambah handler misalnya `category == 10` di `demo.c`.
|
||||||
|
|
||||||
|
## Dashboard Yang Perlu Disiapkan Berikutnya
|
||||||
|
|
||||||
|
Prioritas UI/dashboard untuk test dua soundbox real:
|
||||||
|
|
||||||
|
1. Device detail QF100 panel:
|
||||||
|
- SN;
|
||||||
|
- model/profile;
|
||||||
|
- config pull terakhir;
|
||||||
|
- broker host/port yang dikirim;
|
||||||
|
- client-id;
|
||||||
|
- subscribe-topic;
|
||||||
|
- keepalive.
|
||||||
|
2. Test payment button:
|
||||||
|
- nominal quick actions, misalnya Rp1.000 dan Rp15.000;
|
||||||
|
- create dummy tx/callback internal;
|
||||||
|
- tampilkan notification dan MQTT payload result.
|
||||||
|
3. MQTT timeline:
|
||||||
|
- direction;
|
||||||
|
- topic;
|
||||||
|
- message_type;
|
||||||
|
- payload JSON;
|
||||||
|
- publish_status/reason;
|
||||||
|
- timestamp.
|
||||||
|
4. Dynamic QR panel:
|
||||||
|
- input nominal;
|
||||||
|
- create dynamic QR;
|
||||||
|
- tampilkan QR payload/status;
|
||||||
|
- nanti `Send to Soundbox` via MQTT command setelah firmware handler siap.
|
||||||
|
5. Ops summary:
|
||||||
|
- total soundbox;
|
||||||
|
- online/stale/offline;
|
||||||
|
- config pull terbaru;
|
||||||
|
- payment notification sent/failed;
|
||||||
|
- dynamic QR active/paid/expired.
|
||||||
|
|
||||||
|
## Endpoint Penting Saat Ini
|
||||||
|
|
||||||
- Health:
|
- Health:
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
- `GET /health/ready`
|
- `GET /health/deep`
|
||||||
|
- QF100 vendor config:
|
||||||
|
- `GET /speaker/dev-config`
|
||||||
- Admin auth/session:
|
- Admin auth/session:
|
||||||
- `POST /admin/login`
|
- `POST /admin/login`
|
||||||
- `POST /admin/logout`
|
- `POST /admin/logout`
|
||||||
- `GET /admin/me`
|
- `GET /admin/me`
|
||||||
- Admin audit/observability:
|
- Admin device:
|
||||||
- `GET /admin/audit-logs`
|
- `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`
|
||||||
|
- `GET /admin/devices/{id}/mqtt-messages`
|
||||||
|
- `GET /admin/devices/{id}/notifications`
|
||||||
|
- Device API:
|
||||||
|
- `POST /device/heartbeat`
|
||||||
|
- `POST /device/transactions/dynamic-qr`
|
||||||
|
- `POST /device/mqtt/uplink/dynamic-qr/request`
|
||||||
|
- `GET /device/config`
|
||||||
|
- `POST /device/config/ack`
|
||||||
|
- Integrations:
|
||||||
|
- `POST /integrations/qris/callback`
|
||||||
|
- Observability:
|
||||||
- `GET /admin/observability/summary`
|
- `GET /admin/observability/summary`
|
||||||
- `GET /admin/observability/dead-letter-replays`
|
|
||||||
- `GET /admin/observability/mqtt-status`
|
- `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
|
## Package Scripts Penting
|
||||||
|
|
||||||
- `npm run typecheck`
|
- `npm run typecheck`
|
||||||
- `npm run db:migrate`
|
- `npm run db:migrate`
|
||||||
|
- `npm run smoke:qf100`
|
||||||
- `npm run smoke:e2e`
|
- `npm run smoke:e2e`
|
||||||
|
- `npm run smoke:mqtt-real`
|
||||||
|
- `npm run smoke:mqtt-acl`
|
||||||
- `npm run ui:qa`
|
- `npm run ui:qa`
|
||||||
- `npm run deploy:check-env`
|
- `npm run deploy:check-env`
|
||||||
- `npm run load:test`
|
- `npm run load:test`
|
||||||
- `npm run load:test:staging`
|
- `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:provision-device`
|
||||||
- `npm run mqtt:check-acl`
|
- `npm run mqtt:check-acl`
|
||||||
- `npm run smoke:mqtt-acl`
|
- `npm run admin:create-user`
|
||||||
- `npm run smoke:mqtt-real`
|
- `npm run merchant:create-user`
|
||||||
|
|
||||||
## File Kunci yang Sering Disentuh
|
## File Kunci Yang Sering Disentuh
|
||||||
|
|
||||||
- App bootstrap: [src/app.ts](/home/wira/work/codex/qris-soundbox-platform/src/app.ts)
|
- App bootstrap: `src/app.ts`
|
||||||
- Env config: [src/config/env.ts](/home/wira/work/codex/qris-soundbox-platform/src/config/env.ts)
|
- Env config: `src/config/env.ts`
|
||||||
- Admin routes: [src/routes/admin.ts](/home/wira/work/codex/qris-soundbox-platform/src/routes/admin.ts)
|
- QF100 config route: `src/routes/speaker.ts`
|
||||||
- Merchant routes: [src/routes/merchant.ts](/home/wira/work/codex/qris-soundbox-platform/src/routes/merchant.ts)
|
- Device route: `src/routes/device.ts`
|
||||||
- Audit store: [src/shared/store/auditLogStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/auditLogStore.ts)
|
- Admin route: `src/routes/admin.ts`
|
||||||
- Export worker: [src/shared/services/exportJobWorker.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/exportJobWorker.ts)
|
- MQTT publisher: `src/shared/services/mqttPublisher.ts`
|
||||||
- Export store: [src/shared/store/exportJobStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/exportJobStore.ts)
|
- MQTT subscriber: `src/shared/services/mqttSubscriber.ts`
|
||||||
- Rate limit middleware: [src/shared/middleware/rateLimit.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/middleware/rateLimit.ts)
|
- Notification orchestrator: `src/shared/orchestrators/notificationOrchestrator.ts`
|
||||||
- UI QA script: [scripts/ui-qa-check.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/ui-qa-check.mjs)
|
- Device store: `src/shared/store/deviceStore.ts`
|
||||||
- Admin audit UI: [ui/admin-system-audit-logs/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/admin-system-audit-logs/index.html)
|
- MQTT message store: `src/shared/store/mqttMessageStore.ts`
|
||||||
- Env sample: [.env.example](/home/wira/work/codex/qris-soundbox-platform/.env.example)
|
- Heartbeat store: `src/shared/store/heartbeatStore.ts`
|
||||||
|
- Schema bootstrap: `src/shared/db/pool.ts`
|
||||||
## Decision Log Ringkas
|
- QF100 smoke: `scripts/smoke-qf100-adapter.mjs`
|
||||||
|
- Env sample: `.env.example`
|
||||||
- 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
|
## Sisa Gap Utama
|
||||||
|
|
||||||
1. Eksekusi staging nyata dari checklist:
|
1. Jalankan `npm run smoke:qf100` terhadap backend + DB lokal/staging.
|
||||||
- deploy dengan env final;
|
2. Register dua device real:
|
||||||
- jalankan `deploy:check-env`, migration, smoke, UI QA, load report;
|
- static SN -> device static;
|
||||||
- simpan artefak hasil staging.
|
- dynamic SN -> device dynamic.
|
||||||
2. Pilot real device:
|
3. Patch firmware QF100:
|
||||||
- provisioning device real;
|
- `CONFIG_ADDR` ke backend kita;
|
||||||
- validasi MQTT ACL per device;
|
- TLS setting sesuai broker.
|
||||||
- transaksi QRIS test end-to-end;
|
4. Test static device real:
|
||||||
- validasi soundbox delivery dan dead-letter handling.
|
- config pull;
|
||||||
3. Restore drill nyata:
|
- MQTT connect;
|
||||||
- backup production/staging;
|
- payment success bunyi;
|
||||||
- restore ke database disposable;
|
- dashboard melihat downlink.
|
||||||
- jalankan `restore:validate`;
|
5. Patch firmware dynamic:
|
||||||
- dokumentasikan RTO/RPO aktual.
|
- tambah handler command dynamic QR, kemungkinan `category == 10`;
|
||||||
4. Export storage production topology:
|
- pilih direct QR payload vs HTTP fetch detail.
|
||||||
- pastikan `EXPORT_STORAGE_DIR` durable, absolute, writable, dan di-backup;
|
6. Siapkan dashboard QF100 detail/test panel.
|
||||||
- jika multi-node, perlu shared filesystem/object storage strategy.
|
7. Putuskan credential strategy production:
|
||||||
5. Manual visual QA:
|
- shared pilot password saat ini cukup untuk lab;
|
||||||
- buka halaman admin utama di browser;
|
- production sebaiknya credential per-device yang bisa diprovisioning aman.
|
||||||
- cek layout mobile/desktop;
|
8. Tetap perlu staging rehearsal, restore drill, export storage strategy, dan manual visual QA dari handoff lama.
|
||||||
- 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
|
## Catatan Penting
|
||||||
|
|
||||||
- Jangan hidupkan legacy auth di production.
|
- Folder SDK QF100 adalah artefak lokal dan jangan dimasukkan git.
|
||||||
- Jangan gunakan wildcard MQTT subscribe di production kecuali sedang maintenance terkontrol.
|
- Jangan hardcode broker credential production di firmware.
|
||||||
- `EXPORT_STORAGE_DIR` harus absolute path dan durable untuk production.
|
- Firmware cukup hardcode config server URL; broker/topic/credential dikirim dari backend.
|
||||||
- Rate limiting sekarang aktif secara default jika `RATE_LIMIT_ENABLED=true`; hati-hati saat smoke test berulang pada login endpoint.
|
- Jangan gunakan wildcard MQTT subscribe di production kecuali maintenance terkontrol.
|
||||||
|
- Jika memakai MQTT TLS (`mqtts://...:8883`), firmware QF100 perlu `MQTT_TLS_ENABLE=1` dan sertifikat yang sesuai.
|
||||||
|
- Rate limiting aktif default; `/speaker` memakai limiter device.
|
||||||
- `CODEX_HANDOFF.md` ini adalah snapshot operasional terbaru; untuk detail historis keputusan, baca `DECISIONS_LOG.md`.
|
- `CODEX_HANDOFF.md` ini adalah snapshot operasional terbaru; untuk detail historis keputusan, baca `DECISIONS_LOG.md`.
|
||||||
|
|||||||
372
DEBIAN12_APP_SERVER_SETUP.md
Normal file
372
DEBIAN12_APP_SERVER_SETUP.md
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
# Debian 12 App Server Setup
|
||||||
|
|
||||||
|
Panduan ini untuk menyiapkan server kosong Debian 12 sebagai app server QRIS Soundbox Platform.
|
||||||
|
|
||||||
|
- App domain: `sms.bizone.id`
|
||||||
|
- App user: `qrisapp`
|
||||||
|
- App directory: `/opt/qris-soundbox`
|
||||||
|
- Env file: `/etc/qris-soundbox/qris-soundbox.env`
|
||||||
|
- Local app port: `3000`
|
||||||
|
- MQTT broker: `broker.bizone.id`
|
||||||
|
|
||||||
|
Broker MQTT boleh berada di server lain. Dokumen ini fokus ke server aplikasi.
|
||||||
|
|
||||||
|
## 1. DNS
|
||||||
|
|
||||||
|
Pastikan `sms.bizone.id` sudah mengarah ke public IP server app.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short sms.bizone.id
|
||||||
|
curl -4 ifconfig.me
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Base Packages
|
||||||
|
|
||||||
|
Jalankan sebagai root atau user sudo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt upgrade -y
|
||||||
|
sudo apt install -y \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gnupg \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
nginx \
|
||||||
|
certbot \
|
||||||
|
python3-certbot-nginx \
|
||||||
|
postgresql \
|
||||||
|
postgresql-contrib \
|
||||||
|
ufw
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Node.js
|
||||||
|
|
||||||
|
Gunakan Node.js 22 untuk runtime production.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Firewall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow OpenSSH
|
||||||
|
sudo ufw allow 'Nginx Full'
|
||||||
|
sudo ufw enable
|
||||||
|
sudo ufw status
|
||||||
|
```
|
||||||
|
|
||||||
|
Jangan buka port `3000` ke internet. Traffic publik masuk lewat Nginx.
|
||||||
|
|
||||||
|
## 5. App User
|
||||||
|
|
||||||
|
Buat user khusus untuk menjalankan service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser --system --group --home /opt/qris-soundbox qrisapp
|
||||||
|
sudo install -d -o qrisapp -g qrisapp -m 750 /opt/qris-soundbox
|
||||||
|
sudo install -d -o root -g qrisapp -m 750 /etc/qris-soundbox
|
||||||
|
sudo install -d -o qrisapp -g qrisapp -m 750 /var/lib/qris-soundbox/exports
|
||||||
|
```
|
||||||
|
|
||||||
|
Untuk deploy via git/rsync, operator boleh masuk dengan user sudo biasa, lalu copy release ke `/opt/qris-soundbox` dan set ownership ke `qrisapp:qrisapp`.
|
||||||
|
|
||||||
|
## 6. PostgreSQL
|
||||||
|
|
||||||
|
Buat database dan user production.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql
|
||||||
|
```
|
||||||
|
|
||||||
|
Di prompt `psql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE USER qris_app WITH PASSWORD '5174e2c2fb3f8424806d1e5a4ca873a3dd33aace06a7b99c49f1bed9d1f42c4a';
|
||||||
|
CREATE DATABASE qris_soundbox_platform OWNER qris_app;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE qris_soundbox_platform TO qris_app;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Deploy Code
|
||||||
|
|
||||||
|
Contoh deploy awal via git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u qrisapp git clone <repo-url> /opt/qris-soundbox
|
||||||
|
cd /opt/qris-soundbox
|
||||||
|
sudo -u qrisapp npm ci
|
||||||
|
sudo -u qrisapp npm run typecheck
|
||||||
|
sudo -u qrisapp npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika deploy dari artifact, extract/copy artifact ke `/opt/qris-soundbox`, lalu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R qrisapp:qrisapp /opt/qris-soundbox
|
||||||
|
cd /opt/qris-soundbox
|
||||||
|
sudo -u qrisapp npm ci
|
||||||
|
sudo -u qrisapp npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Catatan: command `npm run typecheck`, `npm run db:migrate`, dan sebagian smoke script membutuhkan dev dependency. Untuk server staging/pilot awal, gunakan `npm ci` penuh agar semua command operasional tersedia. Service production tetap menjalankan hasil build lewat `npm run start:dist`.
|
||||||
|
|
||||||
|
## 8. Environment
|
||||||
|
|
||||||
|
Buat env production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/qris-soundbox/qris-soundbox.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Template awal:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
TRUST_PROXY=true
|
||||||
|
JSON_BODY_LIMIT=1mb
|
||||||
|
LOG_FORMAT=json
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
ADMIN_AUTH_ALLOW_LEGACY_TOKEN=false
|
||||||
|
ADMIN_DEV_LOGIN_ENABLED=false
|
||||||
|
ADMIN_SESSION_SECRET=CHANGE_ME_LONG_RANDOM_ADMIN_SESSION_SECRET
|
||||||
|
ADMIN_SESSION_TTL_SECONDS=28800
|
||||||
|
|
||||||
|
MERCHANT_AUTH_ALLOW_LEGACY_TOKEN=false
|
||||||
|
MERCHANT_DEV_LOGIN_ENABLED=false
|
||||||
|
MERCHANT_SESSION_SECRET=CHANGE_ME_LONG_RANDOM_MERCHANT_SESSION_SECRET
|
||||||
|
MERCHANT_SESSION_TTL_SECONDS=28800
|
||||||
|
|
||||||
|
DEVICE_AUTH_ALLOW_LEGACY_TOKEN=false
|
||||||
|
TRACE_HEADER=x-request-id
|
||||||
|
IDEMPOTENCY_TTL_MS=300000
|
||||||
|
INTEGRATION_WEBHOOK_SECRET=CHANGE_ME_LONG_RANDOM_WEBHOOK_SECRET
|
||||||
|
|
||||||
|
MQTT_PUBLISH_MODE=broker
|
||||||
|
MQTT_BROKER_URL=mqtts://broker.bizone.id:8883
|
||||||
|
MQTT_USERNAME=qris-backend
|
||||||
|
MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD
|
||||||
|
MQTT_CLIENT_ID=qris-platform-backend-prod
|
||||||
|
MQTT_CONNECT_TIMEOUT_MS=5000
|
||||||
|
MQTT_SUBSCRIBE_ENABLED=true
|
||||||
|
MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/#
|
||||||
|
MQTT_PUBLISH_FORCE_FAIL_ALL=false
|
||||||
|
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS=
|
||||||
|
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000
|
||||||
|
|
||||||
|
QF100_MQTT_BROKER_HOST=broker.bizone.id
|
||||||
|
QF100_MQTT_BROKER_PORT=8883
|
||||||
|
QF100_MQTT_USERNAME=qris-backend
|
||||||
|
QF100_MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD
|
||||||
|
QF100_MQTT_KEEP_ALIVE_SECONDS=60
|
||||||
|
|
||||||
|
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=/var/lib/qris-soundbox/exports
|
||||||
|
EXPORT_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
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=true
|
||||||
|
|
||||||
|
PGHOST=127.0.0.1
|
||||||
|
PGPORT=5432
|
||||||
|
PGUSER=qris_app
|
||||||
|
PGPASSWORD=CHANGE_ME_STRONG_DB_PASSWORD
|
||||||
|
PGDATABASE=qris_soundbox_platform
|
||||||
|
```
|
||||||
|
|
||||||
|
Lock permission:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown root:qrisapp /etc/qris-soundbox/qris-soundbox.env
|
||||||
|
sudo chmod 640 /etc/qris-soundbox/qris-soundbox.env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Database Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/qris-soundbox
|
||||||
|
sudo -u qrisapp env $(sudo cat /etc/qris-soundbox/qris-soundbox.env | xargs) npm run db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika env berisi karakter spesial yang membuat `xargs` bermasalah, gunakan systemd service sementara atau jalankan lewat shell yang melakukan `set -a; source ...; set +a`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/qris-soundbox
|
||||||
|
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run db:migrate'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Systemd Service
|
||||||
|
|
||||||
|
Buat service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/systemd/system/qris-soundbox.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Isi:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=QRIS Soundbox Platform
|
||||||
|
After=network-online.target postgresql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=qrisapp
|
||||||
|
Group=qrisapp
|
||||||
|
WorkingDirectory=/opt/qris-soundbox
|
||||||
|
EnvironmentFile=/etc/qris-soundbox/qris-soundbox.env
|
||||||
|
ExecStart=/usr/bin/npm run start:dist
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ReadWritePaths=/opt/qris-soundbox /var/lib/qris-soundbox
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Start service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable qris-soundbox
|
||||||
|
sudo systemctl start qris-soundbox
|
||||||
|
sudo systemctl status qris-soundbox --no-pager
|
||||||
|
journalctl -u qris-soundbox -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Nginx
|
||||||
|
|
||||||
|
Buat config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nano /etc/nginx/sites-available/sms.bizone.id
|
||||||
|
```
|
||||||
|
|
||||||
|
Isi:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name sms.bizone.id;
|
||||||
|
|
||||||
|
client_max_body_size 1m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/sms.bizone.id /etc/nginx/sites-enabled/sms.bizone.id
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. TLS Certificate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx -d sms.bizone.id
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Create Production Users
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/qris-soundbox
|
||||||
|
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run admin:create-user -- --email <email> --name <name> --role admin --password <strong-password>'
|
||||||
|
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run merchant:create-user -- --merchant <merchant-id-or-code> --email <email> --name <name> --role owner --password <strong-password>'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 14. Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS https://sms.bizone.id/health
|
||||||
|
curl -fsS https://sms.bizone.id/health/deep
|
||||||
|
curl -I https://sms.bizone.id/ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Run production preflight from server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/qris-soundbox
|
||||||
|
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run deploy:check-env'
|
||||||
|
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run smoke:mqtt-real'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run full smoke only on staging/controlled environment because it creates and cleans test data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run smoke:e2e'
|
||||||
|
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run ui:qa'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 15. QF100 Notes
|
||||||
|
|
||||||
|
For QF100 devices, app config server URL should point to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://sms.bizone.id/speaker/dev-config
|
||||||
|
```
|
||||||
|
|
||||||
|
The config response will point the device to MQTT broker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
broker.bizone.id:8883
|
||||||
|
```
|
||||||
|
|
||||||
|
If the firmware still has MQTT TLS disabled, either patch firmware TLS or prepare a restricted non-TLS pilot listener. Do not expose unrestricted non-TLS MQTT to the internet.
|
||||||
|
|
||||||
|
## 16. Routine Ops
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status qris-soundbox --no-pager
|
||||||
|
journalctl -u qris-soundbox -n 200 --no-pager
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/qris-soundbox
|
||||||
|
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run backup:production -- --out /var/backups/qris --include-mosquitto'
|
||||||
|
```
|
||||||
@ -829,7 +829,7 @@ Log keputusan arsitektur dan implementasi yang harus dijadikan acuan eksekusi.
|
|||||||
## D-062 — Real MQTT Broker Smoke Validation
|
## D-062 — Real MQTT Broker Smoke Validation
|
||||||
- Tanggal: 2026-05-29
|
- Tanggal: 2026-05-29
|
||||||
- Keputusan:
|
- Keputusan:
|
||||||
- `npm run smoke:mqtt-real` dijalankan terhadap broker `mqtts://mqtt.iptek.co:8883`.
|
- `npm run smoke:mqtt-real` dijalankan terhadap broker `mqtts://broker.bizone.id:8883`.
|
||||||
- Test memverifikasi broker connect, subscribe `devices/+/downlink/#`, payment success downlink, config push, dan dynamic QR response.
|
- Test memverifikasi broker connect, subscribe `devices/+/downlink/#`, payment success downlink, config push, dan dynamic QR response.
|
||||||
- Alasan:
|
- Alasan:
|
||||||
- MQTT broker production-like harus divalidasi terpisah dari simulator sebelum pilot device real.
|
- MQTT broker production-like harus divalidasi terpisah dari simulator sebelum pilot device real.
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## Production Preflight
|
## Production Preflight
|
||||||
|
|
||||||
|
Debian 12 app server setup untuk domain `sms.bizone.id` tersedia di `DEBIAN12_APP_SERVER_SETUP.md`.
|
||||||
|
|
||||||
Run this before deploying a production candidate:
|
Run this before deploying a production candidate:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -14,6 +14,7 @@ Paket ini berisi blueprint final v1 untuk platform merchant aggregator QRIS + so
|
|||||||
- 09-screen-inventory.md
|
- 09-screen-inventory.md
|
||||||
- 10-design-blueprint.md
|
- 10-design-blueprint.md
|
||||||
- 11-low-fi-wireframes.md
|
- 11-low-fi-wireframes.md
|
||||||
|
- DEBIAN12_APP_SERVER_SETUP.md
|
||||||
- 18-mqtt-broker-mosquitto-debian13.md
|
- 18-mqtt-broker-mosquitto-debian13.md
|
||||||
|
|
||||||
## Tujuan
|
## Tujuan
|
||||||
@ -185,7 +186,7 @@ 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.
|
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.
|
Smoke broker real terakhir lulus terhadap `mqtts://broker.bizone.id:8883`: broker connect ok, subscribe `devices/+/downlink/#`, menerima payment success, config push, dynamic QR response, dan cleanup data smoke.
|
||||||
|
|
||||||
### Async export worker
|
### Async export worker
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
"load:test:staging": "node scripts/run-staging-load-report.mjs",
|
"load:test:staging": "node scripts/run-staging-load-report.mjs",
|
||||||
"smoke:flow": "node scripts/smoke.mjs",
|
"smoke:flow": "node scripts/smoke.mjs",
|
||||||
"smoke:mqtt-real": "PORT=3115 MQTT_PUBLISH_MODE=broker node scripts/smoke-mqtt-real.mjs",
|
"smoke:mqtt-real": "PORT=3115 MQTT_PUBLISH_MODE=broker node scripts/smoke-mqtt-real.mjs",
|
||||||
|
"smoke:qf100": "node scripts/smoke-qf100-adapter.mjs",
|
||||||
"deploy:check-env": "node scripts/check-production-env.mjs",
|
"deploy:check-env": "node scripts/check-production-env.mjs",
|
||||||
"backup:production": "node scripts/backup-production.mjs",
|
"backup:production": "node scripts/backup-production.mjs",
|
||||||
"restore:plan": "node scripts/restore-plan.mjs",
|
"restore:plan": "node scripts/restore-plan.mjs",
|
||||||
|
|||||||
269
scripts/smoke-qf100-adapter.mjs
Normal file
269
scripts/smoke-qf100-adapter.mjs
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE_URL || `http://127.0.0.1:${process.env.PORT || "3000"}`;
|
||||||
|
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 STATIC_SN = process.env.QF100_STATIC_SN || `QF100-STATIC-${Date.now()}`;
|
||||||
|
const DYNAMIC_SN = process.env.QF100_DYNAMIC_SN || `QF100-DYNAMIC-${Date.now()}`;
|
||||||
|
|
||||||
|
function short(data) {
|
||||||
|
const text = typeof data === "string" ? data : JSON.stringify(data || {});
|
||||||
|
return text.length > 220 ? `${text.slice(0, 220)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBundle({ ts, suffix, serialNumber, terminalMode, capability }) {
|
||||||
|
const merchant = await reqAdmin("/admin/merchants", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
legal_name: `QF100 Smoke Merchant ${suffix} ${ts}`,
|
||||||
|
brand_name: `QF100-${suffix}-${ts}`,
|
||||||
|
settlement_account_reference: `bank:qf100:${suffix}:${ts}`,
|
||||||
|
settlement_account_type: "merchant_bank_account",
|
||||||
|
payout_mode: "merchant_direct"
|
||||||
|
},
|
||||||
|
_label: `POST /admin/merchants ${suffix}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const outlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { name: `QF100 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: `QF100-${suffix}-TERM-${ts}`, qr_mode: terminalMode },
|
||||||
|
_label: `POST /admin/outlets/:id/terminals ${suffix}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const device = await reqAdmin("/admin/devices", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
device_code: `QF100-${suffix}-DEV-${ts}`,
|
||||||
|
serial_number: serialNumber,
|
||||||
|
vendor: "QF",
|
||||||
|
model: "QF100",
|
||||||
|
communication_mode: "mqtt",
|
||||||
|
status: "active",
|
||||||
|
capability_profile_json: {
|
||||||
|
mqtt_payload_profile: "qf100",
|
||||||
|
...(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 pullQf100Config(serialNumber, label) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
"dev-model": "QF100",
|
||||||
|
"item-number": "00",
|
||||||
|
"dev-sn": serialNumber,
|
||||||
|
"fw-version": "1.0.0",
|
||||||
|
"fw-build": "1",
|
||||||
|
"app-config-version": "1",
|
||||||
|
imei: `${label}-imei`,
|
||||||
|
imsi: `${label}-imsi`,
|
||||||
|
iccid: `${label}-iccid`
|
||||||
|
});
|
||||||
|
const config = await req(`/speaker/dev-config?${query.toString()}`, {
|
||||||
|
_label: `GET /speaker/dev-config ${label}`
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(config["error-code"] === 0, `${label} config error-code must be 0`);
|
||||||
|
assert(config.mqtt?.["broker-ip"], `${label} config must include mqtt.broker-ip`);
|
||||||
|
assert(config.mqtt?.["broker-port"], `${label} config must include mqtt.broker-port`);
|
||||||
|
assert(config.mqtt?.["subscribe-topic"]?.includes("/downlink/qf100"), `${label} must subscribe qf100 topic`);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForQf100PaymentMessage(deviceId) {
|
||||||
|
for (let i = 0; i < 20; i += 1) {
|
||||||
|
const data = await reqAdmin(`/admin/devices/${deviceId}/mqtt-messages?direction=downlink&message_type=payment_success&limit=10`, {
|
||||||
|
_label: `GET /admin/devices/:id/mqtt-messages attempt ${i + 1}`
|
||||||
|
});
|
||||||
|
const found = data.messages?.find((message) => message.topic === `devices/${deviceId}/downlink/qf100`);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
throw new Error("QF100 payment downlink message not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerStaticPayment({ bundle, ts }) {
|
||||||
|
const partnerReference = `QF100-PR-${ts}`;
|
||||||
|
await 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: 15000,
|
||||||
|
currency: "IDR",
|
||||||
|
qr_mode: "static",
|
||||||
|
initiation_mode: "static",
|
||||||
|
status: "initiated"
|
||||||
|
},
|
||||||
|
_label: "POST /admin/transactions static"
|
||||||
|
});
|
||||||
|
|
||||||
|
const callback = {
|
||||||
|
partner_reference: partnerReference,
|
||||||
|
partner_txn_id: `QF100-PTX-${ts}`,
|
||||||
|
amount: 15000,
|
||||||
|
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 static paid"
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = await waitForQf100PaymentMessage(bundle.device.id);
|
||||||
|
assert(message.payload_json?.header?.category === 1, "QF100 payment payload header.category must be 1");
|
||||||
|
assert(message.payload_json?.data?.["pay-amount"] === 15000, "QF100 payment payload pay-amount must match");
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerDynamicMqttQr({ bundle, ts }) {
|
||||||
|
const requestId = `QF100-DYN-${ts}`;
|
||||||
|
const response = await reqDevice("/device/mqtt/uplink/dynamic-qr/request", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
message_type: "dynamic_qr_request",
|
||||||
|
request_id: requestId,
|
||||||
|
device_id: bundle.device.id,
|
||||||
|
terminal_id: bundle.terminal.id,
|
||||||
|
amount: 27500,
|
||||||
|
currency: "IDR",
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
},
|
||||||
|
_label: "POST /device/mqtt/uplink/dynamic-qr/request"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(response.status === "success", "dynamic MQTT QR response must be success");
|
||||||
|
assert(response.qr_payload, "dynamic MQTT QR response must include qr_payload");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await req("/health", { _label: "GET /health" });
|
||||||
|
const ts = Date.now();
|
||||||
|
|
||||||
|
const staticBundle = await createBundle({
|
||||||
|
ts,
|
||||||
|
suffix: "STATIC",
|
||||||
|
serialNumber: STATIC_SN,
|
||||||
|
terminalMode: "static",
|
||||||
|
capability: {}
|
||||||
|
});
|
||||||
|
const dynamicBundle = await createBundle({
|
||||||
|
ts,
|
||||||
|
suffix: "DYNAMIC",
|
||||||
|
serialNumber: DYNAMIC_SN,
|
||||||
|
terminalMode: "dynamic_mqtt",
|
||||||
|
capability: {
|
||||||
|
dynamic_qr: {
|
||||||
|
mqtt: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const staticConfig = await pullQf100Config(STATIC_SN, "static");
|
||||||
|
const dynamicConfig = await pullQf100Config(DYNAMIC_SN, "dynamic");
|
||||||
|
assert(staticConfig.mqtt["client-id"] === staticBundle.device.id, "static client-id must match device id");
|
||||||
|
assert(dynamicConfig.mqtt["client-id"] === dynamicBundle.device.id, "dynamic client-id must match device id");
|
||||||
|
|
||||||
|
const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts });
|
||||||
|
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });
|
||||||
|
|
||||||
|
console.log("\nQF100 adapter smoke passed");
|
||||||
|
console.log(`static_sn=${STATIC_SN}`);
|
||||||
|
console.log(`static_device_id=${staticBundle.device.id}`);
|
||||||
|
console.log(`static_payment_topic=${staticPaymentMessage.topic}`);
|
||||||
|
console.log(`dynamic_sn=${DYNAMIC_SN}`);
|
||||||
|
console.log(`dynamic_device_id=${dynamicBundle.device.id}`);
|
||||||
|
console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -10,6 +10,7 @@ import adminRoutes from "./routes/admin";
|
|||||||
import integrationRoutes from "./routes/integrations";
|
import integrationRoutes from "./routes/integrations";
|
||||||
import deviceRoutes from "./routes/device";
|
import deviceRoutes from "./routes/device";
|
||||||
import merchantRoutes from "./routes/merchant";
|
import merchantRoutes from "./routes/merchant";
|
||||||
|
import speakerRoutes from "./routes/speaker";
|
||||||
import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator";
|
import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator";
|
||||||
import { startDynamicQrExpiryScheduler } from "./shared/services/dynamicQrExpiryScheduler";
|
import { startDynamicQrExpiryScheduler } from "./shared/services/dynamicQrExpiryScheduler";
|
||||||
import { startExportJobWorker } from "./shared/services/exportJobWorker";
|
import { startExportJobWorker } from "./shared/services/exportJobWorker";
|
||||||
@ -135,12 +136,14 @@ app.use("/admin", (req, res, next) => {
|
|||||||
return next();
|
return next();
|
||||||
});
|
});
|
||||||
app.use("/device", deviceLimiter);
|
app.use("/device", deviceLimiter);
|
||||||
|
app.use("/speaker", deviceLimiter);
|
||||||
app.use("/integrations", rateLimit({ name: "integrations", windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS, max: env.RATE_LIMIT_DEVICE_MAX }));
|
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("/admin", adminRoutes);
|
||||||
app.use("/merchant", merchantRoutes);
|
app.use("/merchant", merchantRoutes);
|
||||||
app.use("/integrations", integrationRoutes);
|
app.use("/integrations", integrationRoutes);
|
||||||
app.use("/device", deviceRoutes);
|
app.use("/device", deviceRoutes);
|
||||||
|
app.use("/speaker", speakerRoutes);
|
||||||
|
|
||||||
app.use((err: Error, _req: Request, res: Response, next: NextFunction) => {
|
app.use((err: Error, _req: Request, res: Response, next: NextFunction) => {
|
||||||
handleErrors(err, _req, res, next);
|
handleErrors(err, _req, res, next);
|
||||||
|
|||||||
@ -35,6 +35,11 @@ export const env = {
|
|||||||
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number(
|
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number(
|
||||||
process.env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000
|
process.env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000
|
||||||
),
|
),
|
||||||
|
QF100_MQTT_BROKER_HOST: process.env.QF100_MQTT_BROKER_HOST || "",
|
||||||
|
QF100_MQTT_BROKER_PORT: Number(process.env.QF100_MQTT_BROKER_PORT || 0),
|
||||||
|
QF100_MQTT_USERNAME: process.env.QF100_MQTT_USERNAME || "",
|
||||||
|
QF100_MQTT_PASSWORD: process.env.QF100_MQTT_PASSWORD || "",
|
||||||
|
QF100_MQTT_KEEP_ALIVE_SECONDS: Number(process.env.QF100_MQTT_KEEP_ALIVE_SECONDS || 60),
|
||||||
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true",
|
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_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),
|
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100),
|
||||||
|
|||||||
125
src/routes/speaker.ts
Normal file
125
src/routes/speaker.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from "express";
|
||||||
|
import { env } from "../config/env";
|
||||||
|
import { createDeviceHeartbeat } from "../shared/store/heartbeatStore";
|
||||||
|
import { getDeviceBySerialNumber, patchDevice } from "../shared/store/deviceStore";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
type Qf100ConfigRequest = {
|
||||||
|
"dev-model"?: string;
|
||||||
|
"item-number"?: string;
|
||||||
|
"dev-sn"?: string;
|
||||||
|
"fw-version"?: string;
|
||||||
|
"fw-build"?: number;
|
||||||
|
"app-config-version"?: number;
|
||||||
|
imei?: string;
|
||||||
|
imsi?: string;
|
||||||
|
iccid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseBrokerUrl() {
|
||||||
|
if (!env.MQTT_BROKER_URL) {
|
||||||
|
return { host: "", port: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(env.MQTT_BROKER_URL);
|
||||||
|
return {
|
||||||
|
host: parsed.hostname,
|
||||||
|
port: parsed.port ? Number(parsed.port) : parsed.protocol === "mqtts:" ? 8883 : 1883
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return { host: "", port: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestPayload(req: Request): Qf100ConfigRequest {
|
||||||
|
return {
|
||||||
|
...((req.query || {}) as Qf100ConfigRequest),
|
||||||
|
...((req.body || {}) as Qf100ConfigRequest)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function vendorError(res: Response, code: number, message: string) {
|
||||||
|
res.status(200).json({
|
||||||
|
"error-code": code,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/dev-config", async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const payload = getRequestPayload(req);
|
||||||
|
const serialNumber = String(payload["dev-sn"] || "").trim();
|
||||||
|
|
||||||
|
if (!serialNumber) {
|
||||||
|
return vendorError(res, 1001, "dev-sn is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = await getDeviceBySerialNumber(serialNumber);
|
||||||
|
if (!device || device.status !== "active") {
|
||||||
|
return vendorError(res, 1002, "device not registered or inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const firmwareVersion = payload["fw-version"] ? String(payload["fw-version"]) : device.firmware_version;
|
||||||
|
await patchDevice(device.id, {
|
||||||
|
vendor: device.vendor || "QF",
|
||||||
|
model: payload["dev-model"] ? String(payload["dev-model"]) : device.model || "QF100",
|
||||||
|
communication_mode: "mqtt",
|
||||||
|
last_seen_at: now.toISOString(),
|
||||||
|
firmware_version: firmwareVersion
|
||||||
|
});
|
||||||
|
|
||||||
|
await createDeviceHeartbeat({
|
||||||
|
device_id: device.id,
|
||||||
|
timestamp: now.toISOString(),
|
||||||
|
firmware_version: firmwareVersion,
|
||||||
|
state: "config_pull",
|
||||||
|
payload_json: {
|
||||||
|
source: "qf100_dev_config",
|
||||||
|
dev_model: payload["dev-model"],
|
||||||
|
item_number: payload["item-number"],
|
||||||
|
dev_sn: serialNumber,
|
||||||
|
fw_build: payload["fw-build"],
|
||||||
|
app_config_version: payload["app-config-version"],
|
||||||
|
imei: payload.imei,
|
||||||
|
imsi: payload.imsi,
|
||||||
|
iccid: payload.iccid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const brokerFromUrl = parseBrokerUrl();
|
||||||
|
const brokerHost = env.QF100_MQTT_BROKER_HOST || brokerFromUrl.host;
|
||||||
|
const brokerPort = env.QF100_MQTT_BROKER_PORT || brokerFromUrl.port;
|
||||||
|
const username = env.QF100_MQTT_USERNAME || device.mqtt_username || device.id;
|
||||||
|
const password = env.QF100_MQTT_PASSWORD || env.MQTT_PASSWORD;
|
||||||
|
|
||||||
|
if (!brokerHost || !brokerPort) {
|
||||||
|
return vendorError(res, 1003, "mqtt broker is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
"error-code": 0,
|
||||||
|
"bind-state": 1,
|
||||||
|
"app-config-version": 1,
|
||||||
|
"time-stamp": Math.floor(now.getTime() / 1000),
|
||||||
|
"date-time": now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14),
|
||||||
|
mqtt: {
|
||||||
|
"broker-ip": brokerHost,
|
||||||
|
"broker-port": brokerPort,
|
||||||
|
"client-id": device.id,
|
||||||
|
"user-name": username,
|
||||||
|
password,
|
||||||
|
"subscribe-topic": `devices/${device.id}/downlink/qf100`,
|
||||||
|
"publish-topic": `devices/${device.id}/uplink/qf100`,
|
||||||
|
"keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS,
|
||||||
|
"cert-update": 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -159,6 +159,7 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
updated_at TIMESTAMPTZ NOT NULL
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_serial_number ON devices (serial_number);
|
||||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS mqtt_username TEXT;
|
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_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_status TEXT NOT NULL DEFAULT 'not_issued';
|
||||||
|
|||||||
@ -10,8 +10,10 @@ import {
|
|||||||
updateNotification
|
updateNotification
|
||||||
} from "../store/notificationStore";
|
} from "../store/notificationStore";
|
||||||
import { getMerchantById } from "../store/merchantStore";
|
import { getMerchantById } from "../store/merchantStore";
|
||||||
|
import { getDeviceById, type DeviceEntity } from "../store/deviceStore";
|
||||||
|
import { createMqttMessage } from "../store/mqttMessageStore";
|
||||||
import { getTransactionById, listTransactions, toTransactionPayload, TransactionEntity } from "../store/transactionStore";
|
import { getTransactionById, listTransactions, toTransactionPayload, TransactionEntity } from "../store/transactionStore";
|
||||||
import { buildPaymentSuccessPayload, publishPaymentSuccess, MqttPublishResult } from "../services/mqttPublisher";
|
import { buildPaymentSuccessPayload, publishPaymentSuccessForProtocol, MqttPublishResult } from "../services/mqttPublisher";
|
||||||
import type { TransactionPaidEvent, TransactionPaidPayload } from "../events/transactionEvents";
|
import type { TransactionPaidEvent, TransactionPaidPayload } from "../events/transactionEvents";
|
||||||
import { subscribeTransactionPaid } from "../events/transactionEvents";
|
import { subscribeTransactionPaid } from "../events/transactionEvents";
|
||||||
import { env } from "../../config/env";
|
import { env } from "../../config/env";
|
||||||
@ -81,6 +83,20 @@ function makeNextRetryDate(retryCount: number) {
|
|||||||
return new Date(Date.now() + intervalMs).toISOString();
|
return new Date(Date.now() + intervalMs).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePaymentProtocol(device: DeviceEntity | null): "platform_v1" | "qf100" {
|
||||||
|
const profile = device?.capability_profile_json || {};
|
||||||
|
const configuredProfile = String(
|
||||||
|
profile.mqtt_payload_profile || profile.protocol_profile || profile.vendor_protocol || ""
|
||||||
|
).toLowerCase();
|
||||||
|
const model = String(device?.model || "").toLowerCase();
|
||||||
|
|
||||||
|
if (configuredProfile === "qf100" || model.includes("qf100")) {
|
||||||
|
return "qf100";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "platform_v1";
|
||||||
|
}
|
||||||
|
|
||||||
async function getNotificationMerchantName(merchantId: string): Promise<string> {
|
async function getNotificationMerchantName(merchantId: string): Promise<string> {
|
||||||
const merchant = await getMerchantById(merchantId);
|
const merchant = await getMerchantById(merchantId);
|
||||||
return merchant?.brand_name || merchant?.legal_name || merchantId;
|
return merchant?.brand_name || merchant?.legal_name || merchantId;
|
||||||
@ -107,7 +123,7 @@ function mapMqttFailureState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markNotificationSent(notification: NotificationEntity, publishResult: MqttPublishResult) {
|
async function markNotificationSent(notification: NotificationEntity, publishResult: MqttPublishResult<unknown>) {
|
||||||
await updateNotification(notification.id, {
|
await updateNotification(notification.id, {
|
||||||
delivery_status: "sent",
|
delivery_status: "sent",
|
||||||
retry_count: notification.retry_count,
|
retry_count: notification.retry_count,
|
||||||
@ -116,7 +132,7 @@ async function markNotificationSent(notification: NotificationEntity, publishRes
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markNotificationFailed(notification: NotificationEntity, publishResult: MqttPublishResult) {
|
async function markNotificationFailed(notification: NotificationEntity, publishResult: MqttPublishResult<unknown>) {
|
||||||
const next = mapMqttFailureState(notification.retry_count, publishResult.reason);
|
const next = mapMqttFailureState(notification.retry_count, publishResult.reason);
|
||||||
await updateNotification(notification.id, {
|
await updateNotification(notification.id, {
|
||||||
delivery_status: next.status,
|
delivery_status: next.status,
|
||||||
@ -209,7 +225,18 @@ async function publishNotificationNow(notification: NotificationEntity, eventPay
|
|||||||
event_id: notification.event_id
|
event_id: notification.event_id
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await publishPaymentSuccess(mqttPayload);
|
const device = notification.device_id ? await getDeviceById(notification.device_id) : null;
|
||||||
|
const result = await publishPaymentSuccessForProtocol(mqttPayload, resolvePaymentProtocol(device));
|
||||||
|
await createMqttMessage({
|
||||||
|
direction: "downlink",
|
||||||
|
device_id: notification.device_id || String(effectivePayload.device_id || ""),
|
||||||
|
topic: result.topic,
|
||||||
|
message_type: "payment_success",
|
||||||
|
correlation_id: notification.event_id,
|
||||||
|
payload_json: result.payload as Record<string, unknown>,
|
||||||
|
publish_status: result.ok ? "sent" : "failed",
|
||||||
|
reason: result.reason
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
await markNotificationFailed(notification, result);
|
await markNotificationFailed(notification, result);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -16,6 +16,17 @@ type PaymentSuccessPayload = {
|
|||||||
display_text: string;
|
display_text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PaymentSuccessProtocol = "platform_v1" | "qf100";
|
||||||
|
|
||||||
|
type Qf100PaymentSuccessPayload = {
|
||||||
|
header: {
|
||||||
|
category: 1;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
"pay-amount": number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type DynamicQrResponsePayload = {
|
type DynamicQrResponsePayload = {
|
||||||
message_type: "dynamic_qr_response";
|
message_type: "dynamic_qr_response";
|
||||||
correlation_id: string;
|
correlation_id: string;
|
||||||
@ -179,6 +190,10 @@ export function makePaymentSuccessTopic(deviceId: string) {
|
|||||||
return `devices/${deviceId}/downlink/payment/success`;
|
return `devices/${deviceId}/downlink/payment/success`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function makeQf100DownlinkTopic(deviceId: string) {
|
||||||
|
return `devices/${deviceId}/downlink/qf100`;
|
||||||
|
}
|
||||||
|
|
||||||
export function makeDynamicQrResponseTopic(deviceId: string) {
|
export function makeDynamicQrResponseTopic(deviceId: string) {
|
||||||
return `devices/${deviceId}/downlink/dynamic-qr/response`;
|
return `devices/${deviceId}/downlink/dynamic-qr/response`;
|
||||||
}
|
}
|
||||||
@ -241,6 +256,26 @@ export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Pro
|
|||||||
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
|
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function publishPaymentSuccessForProtocol(
|
||||||
|
payload: PaymentSuccessPayload,
|
||||||
|
protocol: PaymentSuccessProtocol
|
||||||
|
): Promise<MqttPublishResult<PaymentSuccessPayload | Qf100PaymentSuccessPayload>> {
|
||||||
|
if (protocol !== "qf100") {
|
||||||
|
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const qf100Payload: Qf100PaymentSuccessPayload = {
|
||||||
|
header: {
|
||||||
|
category: 1
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
"pay-amount": payload.amount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return publishMqttPayload(payload.device_id, makeQf100DownlinkTopic(payload.device_id), qf100Payload);
|
||||||
|
}
|
||||||
|
|
||||||
export async function publishDynamicQrResponse(deviceId: string, payload: DynamicQrResponsePayload) {
|
export async function publishDynamicQrResponse(deviceId: string, payload: DynamicQrResponsePayload) {
|
||||||
return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload);
|
return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,6 +137,13 @@ export async function getDeviceById(id: string): Promise<DeviceEntity | null> {
|
|||||||
return rows[0] ? mapDevice(rows[0]) : null;
|
return rows[0] ? mapDevice(rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDeviceBySerialNumber(serialNumber: string): Promise<DeviceEntity | null> {
|
||||||
|
const { rows } = await getPool().query("SELECT * FROM devices WHERE serial_number = $1 ORDER BY created_at DESC LIMIT 1", [
|
||||||
|
serialNumber
|
||||||
|
]);
|
||||||
|
return rows[0] ? mapDevice(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Promise<DeviceEntity> {
|
export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Promise<DeviceEntity> {
|
||||||
const existing = await getDeviceById(id);
|
const existing = await getDeviceById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|||||||
Reference in New Issue
Block a user