commit a152c99cce5dcee9fc78addb8b7ea95eaeda2a1a Author: wirabasalamah Date: Mon May 25 08:22:12 2026 +0700 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0b19fdd --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +PORT=3000 +ADMIN_TOKEN=admin-dev-token +DEVICE_TOKEN=device-dev-token +TRACE_HEADER=x-request-id +IDEMPOTENCY_TTL_MS=300000 +INTEGRATION_WEBHOOK_SECRET=dev-callback-secret +MQTT_PUBLISH_FORCE_FAIL_ALL=false +MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS= +MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000 + +# PostgreSQL settings +PGHOST=127.0.0.1 +PGPORT=5432 +PGUSER=postgres +PGPASSWORD=postgres +PGDATABASE=qris_soundbox_platform +# Optional alternative: +# DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/qris_soundbox_platform diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba58a91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +npm-debug.log* +.DS_Store +.env diff --git a/01-executive-blueprint.md b/01-executive-blueprint.md new file mode 100644 index 0000000..247c15d --- /dev/null +++ b/01-executive-blueprint.md @@ -0,0 +1,79 @@ +# Executive Blueprint - QRIS Soundbox Platform v1 + +## 1. Ringkasan +Platform ini dirancang sebagai **Merchant Aggregator QRIS + Payment Orchestration + Soundbox/TMS Platform**. + +Sistem menangani: +- onboarding merchant +- koneksi ke bank, issuer, acquirer, atau partner QRIS +- konfigurasi SNAP BI / QRIS integration +- pemrosesan transaksi QRIS statis dan dinamis +- ledger internal dan perhitungan hak merchant +- settlement/disbursement ke merchant +- manajemen device soundbox lintas vendor +- notifikasi pembayaran realtime ke device +- dashboard admin, ops, finance, dan merchant + +## 2. Objective +Tujuan utama sistem: +1. mendukung banyak jenis soundbox tanpa vendor lock-in +2. memusatkan kontrol payment dan orkestrasi payout di backend perusahaan, sementara pencairan diproses sesuai rekening/flow payout milik merchant (non-tersentral pada MVP) +3. memastikan transaksi, ledger, dan settlement dapat diaudit +4. memberi notifikasi realtime ke merchant melalui soundbox +5. menyediakan fondasi yang bisa dicicil implementasinya per fase + +## 3. Prinsip Kunci +- backend adalah pusat logika sistem +- device adalah channel, bukan pusat source of truth +- callback/webhook payment partner adalah sumber status transaksi eksternal +- ledger internal adalah sumber kebenaran finansial internal (pending settlement, fee, dan entitlement) +- settlement/payout dipisah dari payment event dan dapat dikoordinasikan via partner payout merchant +- device support berbasis capability, bukan hardcode per vendor +- TMS adalah domain inti, bukan tambahan +- semua aksi kritikal harus memiliki audit trail + +## 4. Tipe Device yang Didukung +### 4.1 Soundbox Static +- QR merchant statis +- transaksi masuk via callback partner +- backend melakukan mapping ke merchant/outlet/terminal/device +- notifikasi sukses dikirim ke device via MQTT + +### 4.2 Soundbox Dynamic MQTT-only +- device membuat request QR dynamic via MQTT +- backend memproses request dan mengirim hasil via MQTT +- payment success tetap berasal dari callback partner +- notifikasi sukses ke device via MQTT + +### 4.3 Soundbox Dynamic API-direct +- device dapat hit API backend langsung +- request-response create QR dilakukan via API +- payment success tetap berasal dari callback partner +- notifikasi sukses ke device via MQTT + +## 5. Domain Inti +- Merchant & Onboarding +- Payment Integration +- Transaction +- Ledger & Settlement +- Device/TMS +- Notification +- Operations & Governance + +## 6. Keputusan Arsitektur Final +1. one unified backend +2. support multi-protocol ingress untuk device +3. use capability-based routing untuk flow device +4. use QRIS/payment callback as external payment status trigger +5. use internal ledger for merchant payable and settlement +6. support admin portal dan merchant portal terpisah +7. design all critical integration points with idempotency and auditability + +## 7. Hasil yang Diharapkan dari Paket Ini +Dokumen ini menjadi fondasi untuk: +- diskusi arsitektur +- desain UI/UX +- breakdown sprint engineering +- definisi database awal +- definisi API dan kontrak MQTT +- roadmap delivery bertahap diff --git a/02-system-architecture.md b/02-system-architecture.md new file mode 100644 index 0000000..da6eb79 --- /dev/null +++ b/02-system-architecture.md @@ -0,0 +1,126 @@ +# System Architecture - QRIS Soundbox Platform v1 + +## 1. Posisi Sistem +Platform diposisikan sebagai: +- merchant aggregator +- payment orchestrator +- device orchestration platform untuk soundbox lintas vendor + +## 2. Context Eksternal +### Pihak eksternal +- Merchant +- Customer payer +- Bank / Issuer / Acquirer / Aggregator QRIS +- SNAP BI / QRIS integration provider +- Device vendor / soundbox devices + +## 3. Layer Arsitektur + +### Layer 1. Experience Layer +- Admin Web Portal +- Merchant Web Portal +- Ops / Finance Dashboard +- Device UI / Soundbox UI + +### Layer 2. Access Layer +- API Gateway +- MQTT Broker +- Webhook Receiver +- Auth Gateway + +### Layer 3. Device Abstraction Layer +- MQTT Adapter +- API Adapter +- Capability Resolver +- Command Router +- Notification Router +- Payload Normalizer + +### Layer 4. Core Business Layer +- Merchant Service +- Outlet Service +- Terminal Service +- Device/TMS Service +- QRIS Service +- Transaction Service +- Ledger Service +- Settlement Service +- Notification Service +- Reconciliation Service +- Audit Service + +### Layer 5. Integration Layer +- Bank Connector +- QRIS Partner Connector +- SNAP BI Connector +- Payout / Disbursement Connector + +### Layer 6. Data Layer +- Merchant Database +- Transaction Database +- Ledger Database +- Device Database +- Audit Log Store +- Queue / Event Store +- Configuration Store + +## 4. Arsitektur Logis +```text +Merchant/Admin/Finance/Device + | + v +Access Layer (API Gateway / MQTT Broker / Webhook Receiver) + | + v +Device Abstraction + Auth + Capability Resolver + | + v +Core Business Services + | + +--> Payment/QRIS Integrations + +--> Ledger & Settlement + +--> TMS & Notification + | + v +Databases / Event Store / Audit Logs +``` + +## 5. Prinsip Routing Device +### Static device +- tidak perlu create QR request +- device mainly menerima payment notification + +### Dynamic MQTT-only device +- uplink via MQTT +- downlink via MQTT +- async success via MQTT + +### Dynamic API-direct device +- create QR via API +- async success via MQTT +- ops/config dapat via API atau MQTT tergantung capability + +## 6. Prinsip Integrasi Payment +- semua request ke bank/partner dilakukan oleh backend kita +- semua callback payment diterima oleh backend kita +- device tidak pernah menjadi source of truth transaksi +- status transaksi final berasal dari backend setelah verifikasi callback atau reconciliation + +## 7. Prinsip Finansial +- dana customer dihimpun sesuai flow partner/integrasi payout merchant (tanpa vault settlement pusat di awal MVP) +- ledger internal mencatat hak merchant, fee, penyesuaian, dan status reconciliation payout +- settlement/disbursement dieksekusi sebagai domain terpisah dari transaction event + +## 8. Prinsip Operasional +- semua perubahan entity penting harus diaudit +- onboarding merchant harus punya status workflow +- device harus punya heartbeat, last seen, config version, dan binding history +- delivery notification harus dapat di-track dan di-retry + +## 9. NFR Utama +- idempotency untuk create QR, callback, payout, notification +- observability untuk payment, device, settlement +- auditability untuk finance dan merchant changes +- multi-tenant isolation +- secure device auth +- HA untuk broker, callback receiver, dan transaction services diff --git a/03-domain-modules.md b/03-domain-modules.md new file mode 100644 index 0000000..22ff0a8 --- /dev/null +++ b/03-domain-modules.md @@ -0,0 +1,132 @@ +# Domain Modules - QRIS Soundbox Platform v1 + +## 1. Merchant & Onboarding Domain +### Tanggung jawab +- merchant registration +- KYC / KYB workflow +- document collection +- approval / rejection +- fee profile assignment +- settlement destination registration (bank/partner payout reference) +- outlet and terminal creation + +### Submodule +- Merchant Registry +- Onboarding Workflow +- Document Service +- Fee Profile Management +- Settlement Destination Management +- Outlet Management +- Terminal Management + +## 2. Payment Integration Domain +### Tanggung jawab +- partner QRIS integration +- bank integration +- SNAP BI configuration +- callback handling +- signature verification +- reconciliation feed import/export + +### Submodule +- Partner Credential Vault +- QRIS Connector +- SNAP BI Connector +- Callback Receiver +- Signature Validator +- Reconciliation Importer + +## 3. Transaction Domain +### Tanggung jawab +- create transaction +- static vs dynamic QR transaction lifecycle +- transaction status management +- timeout, expiry, and cancel flow +- idempotency control + +### Submodule +- Transaction Registry +- Transaction State Machine +- QR Generation Orchestrator +- Transaction Query Service +- Refund/Dispute Hook Interface + +## 4. Ledger & Settlement Domain +### Tanggung jawab +- mencatat efek finansial transaksi +- menghitung fee platform dan merchant payable +- membuat settlement batch +- menginisiasi payout request ke partner payout (bukan menampung dana di perusahaan) +- menangani payout failure +- support reconciliation + +### Submodule +- Ledger Entry Service +- Merchant Balance Calculator +- Fee Engine +- Settlement Batch Service +- Payout Execution Service +- Adjustment Service +- Reconciliation Service + +## 5. Device / TMS Domain +### Tanggung jawab +- device registration +- provisioning +- auth credential management +- merchant/outlet/terminal binding +- heartbeat and presence +- config management +- command delivery +- capability registry +- firmware and compatibility tracking + +### Submodule +- Device Registry +- Provisioning Service +- Device Binding Service +- Heartbeat Service +- Device Auth Service +- Capability Profile Service +- Config Management Service +- Command Center Service +- Firmware Tracking Service + +## 6. Notification Domain +### Tanggung jawab +- payment success notification +- audio/display payload preparation +- routing based on device capability +- retry and ack tracking +- duplicate suppression + +### Submodule +- Notification Orchestrator +- Payload Formatter +- Delivery Tracker +- Retry Worker +- Ack Processor + +## 7. Operations & Governance Domain +### Tanggung jawab +- admin portal actions +- audit logging +- alerting +- role-based access +- compliance visibility +- fraud / risk checks + +### Submodule +- Admin Management +- RBAC Service +- Audit Log Service +- Monitoring & Alerting +- Risk/Fraud Rule Service +- Incident Tracker + +## 8. Domain Boundary Notes +- Payment Integration tidak boleh menampung logic device +- Device/TMS tidak boleh menampung source of truth transaksi +- Ledger & Settlement tidak boleh bergantung pada notifikasi device +- Notification hanya boleh membaca transaction state final/eligible +- Admin UI harus memakai policy dan RBAC lintas domain diff --git a/04-device-flows.md b/04-device-flows.md new file mode 100644 index 0000000..26b35df --- /dev/null +++ b/04-device-flows.md @@ -0,0 +1,93 @@ +# Device Flows - QRIS Soundbox Platform v1 + +## 1. Soundbox Static Flow +### Karakteristik +- QR merchant bersifat statis +- customer input nominal di aplikasi bank/e-wallet +- device menerima notifikasi hasil transaksi + +### Sequence +1. customer scan QR statis merchant +2. customer input nominal dan submit pembayaran +3. partner/bank memproses pembayaran +4. callback payment masuk ke backend +5. backend verifikasi signature dan status +6. transaction state diupdate +7. ledger internal diupdate +8. backend lookup binding merchant -> outlet -> terminal -> device +9. notification event dibuat +10. MQTT push ke device +11. device memainkan audio dan/atau menampilkan nominal + +### Catatan +- challenge utama adalah mapping transaksi ke device yang tepat +- perlu binding yang presisi dan fallback jika device offline + +## 2. Soundbox Dynamic MQTT-only Flow +### Karakteristik +- device tidak bisa hit API langsung +- request create QR lewat MQTT +- response dan success notification lewat MQTT + +### Sequence +1. merchant input nominal di device +2. device publish request create QR ke topic uplink +3. backend MQTT adapter menerima request +4. device auth dan capability check dilakukan +5. backend validasi nominal, merchant binding, idempotency key +6. backend request QR dynamic ke partner QRIS +7. transaction dibuat dengan status pending/awaiting_payment +8. response QR payload dipublish ke topic device +9. device render QR di layar +10. customer scan dan bayar +11. callback payment masuk ke backend +12. backend verifikasi dan update transaction +13. ledger internal diupdate +14. notification success dipublish ke device +15. device memainkan audio sukses + +### Catatan +- wajib ada request_id / correlation_id +- retry device tidak boleh menimbulkan double create +- MQTT handler jangan jadi pusat logic, hanya ingress adapter + +## 3. Soundbox Dynamic API-direct Flow +### Karakteristik +- device dapat call API backend langsung +- create QR dilakukan secara sinkron via API +- success notification tetap realtime via MQTT + +### Sequence +1. merchant input nominal di device +2. device call API create dynamic QR +3. backend auth device dan validasi payload +4. backend check capability dan idempotency +5. backend request QR dynamic ke partner QRIS +6. transaction dibuat dengan status pending +7. response API berisi QR payload + expiry + transaction reference +8. device render QR +9. customer scan dan bayar +10. callback payment masuk ke backend +11. backend verifikasi dan update transaction +12. ledger internal diupdate +13. success notification dikirim via MQTT +14. device memainkan audio sukses + +### Catatan +- ini flow paling bersih untuk engineering +- request-response lebih natural, observability lebih baik + +## 4. Heartbeat Flow +1. device kirim heartbeat secara periodik +2. backend update last_seen_at +3. backend simpan health metrics yang tersedia +4. status device diturunkan menjadi online/stale/offline/degraded +5. ops dashboard menampilkan alert bila perlu + +## 5. Provisioning Flow +1. device didaftarkan di TMS +2. credential/token/certificate dibuat +3. device dibinding ke merchant/outlet/terminal +4. config profile ditetapkan +5. device mengambil config awal atau dikirimkan via channel yang tersedia +6. device aktif dan mulai heartbeat diff --git a/05-api-contract-draft.md b/05-api-contract-draft.md new file mode 100644 index 0000000..c762b8e --- /dev/null +++ b/05-api-contract-draft.md @@ -0,0 +1,194 @@ +# API Contract Draft - QRIS Soundbox Platform v1 + +## 1. Prinsip API +- gunakan HTTPS +- semua endpoint device memakai auth khusus device +- semua endpoint admin/merchant memakai RBAC +- endpoint create yang sensitif wajib support idempotency key +- response harus punya request_id / trace_id + +## 2. Admin / Merchant APIs + +### Merchant +- `POST /admin/merchants` +- `GET /admin/merchants` +- `GET /admin/merchants/{merchantId}` +- `PATCH /admin/merchants/{merchantId}` +- `POST /admin/merchants/{merchantId}/approve` +- `POST /admin/merchants/{merchantId}/reject` + +#### Create Merchant +- `POST /admin/merchants` + +Request body: +```json +{ + "legal_name": "Toko Indo", + "brand_name": "Toko Indo", + "settlement_account_reference": "bank:9876543210", + "settlement_account_type": "merchant_bank_account", + "payout_mode": "merchant_direct", + "fee_profile_id": "fee_basic" +} +``` + +`payout_mode`: +- `merchant_direct` (default): payout mengikuti rekening/referensi milik merchant. +- `manual`: payout dilakukan manual/offline oleh tim operasi. + +Response: +```json +{ + "data": { + "id": "m_123", + "merchant_code": "m_123abc", + "legal_name": "Toko Indo", + "brand_name": "Toko Indo", + "settlement_account_reference": "bank:9876543210", + "settlement_account_type": "merchant_bank_account", + "payout_mode": "merchant_direct" + }, + "request_id": "req_001", + "timestamp": "2026-05-24T00:00:00Z" +} +``` + +Catatan penting: +- Pada fase awal, settlement tidak ditarik ke rekening perusahaan. +- Pencairan merchant dilakukan via `payout_account_reference` milik merchant. +- Untuk merchant yang belum punya integrasi payout otomatis, gunakan `payout_mode: manual`. + +### Outlet +- `POST /admin/merchants/{merchantId}/outlets` +- `GET /admin/outlets` +- `GET /admin/outlets/{outletId}` +- `PATCH /admin/outlets/{outletId}` + +### Terminal +- `POST /admin/outlets/{outletId}/terminals` +- `GET /admin/terminals` +- `GET /admin/terminals/{terminalId}` +- `PATCH /admin/terminals/{terminalId}` + +### Device / TMS +- `POST /admin/devices` +- `GET /admin/devices` +- `GET /admin/devices/{deviceId}` +- `PATCH /admin/devices/{deviceId}` +- `POST /admin/devices/{deviceId}/bind` +- `POST /admin/devices/{deviceId}/unbind` +- `POST /admin/devices/{deviceId}/commands` +- `GET /admin/devices/{deviceId}/commands` +- `GET /admin/devices/{deviceId}/commands/{commandId}` +- `GET /admin/devices/{deviceId}/heartbeats` +- `GET /admin/devices/{deviceId}/notifications` +- `POST /admin/seed` + +### Transactions +- `GET /admin/transactions` +- `GET /admin/transactions/{transactionId}` +- `POST /admin/transactions/{transactionId}/retry-notification` + +### Settlements +- `GET /admin/settlements` +- `GET /admin/settlements/{settlementId}` +- `POST /admin/settlements/run` +- `POST /admin/settlements/{settlementId}/retry-payout` +- Catatan: endpoint ini dipakai untuk batch/reconciliation status; eksekusi payout dilakukan sesuai konfigurasi `payout_mode` merchant. + +## 3. Device APIs + +### Provisioning +- `POST /device/provision/activate` +- `POST /device/provision/refresh-credential` + +### Heartbeat +- `POST /device/heartbeat` + +Sample request: +```json +{ + "device_id": "sbx_001", + "timestamp": "2026-05-23T10:00:00Z", + "firmware_version": "1.0.3", + "network_strength": 78, + "battery_level": 92, + "state": "idle" +} +``` + +### Config +- `GET /device/config` +- `POST /device/config/ack` +- `POST /device/commands/ack` + +### Device command payload ack +Device mengirim: +```json +{ + "device_id": "sbx_001", + "command_id": "cmd_123", + "status": "delivered", + "reason": "ok", + "result_payload": { + "payment_result": "ok" + } +} +``` +`status` untuk ACK: `delivered` | `failed` | `timeout`. + +### Dynamic QR create +- `POST /device/transactions/dynamic-qr` + +Headers: +- `Authorization: Bearer ` +- `Idempotency-Key: ` + +Request: +```json +{ + "device_id": "sbx_001", + "terminal_id": "term_001", + "amount": 50000, + "currency": "IDR", + "request_id": "req_123" +} +``` + +Response: +```json +{ + "request_id": "req_123", + "transaction_id": "tx_123", + "qr_type": "dynamic", + "qr_payload": "000201010212...", + "expires_at": "2026-05-23T10:05:00Z", + "status": "awaiting_payment" +} +``` + +## 4. Merchant Portal APIs +- `GET /merchant/me` +- `GET /merchant/dashboard/summary` +- `GET /merchant/transactions` +- `GET /merchant/transactions/{transactionId}` +- `GET /merchant/settlements` +- `GET /merchant/settlements/{settlementId}` +- `GET /merchant/devices` +- `GET /merchant/outlets` + +## 5. Webhook Receiver +- `POST /integrations/qris/callback` +- `POST /integrations/bank/payout-callback` + +## 6. Catatan Error Model +Gunakan error code yang konsisten, misalnya: +- `DEVICE_UNAUTHORIZED` +- `DEVICE_NOT_BOUND` +- `DEVICE_CAPABILITY_NOT_SUPPORTED` +- `INVALID_AMOUNT` +- `DUPLICATE_REQUEST` +- `PARTNER_TIMEOUT` +- `TRANSACTION_NOT_FOUND` +- `SETTLEMENT_NOT_ELIGIBLE` +- `MERCHANT_PAYOUT_NOT_CONFIGURED` diff --git a/06-mqtt-contract-draft.md b/06-mqtt-contract-draft.md new file mode 100644 index 0000000..dd706ff --- /dev/null +++ b/06-mqtt-contract-draft.md @@ -0,0 +1,103 @@ +# MQTT Contract Draft - QRIS Soundbox Platform v1 + +## 1. Prinsip MQTT +- topic harus namespaced per device atau tenant +- device hanya boleh publish/subscribe ke topic yang diizinkan +- semua pesan request harus punya request_id +- semua pesan response harus punya correlation_id +- notifikasi harus punya event_id untuk dedup + +## 2. Topic Convention +### Uplink dari device +- `devices/{deviceId}/uplink/heartbeat` +- `devices/{deviceId}/uplink/dynamic-qr/request` +- `devices/{deviceId}/uplink/config/ack` +- `devices/{deviceId}/uplink/command/ack` + +### Downlink ke device +- `devices/{deviceId}/downlink/dynamic-qr/response` +- `devices/{deviceId}/downlink/payment/success` +- `devices/{deviceId}/downlink/config/push` +- `devices/{deviceId}/downlink/command` + +## 3. Heartbeat Payload +```json +{ + "message_type": "heartbeat", + "device_id": "sbx_001", + "timestamp": "2026-05-23T10:00:00Z", + "firmware_version": "1.0.3", + "state": "idle", + "network_strength": 78, + "battery_level": 92 +} +``` + +## 4. Dynamic QR Request Payload +```json +{ + "message_type": "dynamic_qr_request", + "request_id": "req_123", + "device_id": "sbx_001", + "terminal_id": "term_001", + "amount": 50000, + "currency": "IDR", + "created_at": "2026-05-23T10:00:00Z" +} +``` + +## 5. Dynamic QR Response Payload +```json +{ + "message_type": "dynamic_qr_response", + "correlation_id": "req_123", + "transaction_id": "tx_123", + "status": "success", + "qr_payload": "000201010212...", + "expires_at": "2026-05-23T10:05:00Z" +} +``` + +## 6. Payment Success Notification Payload +```json +{ + "message_type": "payment_success", + "event_id": "evt_123", + "transaction_id": "tx_123", + "merchant_name": "Toko Berkah", + "amount": 50000, + "currency": "IDR", + "paid_at": "2026-05-23T10:02:10Z", + "audio_text": "Pembayaran diterima lima puluh ribu rupiah", + "display_text": "Pembayaran diterima Rp50.000" +} +``` + +## 7. Config Push Payload +```json +{ + "message_type": "config_push", + "config_version": 3, + "settings": { + "volume": 80, + "language": "id-ID", + "heartbeat_interval_seconds": 60 + } +} +``` + +## 8. Command Payload +```json +{ + "message_type": "command", + "command_id": "cmd_123", + "command_name": "test_speaker", + "parameters": {} +} +``` + +## 9. QoS dan Delivery +- heartbeat dapat memakai QoS rendah sesuai kebutuhan +- payment success sebaiknya QoS lebih tinggi +- retained message jangan dipakai untuk notifikasi sukses transaksi +- ack command dan ack config harus dicatat diff --git a/07-database-schema-draft.md b/07-database-schema-draft.md new file mode 100644 index 0000000..65c3c64 --- /dev/null +++ b/07-database-schema-draft.md @@ -0,0 +1,222 @@ +# Database Schema Draft - QRIS Soundbox Platform v1 + +## 1. merchants +- id +- merchant_code +- legal_name +- brand_name +- status +- onboarding_status +- fee_profile_id +- settlement_account_reference +- settlement_account_type +- payout_mode +- created_at +- updated_at + +## 2. merchant_documents +- id +- merchant_id +- document_type +- document_url +- verification_status +- created_at + +## 3. outlets +- id +- merchant_id +- outlet_code +- name +- address +- status +- created_at +- updated_at + +## 4. terminals +- id +- outlet_id +- terminal_code +- qr_mode +- partner_reference +- status +- created_at +- updated_at + +## 5. devices +- id +- device_code +- serial_number +- vendor +- model +- firmware_version +- communication_mode +- capability_profile_json +- auth_method +- status +- last_seen_at +- created_at +- updated_at + +## 6. device_bindings +- id +- device_id +- merchant_id +- outlet_id +- terminal_id +- active_flag +- bound_at +- unbound_at + +## 7. device_heartbeats +- id +- device_id +- received_at +- network_strength +- battery_level +- firmware_version +- state +- payload_json + +## 8. device_configs +- id +- device_id +- config_version +- config_json +- active_flag +- created_at + +## 9. device_commands +- id +- device_id +- command_name +- command_payload_json +- command_status +- requested_by +- requested_at +- ack_at + +## 10. transactions +- id +- transaction_code +- merchant_id +- outlet_id +- terminal_id +- device_id +- qr_mode +- initiation_mode +- partner_reference +- amount +- currency +- status +- created_at +- paid_at +- expired_at +- updated_at + +## 11. transaction_events +- id +- transaction_id +- event_type +- source +- payload_json +- created_at + +## 12. notifications +- id +- transaction_id +- device_id +- delivery_channel +- payload_type +- delivery_status +- retry_count +- ack_status +- sent_at +- ack_at + +## 13. ledger_entries +- id +- merchant_id +- transaction_id +- entry_type +- amount +- direction +- balance_effect +- created_at + +## 14. settlements +- id +- merchant_id +- settlement_period_start +- settlement_period_end +- gross_amount +- fee_amount +- net_amount +- payout_status +- payout_reference +- created_at +- paid_at + +## 15. settlement_items +- id +- settlement_id +- transaction_id +- gross_amount +- fee_amount +- net_amount + +## 16. payout_attempts +- id +- settlement_id +- partner_reference +- amount +- status +- response_payload_json +- attempted_at + +## 17. reconciliation_records +- id +- reconciliation_type +- entity_type +- entity_id +- external_reference +- internal_amount +- external_amount +- status +- notes +- created_at + +## 18. audit_logs +- id +- actor_type +- actor_id +- action +- entity_type +- entity_id +- before_json +- after_json +- source_ip +- created_at + +## 19. users +- id +- name +- email +- password_hash +- role_id +- status +- created_at + +## 20. roles +- id +- name +- permissions_json +- created_at + +## 21. Index penting +- transactions(partner_reference) +- transactions(merchant_id, created_at) +- notifications(device_id, delivery_status) +- device_heartbeats(device_id, received_at) +- device_bindings(device_id, active_flag) +- settlements(merchant_id, payout_status) +- ledger_entries(merchant_id, created_at) diff --git a/08-implementation-roadmap.md b/08-implementation-roadmap.md new file mode 100644 index 0000000..2384125 --- /dev/null +++ b/08-implementation-roadmap.md @@ -0,0 +1,510 @@ +# Implementation Roadmap - QRIS Soundbox Platform v1 + +## 1. Prinsip Delivery +- kerjakan fondasi domain lebih dulu +- prioritaskan flow yang paling cepat memberi nilai bisnis +- buat modul yang bisa dipakai ulang untuk static, dynamic MQTT, dan dynamic API +- selesaikan admin visibility lebih awal untuk membantu operasi + +## 2. Fase 0 - Persiapan Pra-Pelaksanaan +### Tujuan +menyelaraskan fondasi teknis, governance, dan alur kerja supaya pembangunan fase berikutnya stabil dan konsisten + +### Deliverable Awal +- Tim dan role pelaksana disepakati (PO, Tech Lead, Backend, Infra, QA, Ops) +- Definisi source control workflow dan branching strategy +- CI/CD base pipeline siap (build, lint, security scan dasar) +- Lingkungan dev/test/staging dipastikan terpisah +- Standar kontrak siap pakai: + - API contract final v1 + - MQTT topic dan payload final v1 + - schema migration baseline +- Skema status, kode error, dan idempotency key standar disepakati +- Definisi tracing & logging baseline (request_id, trace_id, event_id, correlation_id) +- Definition of Done (DoD) tim sinkron untuk tiap fase + +### Setup Operasional +- Issue tracker dan sprint board aktif +- Template ticket siap (story, acceptance criteria, checklist testing) +- Test environment data seed untuk merchant, outlet, terminal, device baseline +- Device simulator untuk static + MQTT-only + API-direct +- Monitoring awal: +- uptime endpoint +- callback receiver readiness check +- MQ publish/subscribe smoke test + +### Risiko Persiapan +- Scope creep pada Fase 0 -> batasi sampai API/MQTT contract dan baseline infrastruktur +- Ketidaksinkronan data model -> gunakan satu schema owner untuk review harian +- Keterlambatan perangkat test -> sediakan mock/simulator lebih awal +- Akses keamanan belum stabil -> pastikan IAM/token policy baseline sebelum endpoint sensitive dibuka + +### DoD Fase 0 +- [ ] semua dokumen kontrak dan skema siap untuk implementasi +- [ ] repositori, pipeline, dan environment awal siap dipakai +- [ ] template sprint dan ticket aktif +- [ ] device simulator bisa mensimulasikan callback dan heartbeat +- [ ] tim siap masuk Fase 1 tanpa menunggu keputusan besar + +## 3. Fase 1 - Foundation MVP +### Tujuan +mengaktifkan merchant, device registry dasar, callback payment, dan static soundbox notification + +### Scope +- merchant registry dasar +- outlet dan terminal management dasar +- device registry/TMS dasar +- device binding +- callback/webhook receiver +- transaction registry +- notification service dasar +- MQTT push basic +- admin dashboard basic +- transaction monitoring basic + +### Output +- static soundbox flow berjalan end-to-end +- admin dapat melihat merchant, device, transaksi, status notif + +### Breakdown Implementasi Fase 1 +#### Sprint 1 — Auth & Core Platform +- Setup repo struktur service dan pipeline +- Setup observability dasar (request_id/trace_id, structured log) +- Setup RBAC baseline untuk admin dan service-to-service auth +- Setup auth device token + rotation policy awal +- Setup migrasi awal sesuai schema MVP +- Endpoint onboarding merchant basic (`/admin/merchants` CRUD minimal) +- Endpoint outlet dan terminal basic (`/admin/merchants/{merchantId}/outlets`, `/admin/outlets/{outletId}/terminals`) +- Kriteria Selesai: data merchant/outlet/terminal bisa dibuat, diupdate, ditampilkan, dan dihapus sesuai kebutuhan flow + +#### Sprint 2 — Device Foundation & Binding +- Endpoint device registry (`/admin/devices` CRUD) +- Endpoint konfigurasi awal device (`/admin/devices/{deviceId}/bind`, `/unbind`) +- Simpan binding aktif ke merchant, outlet, terminal dengan history +- Endpoint heartbeat device (`/device/heartbeat`) + tabel `device_heartbeats` +- Endpoint get device dan list heartbeat untuk ops +- Status device derived: online/stale/offline (berdasarkan `last_seen_at`) +- Kriteria Selesai: device bisa didaftarkan, di-bind, kirim heartbeat, dan terlihat statusnya di dashboard ops + +#### Sprint 3 — Transaction Engine + Webhook Static +- Model transaksi inti untuk mode static (`transactions` + `transaction_events`) +- Webhook callback (`/integrations/qris/callback`) dengan verifikasi signature +- Idempotency key untuk callback dan penanganan duplicate callback +- State machine transaksi minimal: `initiated`, `paid`, `failed`, `expired` +- Audit dasar pada perubahan status transaksi +- Kriteria Selesai: callback memutakhirkan status transaksi dan terekam eventnya + +#### Sprint 4 — Notifikasi Berbasis MQTT +- Event notification orchestrator sederhana dari status transaksi `paid` +- Publisher ke topic device `devices/{deviceId}/downlink/payment/success` +- Format payload sesuai kontrak MQTT draft +- Tabel `notifications` + status pengiriman (`queued/sent/acknowledged/failed`) +- Retry basic untuk status gagal dengan backoff sederhana (tanpa DLQ penuh di sprint ini) +- Kriteria Selesai: transaksi static memicu notifikasi sukses ke device yang terikat + +#### Sprint 5 — Admin Monitoring & Readiness +- Admin transaction list + detail (`/admin/transactions`, `/admin/transactions/{transactionId}`) +- Admin device/merchant list dasar +- Admin dashboard KPI minimal (tx hari ini, success rate, device online, pending notif) +- Endpoint retry notifikasi (`/admin/transactions/{transactionId}/retry-notification`) +- Monitoring kegagalan callback dan notifikasi +- Kriteria Selesai: alur static end-to-end terlihat utuh di admin (input callback -> status tx -> notif -> ack/failed) + +#### Acceptance Criteria Fase 1 +- Static QR flow end-to-end berjalan untuk minimal 1 merchant dan 1 device +- Callback duplicate tidak menambah transaksi ganda +- transaksi yang berhasil bayar tercatat `paid` dan membuat ledger entry placeholder +- status device `online` tersimpan saat heartbeat berhasil +- notifikasi sukses terkirim dengan event_id +- audit log mencatat aksi CRUD penting dan callback state changes + +#### Dependency Diagram (Ringkas) +- Auth & Core dibutuhkan sebelum semua API/device flow +- Device Binding dibutuhkan sebelum transaksi menargetkan device +- Webhook dipasang sebelum notifikasi payment +- Notifikasi MQTT dipasang setelah transaction status `paid` stabil +- Admin monitoring dipasang setelah data transaksi dan notification pipeline berfungsi + +#### Risiko Awal & Mitigasi (Fase 1) +- Mapping merchant-transaksi-device salah -> hard binding + validasi binding aktif saat kirim notifikasi +- Callback retry menyebabkan duplicate -> key composite pada partner reference + event idempotent check +- Device offline saat notify -> queue retry + status `retrying` +- Data konsistensi merchant/outlet/terminal tidak stabil -> blokir pembuatan transaksi jika binding tidak valid +- Audit minim -> tambahkan actor/action/entity id di setiap state mutasi penting + +#### Definisi Selesai Fase 1 +- [ ] static flow: scan-pay callback sukses -> transaksi `paid` -> notifikasi success diterima +- [ ] dashboard ops melihat transaksi, merchant, device, notification status +- [ ] retry basic untuk callback dan notification tersedia +- [ ] skenario utama: normal, duplicate callback, device tanpa binding, webhook invalid signature, device offline + +## 4. Fase 2 - Dynamic QR Enablement +### Scope +- QRIS dynamic orchestration +- MQTT uplink request flow +- API direct create QR flow +- capability resolver +- heartbeat dan online status +- config management dasar + +### Output +- support 3 jenis device flow +- dynamic QR bisa berjalan melalui MQTT-only dan API-direct + +### Breakdown Implementasi Fase 2 +#### Sprint 1 — Capability & Routing +- Implementasi capability profile pada `devices.capability_profile_json` +- Resolver untuk menentukan flow: + - `STATIC` device: tidak boleh create QR + - `DYNAMIC_MQTT` device: create via MQTT request + - `DYNAMIC_API` device: create via API langsung +- Middleware validasi capabilty dan izin per endpoint/action +- Kriteria Selesai: request invalid akan reject dengan error yang jelas (`DEVICE_CAPABILITY_NOT_SUPPORTED`, `DEVICE_NOT_BOUND`) + +#### Sprint 2 — API-direct Dynamic QR +- Finalisasi endpoint `POST /device/transactions/dynamic-qr` +- Validasi payload: `amount`, `terminal_id`, `request_id`, device binding, capabilty +- Idempotency handling pada request device (idempotency key / request_id) +- Orchestrator QRIS dynamic: + - panggil partner QRIS + - simpan `transactions` status `awaiting_payment` + - set `transaction_events` +- Response standard: + - `transaction_id`, `qr_payload`, `expires_at`, `status`, `request_id` +- Kriteria Selesai: device API mendapatkan QR dinamis dan dapat memantau status pembayaran via callback + +#### Sprint 3 — MQTT Dynamic Uplink/Downlink +- Implementasi handler uplink MQTT untuk request: + - `devices/{deviceId}/uplink/dynamic-qr/request` +- Mapping `request_id` -> `correlation_id` untuk response +- Publish response ke: + - `devices/{deviceId}/downlink/dynamic-qr/response` +- Idempotent request handling untuk MQTT retry +- Kriteria Selesai: device MQTT-only bisa membuat QR sukses minimal untuk normal flow + +#### Sprint 4 — Heartbeat & Status Online yang Lebih Akurat +- Peningkatan endpoint heartbeat: + - `POST /device/heartbeat` + MQTT heartbeat ingest terstandar + - health score dari `network_strength`, `battery_level`, `last_seen_at` +- Derivasi status: + - `online`, `degraded`, `stale`, `offline` +- Filter admin untuk status dan rentang health metric +- Kriteria Selesai: ops bisa memfilter device berdasarkan health dan mengurangi retry pada kondisi degraded + +#### Sprint 5 — Konfigurasi Device Dasar +- Endpoint config: + - `GET /device/config` + - `POST /device/config/ack` +- Command config push via MQTT: + - `devices/{deviceId}/downlink/config/push` +- Versioned config (`config_version`) dan idempotent ACK +- Kriteria Selesai: perubahan config tidak memutus flow pembayaran dan bisa disinkronkan ulang + +#### Acceptance Criteria Fase 2 +- 3 mode device berfungsi: + - static: hanya notifikasi + - dynamic-MQTT: request dan response QR via broker + - dynamic-API: request dan response via API +- duplikasi request tidak membuat transaksi ganda +- setiap request punya korelasi penuh `request_id/correlation_id` +- callback tetap jadi sumber truth status bayar +- notifikasi sukses tetap melalui MQTT dengan payload terstandar + +#### Dependency Diagram (Ringkas) +- Fase 2 dimulai setelah Fase 1 merchant/device/binding dan callback stabil +- API-direct bisa mulai setelah capability resolver dan transaksi core siap +- MQTT uplink membutuhkan MQTT adapter dan auth device yang siap dari Fase 1 + +#### DoD Fase 2 +- [ ] Fitur create dynamic QR untuk API dan MQTT berfungsi +- [ ] dynamic-qr request/reply korelatif dan idempotent +- [ ] heartbeat/status device dipakai untuk operasional dan retry +- [ ] config push/ack dasar berjalan +- [ ] semua path mencatat trace/request IDs konsisten + +## 5. Fase 3 - Finance Core +### Scope +- ledger entries +- merchant payable +- fee engine +- settlement batch +- payout/disbursement +- settlement monitoring +- reconciliation basic + +### Output +- merchant balance dan settlement dapat dihitung dan dibayarkan + +### Breakdown Implementasi Fase 3 +#### Sprint 1 — Ledger Foundation +- Finalisasi skema `ledger_entries` dan index query balance +- Implementasi akun per-merchant saldo running (materialized atau computed-on-read) +- Masukkan entri ledger saat transaksi berubah ke `paid`: + - `gross_income` + - `platform_fee` + - `merchant_payable` +- Integrasi reconciliation placeholder agar entri ledger bisa ditandai `reconciled=false` +- Kriteria Selesai: setiap transaksi sukses menambah entri ledger yang benar + +#### Sprint 2 — Fee Engine +- Mapping fee profile per merchant (fixed + percentage jika berlaku) +- Formula payout per transaksi: + - `merchant_payable = gross - fee` +- Validasi konsistensi nilai (rounding, precision uang) +- Simpan audit metadata fee calculation +- Kriteria Selesai: perubahan fee profile langsung memengaruhi simulasi payable + +#### Sprint 3 — Settlement Batch +- Buat job pembuat settlement periodik (harian/mingguan/period custom) +- Generate `settlements` dan `settlement_items` +- Auto-status: draft -> ready_to_pay -> paid / failed +- API basic: + - `POST /admin/settlements/run` + - `GET /admin/settlements` + - `GET /admin/settlements/{settlementId}` +- Kriteria Selesai: batch untuk periode tertentu bisa dibuat dan diekspor + +#### Sprint 4 — Disbursement/Payout +- Implementasi eksekusi payout via connector bank/disbursement +- Simpan `payout_attempts` (attempt, payload, response, status) +- Retry payout dasar + endpoint retry payout +- Fallback status jika payout gagal / pending +- Kriteria Selesai: payout attempt sukses atau tercatat gagal dengan alasan terstruktur + +#### Sprint 5 — Monitoring Finance Dasar +- Dashboard settlement: gross, fee, net, status payout +- Reconciliation basic: + - compare internal `gross/net` vs feed partner + - status `matched`, `mismatch`, `missing_internal`, `missing_external` +- Kriteria Selesai: perbedaan finansial terlihat dan bisa di-escalate + +#### Acceptance Criteria Fase 3 +- saldo merchant dapat dihitung dari transaksi settled +- settlement batch dan payout tidak bergantung pada notifikasi device +- failed payout tidak menghapus transaksi dan tetap terjaga jejaknya +- fitur finance dapat dijalankan dengan idempotency dasar + +#### Dependency Diagram (Ringkas) +- Bergantung penuh pada Fase 1–2 untuk transaction lifecycle + terminal/merchant/terminal binding +- Fee engine perlu fee profile dan transaksi yang valid +- Payout memerlukan settlement batch dan reconciliation record yang konsisten + +#### DoD Fase 3 +- [ ] ledger entries otomatis terbentuk saat transaksi paid +- [ ] settlement batch bisa dibuat, dipreview, dan dieksekusi +- [ ] payout attempt dan status payout terekam +- [ ] laporan mismatch reconciliation dasar tersedia +- [ ] audit log menutup operasi finance kritis + +## 6. Fase 4 - Operations Hardening +### Scope +- alerting +- audit log lengkap +- command center device +- duplicate suppression matang +- retry policies formal +- reconciliation exception handling +- role-based access matang + +### Output +- sistem siap dioperasikan dengan volume lebih tinggi + +### Breakdown Implementasi Fase 4 +#### Sprint 1 — Audit & Governance +- Audit log lengkap pada aksi kritis: + - onboarding approve/reject + - binding/unbinding device +- Capture `before_json` dan `after_json` +- Searchable audit view dengan filter actor/entity/action/time +- Kriteria Selesai: perubahan konfigurasi penting dapat ditelusuri 1 klik + +#### Sprint 2 — Alerting & Observability +- Alert dasar: + - callback failure rate naik + - notification failed spike + - heartbeat timeout massal + - payout failed beruntun +- Integrasi dashboard metrics dasar + error budget sederhana +- Kriteria Selesai: alert operasional muncul ke channel ops + +#### Sprint 3 — Retry & Duplicate Suppression Matang +- Idempotency layer diperluas: + - callback + - dynamic QR request + - MQ downlink +- Retry policy configurable per class event +- DLQ / dead-letter untuk kegagalan permanen +- Kriteria Selesai: event gagal tidak membeku dan dapat ditindaklanjuti + +#### Sprint 4 — Device Command Center +- Endpoint command: + - `POST /admin/devices/{deviceId}/commands` + - track ack melalui `device_commands` +- Command push via MQTT +- Retry command terbatas + timeout handling +- Kriteria Selesai: ops bisa mengirim perintah tes/diagnostic + +#### Sprint 5 — Security & RBAC Matang +- RBAC matrix admin ops / finance / support +- Audit permission perubahan role +- Peningkatan token/device auth (exp, rotasi, revocation) +- Kriteria Selesai: akses sesuai prinsip least privilege + +#### Acceptance Criteria Fase 4 +- kejadian kritis punya jejak audit lengkap +- sistem lebih stabil pada kondisi gagal sementara +- device command operasional tersedia +- kontrol akses sesuai tugas/otoritas + +#### Dependency Diagram (Ringkas) +- Audit dan RBAC dimulai paralel, paling optimal setelah model user-role stabil +- Alerting memerlukan observability baseline dari phase sebelumnya +- Retry matang mengunci keandalan Fase 2–3 + +#### DoD Fase 4 +- [ ] audit log searchable dan immutable +- [ ] alert operasional aktif untuk skenario kegagalan utama +- [ ] duplicate suppression menutup 95% replay issue normal +- [ ] command center mencatat status end-to-end +- [ ] role permission change tervalidasi dan tercatat + +## 7. Fase 5 - Merchant Experience +### Scope +- merchant portal +- merchant transaction view +- merchant settlement view +- device visibility untuk merchant +- support/help flow + +### Output +- merchant bisa self-serve melihat transaksi, settlement, dan device miliknya + +### Breakdown Implementasi Fase 5 +#### Sprint 1 — Portal Dasar +- `GET /merchant/me`, `GET /merchant/dashboard/summary` +- Tabel transaksi dan settlement merchant +- Akses terbatas ke entitas milik merchant +- Kriteria Selesai: merchant dapat login dan melihat saldo/riwayat dasar + +#### Sprint 2 — Transaction Detail & Device Visibility +- Detail transaksi merchant (status timeline + raw callback summary) +- Tabel device merchant, status online, last seen +- Pembatasan data per tenant/merchant +- Kriteria Selesai: merchant bisa menyaring sendiri transaksi/perangkatnya + +#### Sprint 3 — Settlement & Support +- Halaman settlement history + settlement detail +- Informasi payout status dan retry reason +- Support center untuk tiket/FAQ awal +- Kriteria Selesai: merchant bisa memantau proses pencairan + +#### Sprint 4 — Self-Serve Preferences +- Pengaturan notifikasi merchant dan outlet/terminal sederhana +- Delegasi role akses team merchant +- Kriteria Selesai: pengelolaan akun merchant lebih mandiri + +#### Acceptance Criteria Fase 5 +- merchant self-serve tidak bergantung pada ops untuk data transaksi normal +- hak akses tenant kuat, tidak bocor ke merchant lain +- settlement & device informasi cukup untuk operasional harian merchant + +#### Dependency Diagram (Ringkas) +- Fase 5 menunggu Fase 1–4 untuk data transaksi, settlement, binding, audit, dan RBAC +- Fitur support berjenjang setelah data core stabil + +#### DoD Fase 5 +- [ ] merchant login dan melihat dashboard, transaksi, settlements +- [ ] tenant isolation valid di semua query merchant +- [ ] device visibility dan riwayat pembayaran sesuai tenant +- [ ] dokumentasi onboarding pengguna internal merchant siap dipakai + +## 8. Rekomendasi Urutan Pengerjaan Tim +1. finalisasi entity dan schema awal +2. finalisasi API dan MQTT contract +3. bangun admin/tms/transaction monitoring dasar +4. bangun static notification flow +5. bangun dynamic QR flows +6. bangun ledger dan settlement +7. bangun merchant portal + +## 9. Execution Now (Tanpa Jadwal) +### Fase 0 — Segera Dijalankan Hari Ini +- finalisasi dokumen API contract dan MQTT contract ke versi stabil untuk coding +- finalize ownership field API: siapa yang maintain `merchant`, `transaction`, `device`, `ledger`, `settlement` +- finalisasi error code standar dan mapping HTTP status +- buat branch awal `feat/phase-0` dan template commit message +- siapkan repositori pipeline baseline untuk lint/build/test smoke +- setup environment list: dev, staging, production target +- siapkan dataset seed: +- minimal 2 merchant +- minimal 2 outlet dan terminal +- minimal 3 device: static, dynamic-mqtt, dynamic-api +- siapkan MQTT broker topik minimal: +- `devices/{deviceId}/downlink/payment/success` +- `devices/{deviceId}/uplink/dynamic-qr/request` +- `devices/{deviceId}/downlink/dynamic-qr/response` +- `devices/{deviceId}/downlink/config/push` +- simpan semua hasil keputusan di `DECISIONS_LOG.md` (buat file baru nanti) + +### Fase 1 — Langkah Eksekusi Urutan Langsung +#### Step 1 — Core Foundation +- setup service skeleton sesuai arsitektur layer +- implement RBAC baseline admin + device auth +- implement migrasi DB awal untuk merchant, outlet, terminal, device, binding, transaksi, notifications +- implement `request_id`, `trace_id`, `event_id` generator standar +- buat endpoint create/get/list merchant +- buat endpoint create/get/list outlet dan terminal +- buat endpoint binding device (bind/unbind) +- acceptance: CRUD merchant/device/terminal bisa dipakai dari admin + +#### Referensi Step 1 Detail +- gunakan [12-fase1-step1-core-foundation-spec.md](/home/wira/work/codex/qris-soundbox-platform/12-fase1-step1-core-foundation-spec.md) untuk API detail, schema, dan acceptance per paket +- keputusan teknis wajib mengikuti [DECISIONS_LOG.md](/home/wira/work/codex/qris-soundbox-platform/DECISIONS_LOG.md) + +#### Step 2 — Callback dan State Transaction +- implement webhook callback `/integrations/qris/callback` dengan signature check +- implement idempotency callback dan event store `transaction_events` +- implement state machine transaksi minimal +- simpan data callback raw di event +- acceptance: duplicate callback tidak duplikasi state akhir + +#### Referensi Step 2 Detail +- gunakan [13-fase1-step2-callback-transaction-spec.md](/home/wira/work/codex/qris-soundbox-platform/13-fase1-step2-callback-transaction-spec.md) untuk validasi event, idempotency callback, dan state machine + +#### Step 3 — Static Notification +- implement notification orchestrator pada state `paid` +- implement publish MQTT success ke device target +- simpan delivery state dan retry sekali-dua kali +- implement endpoint retry notification admin +- acceptance: static flow menghasilkan notifikasi sukses ke device bound + +#### Referensi Step 3 Detail +- gunakan [14-fase1-step3-notification-spec.md](/home/wira/work/codex/qris-soundbox-platform/14-fase1-step3-notification-spec.md) untuk event flow, payload, status retry, dan endpoint retry + +#### Step 4 — Device & Ops Monitoring +- implement heartbeat endpoint dan status derivation +- implement admin list device, transaction, merchant + detail basic +- implement dashboard KPI minimal +- acceptance: operator bisa melihat health device, transaksi hari ini, dan notifikasi pending + +#### Referensi Step 4 Detail +- gunakan [16-fase1-step4-monitoring-spec.md](/home/wira/work/codex/qris-soundbox-platform/16-fase1-step4-monitoring-spec.md) untuk endpoint heartbeat, status derivation, KPI dashboard, dan retry-notification + +### Cara Mulai Kode Hari Pertama (Checklist) +- [ ] buat modul `shared` untuk idempotency + tracing + error format +- [ ] buat modul `auth` untuk admin dan device token +- [ ] buat modul `merchant` dan `location` (outlet/terminal) terlebih dulu +- [ ] buat modul `device` dan binding service +- [ ] buat modul `transaction` state machine dan callback handler +- [ ] buat modul `notification` publisher MQTT +- [ ] buat modul `monitoring` KPI dasar admin +- [ ] verifikasi Fase 1 DoD sebelum lanjut Fase 2 +- [ ] ikuti urutan kerja di [17-fase1-implementation-task-pack.md](/home/wira/work/codex/qris-soundbox-platform/17-fase1-implementation-task-pack.md) + +### Aturan Kerja Eksekusi +- jangan buka perangkat lain jika API contract belum final +- jangan lanjut dynamic QR sebelum callback + static notification stabil +- setiap PR harus menyertakan: +- skenario happy path +- 1 edge case +- 1 idempotency case diff --git a/09-screen-inventory.md b/09-screen-inventory.md new file mode 100644 index 0000000..9858d27 --- /dev/null +++ b/09-screen-inventory.md @@ -0,0 +1,110 @@ +# Screen Inventory - QRIS Soundbox Platform v1 + +## 1. Admin / Ops Screens +1. Login +2. Dashboard Overview +3. Merchant List +4. Merchant Detail +5. Merchant Onboarding Review +6. Outlet List +7. Outlet Detail +8. Terminal List +9. Terminal Detail +10. Device Registry List +11. Device Detail +12. Device Provisioning +13. Device Command Center +14. Device Monitoring +15. Transaction List +16. Transaction Detail +17. Notification Delivery Monitor +18. Ledger Dashboard +19. Settlement Batch List +20. Settlement Detail +21. Reconciliation Screen +22. Integration Settings +23. Fee & Pricing Management +24. Audit Logs +25. Role & Access Management +26. Incident / Alert Center + +## 2. Merchant Portal Screens +1. Merchant Login +2. Merchant Dashboard +3. Transaction History +4. Transaction Detail +5. Settlement History +6. Settlement Detail +7. Outlet List +8. Outlet Detail +9. Device List +10. Device Detail +11. QR Management +12. Profile & Business Info +13. Settlement Account +14. Team Access +15. Support Center +16. Notification Preferences + +## 3. Merchant Onboarding Screens +1. Registration Start +2. Business Information Form +3. PIC Information Form +4. Settlement Account Form +5. Document Upload +6. Review Submission +7. Onboarding Status Tracker + +## 4. Finance / Internal Ops Screens +1. Payable Summary +2. Disbursement Queue +3. Failed Disbursement Monitor +4. Manual Adjustment +5. Refund / Dispute Review +6. Reconciliation Exception Detail + +## 5. Device-side Screens +### Static device +1. Idle +2. Payment Received +3. Network Issue +4. Bound Device Info + +### Dynamic device +1. Idle +2. Input Amount +3. Generating QR +4. QR Display +5. Payment Success +6. Payment Expired +7. Retry/Error +8. Network Disconnected + +## 6. Prioritas Desain Tahap Awal +### Prioritas 1 +- Admin Dashboard +- Merchant List +- Merchant Detail +- Onboarding Review +- Device Registry List +- Device Detail +- Transaction List +- Transaction Detail +- Settlement Batch List +- Settlement Detail + +### Prioritas 2 +- Merchant Dashboard +- Merchant Transaction History +- Merchant Settlement History +- Merchant Device List +- Device Monitoring +- Notification Delivery Monitor + +### Prioritas 3 +- Reconciliation +- Audit Logs +- Role Management +- Command Center +- Integration Settings +- Fee Management diff --git a/10-design-blueprint.md b/10-design-blueprint.md new file mode 100644 index 0000000..3930d30 --- /dev/null +++ b/10-design-blueprint.md @@ -0,0 +1,133 @@ +# Design Blueprint - QRIS Soundbox Platform v1 + +## 1. Objective +Desain harus mendukung 4 persona utama: +- Admin Ops +- Finance Ops +- Merchant +- Field / Device Operations + +Fokus desain: +- data heavy tapi cepat dipahami +- workflow operasional jelas +- merchant portal sederhana dan terpercaya +- device monitoring tidak membingungkan + +## 2. Visual Direction +### Style keyword +- operational SaaS +- trustworthy fintech +- modern enterprise +- data-centric +- clean and fast + +### Warna yang disarankan +- Primary: `#2563EB` +- Primary dark: `#1D4ED8` +- Success: `#16A34A` +- Warning: `#F59E0B` +- Danger: `#DC2626` +- Info: `#0EA5E9` +- Slate 900: `#0F172A` +- Slate 700: `#334155` +- Slate 500: `#64748B` +- Slate 200: `#E2E8F0` +- Slate 100: `#F1F5F9` +- Background: `#F8FAFC` + +### Typography +- Inter / Plus Jakarta Sans / Geist +- heading semibold +- body regular +- gunakan tabular numbers untuk nominal dan metric + +## 3. Layout Rules +- sidebar tetap di area admin dan merchant portal desktop +- topbar tinggi 72px +- page padding 24px +- 12-column desktop grid +- card padding 20-24px +- table row 52-56px + +## 4. Komponen Utama +- KPI card +- metric card with trend +- data table with sticky header +- filter bar +- detail drawer +- status chips +- timeline vertical +- alert rail +- audit block / raw payload viewer +- command modal +- config panel + +## 5. UX Rules +- tabel utama harus punya filter dan export +- detail transaksi dan device idealnya punya tab + side summary +- gunakan drawer untuk quick inspection, full page untuk deep work +- nominal, status, dan device health harus mudah dipindai +- raw payload harus bisa copy +- empty state harus selalu ada CTA + +## 6. Page Groups +### Admin +- Dashboard +- Merchant Management +- Onboarding Review +- Device/TMS +- Transactions +- Ledger & Settlement +- Reconciliation +- Integration Settings +- Audit & Access Control + +### Merchant Portal +- Dashboard +- Transactions +- Settlements +- Devices +- Outlets +- Profile & Bank Account +- Support + +### Device UI +- Static notification screens +- Dynamic QR flow screens +- Error and offline states + +## 7. Figma Pages Recommendation +1. Cover +2. Foundations +3. Components +4. Admin Dashboard +5. Merchant Portal +6. Device UI +7. Flows +8. Archive + +## 8. Komponen Status Chips yang Wajib +- active +- inactive +- pending +- approved +- rejected +- online +- offline +- degraded +- paid +- expired +- failed +- retrying +- settled +- payout_failed + +## 9. Prioritas Desain Pertama +1. Admin Dashboard +2. Merchant Detail +3. Device Registry + Device Detail +4. Transaction List + Detail +5. Settlement Batch + Detail +6. Merchant Dashboard +7. Merchant Transaction History +8. Dynamic Device UI states diff --git a/11-low-fi-wireframes.md b/11-low-fi-wireframes.md new file mode 100644 index 0000000..4964b7d --- /dev/null +++ b/11-low-fi-wireframes.md @@ -0,0 +1,119 @@ +# Low-Fi Wireframes - QRIS Soundbox Platform v1 + +## 1. Admin Dashboard Overview +```text ++------------------------------------------------------------------------------------------------+ +| Sidebar | Topbar: Search | Date | Alerts | Profile | +|---------+--------------------------------------------------------------------------------------| +| Overview| KPI: Merchant | Device Online | Tx Today | Success Rate | Pending Settlement | +| Merchant|--------------------------------------------------------------------------------------| +| Outlet | Tx Trend Chart | Device Health Chart | +| Device |------------------------------------+---------------------------------------------------| +| Tx | Pending Onboarding Table | Alerts / Incident Rail | +| Ledger |--------------------------------------------------------------------------------------| +| Settle | Latest Transactions Table | +| Recon |--------------------------------------------------------------------------------------| +| Audit | Failed Notifications Table | ++------------------------------------------------------------------------------------------------+ +``` + +## 2. Merchant Detail +```text ++------------------------------------------------------------------------------------------------+ +| Header: Merchant Name | Status | Fee Profile | Settlement Status | Actions | +|------------------------------------------------------------------------------------------------| +| Summary Cards: GMV | Tx Count | Active Outlet | Active Device | Pending Settlement | +|------------------------------------------------------------------------------------------------| +| Tabs: Profile | Outlets | Terminals | Devices | Transactions | Settlements | Audit | +|------------------------------------------------------------------------------------------------| +| Content Area | ++------------------------------------------------------------------------------------------------+ +``` + +## 3. Device Detail +```text ++------------------------------------------------------------------------------------------------+ +| Header: Device ID | Vendor/Model | Online Status | Last Seen | Actions | +|------------------------------------------------------------------------------------------------| +| Cards: Merchant Binding | Firmware | Comm Mode | Capability Profile | Heartbeat Health | +|------------------------------------------------------------------------------------------------| +| Tabs: Overview | Heartbeat | Config | Commands | Notifications | Binding History | +|------------------------------------------------------------------------------------------------| +| Content Area | ++------------------------------------------------------------------------------------------------+ +``` + +## 4. Transaction Detail +```text ++------------------------------------------------------------------------------------------------+ +| Header: Tx ID | Amount | Status | Merchant | Device | Partner Ref | +|------------------------------------------------------------------------------------------------| +| Timeline: Created -> QR Generated -> Paid -> Notified -> Settled | +|------------------------------------------------------------------------------------------------| +| Left: Summary / Callback Payload / Partner Response | +| Right: Ledger Impact / Notification Delivery / Device Ack | ++------------------------------------------------------------------------------------------------+ +``` + +## 5. Settlement Batch List +```text ++------------------------------------------------------------------------------------------------+ +| Filters: Period | Status | Merchant | Export | +|------------------------------------------------------------------------------------------------| +| Table: Batch ID | Period | Merchant Count | Gross | Fee | Net | Status | Action | ++------------------------------------------------------------------------------------------------+ +``` + +## 6. Merchant Portal Dashboard +```text ++------------------------------------------------------------------------------------------------+ +| Sidebar | Topbar: Date | Profile | +|---------+--------------------------------------------------------------------------------------| +| Home | KPI: Tx Today | Settlement Pending | Active Devices | Success Rate | +| Tx |--------------------------------------------------------------------------------------| +| Settle | Recent Transactions Table | +| Device |--------------------------------------------------------------------------------------| +| Outlet | Recent Settlements Table | +| Profile |--------------------------------------------------------------------------------------| +| Support | Device Status Mini Panel | ++------------------------------------------------------------------------------------------------+ +``` + +## 7. Dynamic Device UI +```text ++-------------------------------------------+ +| Merchant Name | +|-------------------------------------------| +| Input Nominal | +| [ 50.000 ] | +| | +| [Buat QR] | +|-------------------------------------------| +| Status / hint text | ++-------------------------------------------+ +``` + +```text ++-------------------------------------------+ +| Merchant Name | +|-------------------------------------------| +| [ QR IMAGE ] | +| | +| Nominal: Rp50.000 | +| Expired in: 04:12 | +| | +| Menunggu pembayaran... | ++-------------------------------------------+ +``` + +```text ++-------------------------------------------+ +| Pembayaran diterima | +| | +| Rp50.000 | +| | +| Referensi: TX-20260523-001 | +| | +| Terima kasih | ++-------------------------------------------+ +``` diff --git a/12-fase1-step1-core-foundation-spec.md b/12-fase1-step1-core-foundation-spec.md new file mode 100644 index 0000000..c640fb2 --- /dev/null +++ b/12-fase1-step1-core-foundation-spec.md @@ -0,0 +1,206 @@ +# Fase 1 — Step 1: Core Foundation (Spesifikasi Implementasi) + +Dokumen ini memecah Step 1 menjadi paket kerja per service, API, schema, dan acceptance detail agar bisa langsung jadi ticket development. + +## 1) Service yang Dibangun Dulu (Urutan Implementasi) + +1. `shared` — tracing, idempotency key store, error envelope, request context +2. `identity` — admin auth + device auth + RBAC baseline +3. `merchant` — merchant onboarding dasar +4. `location` — outlet dan terminal +5. `device-tms` — registrasi device dan binding +6. `core-db` — migrasi schema MVP + seed data +7. `ingress` — endpoint admin yang membutuhkan service di atas + +## 2) Kontrak Data dan ID Standard + +### 2.1 ID +- `merchant_id`, `outlet_id`, `terminal_id`, `device_id`, `binding_id`, `transaction_id`, `notification_id` +- format rekomendasi: ULID/UUID v4 + +### 2.2 Standard Trace/Event +- Setiap request HTTP dan MQTT message harus membawa: +- `request_id` (wajib di inbound entry API / webhook / mqtt) +- `trace_id` (inherit antar service) +- `event_id` (untuk emission / audit event) + +### 2.3 Idempotency +- Table `idempotency_keys` baru: + - `id` + - `scope` (`merchant_create`, `outlet_create`, `terminal_create`, dll) + - `key` (unique) + - `response_hash` + - `created_at`, `expires_at` +- Rule: hit yang sama + key sama => response deterministic dari cache/replay. + +## 3) Skema DB (MVP Step 1) — Detail Migrasi + +### 3.1 Tabel Inti yang Harus Ada +- `merchants` +- `outlets` +- `terminals` +- `devices` +- `device_bindings` +- `device_heartbeats` +- `transactions` +- `transaction_events` +- `notifications` +- `audit_logs` +- `users` +- `roles` +- `idempotency_keys` (tambah) + +### 3.2 Field Wajib Step 1 per Tabel + +#### `merchants` +- id, merchant_code, legal_name, brand_name +- status, onboarding_status +- settlement_account_reference, settlement_account_type, fee_profile_id +- created_at, updated_at + +#### `outlets` +- id, merchant_id(FK), outlet_code, name, address, status +- created_at, updated_at + +#### `terminals` +- id, outlet_id(FK), terminal_code, qr_mode, partner_reference, status +- created_at, updated_at + +#### `devices` +- id, device_code, serial_number, vendor, model +- communication_mode, capability_profile_json, auth_method, status +- last_seen_at, firmware_version +- created_at, updated_at + +#### `device_bindings` +- id, device_id(FK), merchant_id(FK), outlet_id(FK), terminal_id(FK) +- active_flag, bound_at, unbound_at +- unique index: (`device_id`, `active_flag` where active_flag=true) + +#### `device_heartbeats` +- id, device_id(FK), received_at +- network_strength, battery_level, firmware_version, state, payload_json + +#### `transactions` +- id, transaction_code, merchant_id(FK), outlet_id(FK), terminal_id(FK), device_id(FK) +- qr_mode, initiation_mode, partner_reference, amount, currency, status, created_at, paid_at, expired_at, updated_at + +#### `transaction_events` +- id, transaction_id(FK), event_type, source, payload_json, created_at + +#### `notifications` +- id, transaction_id(FK), device_id(FK), delivery_channel, payload_type +- delivery_status, retry_count, ack_status, sent_at, ack_at + +#### `audit_logs` +- id, actor_type, actor_id, action, entity_type, entity_id +- before_json, after_json, source_ip, created_at + +#### `users` / `roles` (minimal) +- users: id, name, email, password_hash, role_id, status, created_at +- roles: id, name, permissions_json, created_at + +### 3.3 Index Prioritas Step 1 +- `device_bindings(device_id, active_flag)` +- `transactions(partner_reference)` +- `transactions(merchant_id, created_at)` +- `transactions(status, created_at)` +- `notifications(device_id, delivery_status)` +- `device_heartbeats(device_id, received_at)` +- `audit_logs(entity_type, entity_id, created_at)` + +## 4) Modul API Step 1 (Spesifikasi Ringkas) + +### 4.1 Admin/Auth +- `POST /auth/login` (bila auth service belum ada, temporary token stub boleh pakai untuk sprint 1) +- `POST /auth/token/refresh` (opsional) +- Validasi RBAC untuk endpoint admin: + - `admin` minimal bisa akses semua CRUD Fase 1 +- Error standar: + - `UNAUTHORIZED`, `FORBIDDEN`, `VALIDATION_ERROR` + +### 4.2 Merchant +- `POST /admin/merchants` + - body: + - `legal_name`, `brand_name`, `settlement_account_reference`, `settlement_account_type`, `fee_profile_id` + - `settlement_account_type` contoh: `merchant_virtual_account`, `merchant_bank_account`, `partner_payout_reference` + - `payout_mode` (opsional): `merchant_direct` (default), `manual` + - `merchant_direct` = payout diarahkan via reference payout merchant + - `manual` = pencairan dibantu operasional, belum otomatis + - validasi: `legal_name` required + - idempotency: `Idempotency-Key` optional untuk create +- `GET /admin/merchants` +- `GET /admin/merchants/{merchantId}` +- `PATCH /admin/merchants/{merchantId}` +- `DELETE /admin/merchants/{merchantId}` (opsional; bisa soft delete via status=inactive) + +### 4.3 Outlet +- `POST /admin/merchants/{merchantId}/outlets` +- `GET /admin/outlets` +- `GET /admin/outlets/{outletId}` +- `PATCH /admin/outlets/{outletId}` + +### 4.4 Terminal +- `POST /admin/outlets/{outletId}/terminals` +- `GET /admin/terminals` +- `GET /admin/terminals/{terminalId}` +- `PATCH /admin/terminals/{terminalId}` + +### 4.5 Device & Binding +- `POST /admin/devices` +- `GET /admin/devices` +- `GET /admin/devices/{deviceId}` +- `PATCH /admin/devices/{deviceId}` +- `POST /admin/devices/{deviceId}/bind` + - body: `{ merchant_id, outlet_id, terminal_id }` + - rule: endpoint ini membuat record `device_bindings` baru dan menonaktifkan binding aktif lama +- `POST /admin/devices/{deviceId}/unbind` + - body: `{ reason }` + +## 5) Acceptance Criteria per Paket + +### 5.1 Merchant/Location +- merchant, outlet, terminal bisa dibuat, diubah, list, detail +- semua response memuat `request_id` +- duplikasi create request dengan idempotency key yang sama menghasilkan resource yang sama + +### 5.2 Device Binding +- bind mengikat hanya satu binding aktif/device pada saat yang sama +- unbind membuat binding aktif jadi nonaktif dan merekam `unbound_at` +- binding referensi invalid menyebabkan endpoint device/transaction terkait ditolak di tahap berikutnya + +### 5.3 Observability/Platform +- semua response error mengikuti envelope: + - `code`, `message`, `details`, `request_id` +- log audit tersimpan saat create/update pada merchant, device, bind/unbind + +### 5.4 Operasional +- `last_seen_at` device berubah saat heartbeat valid (step 2 nantinya lebih lengkap) +- pipeline dasar bisa menjalankan 10 request bersamaan tanpa crash (smoke test) + +## 6) Definisi File/Artefak + +### 6.1 Yang Harus Dibuat di Step 1 +- `12-fase1-step1-core-foundation-spec.md` (dokumen ini) +- `DECISIONS_LOG.md` (dibuat bersama) +- migration file untuk tabel Step 1 +- endpoint handlers sesuai list di atas +- seed file: + - 2 merchant + - 2 outlet + 2 terminal + - 3 devices (static, dynamic-mqtt, dynamic-api) + +### 6.2 Testing Wajib (manual smoke, tanpa alat tambahan) +- login/admin auth +- CRUD merchant +- CRUD outlet/terminal +- bind/unbind device +- seed device bisa dilihat di GET list + +## 7) Handover ke Step 2 + +Step 2 tidak dimulai sampai: +- endpoint binding dan auth stabil +- transaksi memiliki struktur minimal `transactions + transaction_events` +- event/error format terstandardisasi +- idempotency baseline aktif diff --git a/13-fase1-step2-callback-transaction-spec.md b/13-fase1-step2-callback-transaction-spec.md new file mode 100644 index 0000000..d02e82c --- /dev/null +++ b/13-fase1-step2-callback-transaction-spec.md @@ -0,0 +1,156 @@ +# Fase 1 — Step 2: Transaction Engine + Webhook Callback (Spesifikasi Implementasi) + +Dokumen ini merinci implementasi callback QRIS, state machine transaksi, dan eventing dasar untuk alur static flow. + +## 1) Tujuan Step 2 +- Menerima callback pembayaran dari partner secara aman dan idempotent +- Menyimpan state transaksi dari `initiated` sampai `paid/failed/expired` +- Menyimpan jejak event untuk audit dan observability +- Menyiapkan output event agar notifikasi MQTT dan pelaporan ops bisa dipicu + +## 2) Alur State Transaksi (Static Flow) + +1. `initiated` +2. `awaiting_payment` +3. `paid` +4. `failed` +5. `expired` +6. `reversed` (opsional untuk fase awal) + +### Transisi Utama +- `initiated` -> `awaiting_payment` + - terjadi saat transaksi static dibuat dari mapping merchant/outlet/terminal + - saat ini boleh dilakukan di Step 2 sebagai fallback/manual test +- `awaiting_payment` -> `paid` + - terjadi saat callback partner valid dengan status success +- `awaiting_payment` -> `failed` + - status partner gagal/declined/rejected +- `awaiting_payment` -> `expired` + - expiry waktu lewat atau event timeout dari partner +- `failed` / `paid` adalah terminal state untuk Step 1 + +## 3) API Endpoint: Webhook Callback + +### Endpoint +- `POST /integrations/qris/callback` + +### Request Headers +- `X-Partner-Signature`: signature HMAC (mandatory) +- `X-Partner-Event`: jenis event +- `Idempotency-Key` (jika tersedia dari partner) +- `X-Request-Id` (opsional, gunakan untuk trace) + +### Behavior +- Validasi signature sebelum payload diproses +- Jika signature invalid: response `401` dan tidak membuat transaksi +- Parsing event_type: + - sukses -> candidate `paid` + - gagal -> candidate `failed` + - expired / timeout -> candidate `expired` +- Kunci idempotency: + - key = kombinasi partner_reference + signature_reference/transaction_reference + status event +- Jika sudah diproses: + - kembalikan response sukses idempotent (tanpa mengubah state lagi) + +### Request/Response Format +- Response sukses: + - `status`: `accepted` + - `request_id` + - `event_id` + - `timestamp` +- Response error: + - envelope dari keputusan `D-003` + +## 4) Validasi Callback + +### Required Fields (minimum) +- `partner_reference` +- `amount` +- `currency` (default `IDR`) +- `status` / `payment_status` +- `paid_at` +- `merchant_id` / `terminal_id` / mapping key lain dari payload partner +- `signature` + +### Validasi Data +- `amount > 0` +- transaksi ditemukan: mapping `partner_reference` ke `transactions.partner_reference` (atau lookup `merchant_reference`) +- status yang tidak dikenali -> log warning dan set ke `failed` dengan reason `UNKNOWN_STATUS` +- cek expiry jika ada (`expired_at`) saat status sukses masuk -> jika expired, set `failed` / `expired` + +## 5) Idempotency Strategy + +### Tabel Idempotency +- gunakan `idempotency_keys` yang sudah didefinisikan di Step 1 +- scope: `callback_processing` +- key value: hash dari `(partner_reference + payment_status + partner_txn_id)` +- jika key sudah ada: + - return hasil callback yang sama (replay safe) + +## 6) Transaction Store yang Diperlukan + +### `transactions` +- fields yang harus dipopulasi di Step 2: + - `merchant_id`, `outlet_id`, `terminal_id`, `device_id` (jika static device direct binding) + - `qr_mode` = `static` + - `initiation_mode` = `static` + - `partner_reference` + - `amount`, `currency`, `status` + - `created_at`, `paid_at`, `expired_at`, `updated_at` + +### `transaction_events` +- event wajib untuk setiap perubahan state: + - `event_type`: `INITIATED`, `STATE_CHANGED`, `CALLBACK_RECEIVED`, `CALLBACK_REJECTED`, `CALLBACK_DUPLICATE`, `PUSH_QUEUED` + - `source`: `webhook`, `system`, `admin` + - `payload_json`: raw payload callback + context +- urutan event dipakai untuk debugging urut transaksi + +## 7) Integrasi Ke Step 3 (Notifikasi) +- ketika state transaksi berubah ke `paid`, emit event internal: + - `transaction.paid` + - payload ringan: `transaction_id`, `merchant_id`, `device_id`, `amount`, `currency`, `partner_reference`, `paid_at` +- event ini menjadi trigger awal untuk notification orchestrator Step 3 +- jika transaction tidak punya binding aktif: + - event tetap tercatat dengan reason `NO_ACTIVE_BINDING` + +## 8) Error Code (saran) +- `WEBHOOK_SIGNATURE_INVALID` +- `TRANSACTION_NOT_FOUND` +- `PAYMENT_STATUS_INVALID` +- `DUPLICATE_WEBHOOK` +- `CALLBACK_PARTNER_DATA_INVALID` +- `IDEMPOTENCY_MISSING_KEY` + +## 9) Contoh Pseudocode Alur Handler + +1. terima payload +2. validasi signature +3. normalisasi `request_id` dan `event_id` +4. ambil `partner_reference` -> cari transaksi +5. cek idempotency callback +6. jika duplikat: simpan `transaction_events` duplicate dan return success +7. validasi transaksi + status +8. update status + paid_at jika sukses +9. simpan event (`CALLBACK_RECEIVED`, `STATE_CHANGED`) +10. emit `transaction.paid` (hanya saat sukses) +11. commit +12. return response success + +## 10) API/Query untuk Operasional (minimal) +- `GET /admin/transactions` tetap support filter `status`, `merchant_id`, `from`, `to`, `partner_reference` +- `GET /admin/transactions/{transactionId}` menampilkan event timeline +- `GET /admin/transactions/{transactionId}/events` opsional di Step 2 untuk debug + +## 11) Acceptance Criteria Step 2 +- webhook menerima callback valid dan mengubah state transaksi ke `paid` +- callback duplicate tidak menambah perubahan state tambahan +- signature invalid ditolak `401` +- jika partner reference tidak ditemukan, response tetap deterministik dan tidak crash +- setiap perubahan state menghasilkan `transaction_events` minimal satu baris +- setiap perubahan ke `paid` menghasilkan event internal `transaction.paid` untuk Step 3 + +## 12) Keberhasilan Handoff ke Step 3 +- state machine stabil dan bisa dipakai unit/integration smoke +- transaksi berhasil dan gagal terekam lengkap +- callback replay aman +- notifikasi orchestrator dapat subscribe pada event `transaction.paid` diff --git a/14-fase1-step3-notification-spec.md b/14-fase1-step3-notification-spec.md new file mode 100644 index 0000000..e13169c --- /dev/null +++ b/14-fase1-step3-notification-spec.md @@ -0,0 +1,118 @@ +# Fase 1 — Step 3: Notifikasi MQTT Dasar (Static Payment) + +Dokumen ini memandu implementasi notifikasi success payment ke device berbasis event transaksi `paid`. + +## 1) Tujuan Step 3 +- Memastikan setiap transaksi `paid` memicu notifikasi sukses ke device yang valid +- Menyimpan status delivery dan retry agar operasional bisa dipantau +- Menyediakan endpoint admin untuk retry manual + +## 2) Event Source +- Trigger dari Step 2: event internal `transaction.paid` +- Sumber wajib: event bus internal / service method call dari transaction service (untuk fase awal) + +## 3) Mapping Notifikasi +- Input: `transaction_id`, `merchant_id`, `device_id`, `amount`, `currency`, `paid_at`, `reference` +- Output: payload MQTT `payment_success` ke topik: + - `devices/{deviceId}/downlink/payment/success` +- Hanya kirim jika device memiliki binding aktif (`device_bindings.active_flag = true`) + +## 4) Tabel dan Status + +### `notifications` +Kolom inti: +- `id` +- `transaction_id` +- `device_id` +- `delivery_channel` = `mqtt` +- `payload_type` = `payment_success` +- `delivery_status`: `queued | sent | acknowledged | failed | retrying` +- `retry_count` +- `ack_status`: `pending | received | not_supported | not_needed` +- `sent_at` +- `ack_at` + +Status transisi: +- dibuat -> `queued` +- publish sukses -> `sent` +- no ack handler tersedia pada fase awal -> `acknowledged` otomatis atau `failed` sesuai konfigurasi broker +- publish gagal -> `retrying` +- retry 3x gagal -> `failed` + +## 5) Payload Kontrak MQTT (Step 3) +```json +{ + "message_type": "payment_success", + "event_id": "evt_123", + "transaction_id": "tx_123", + "merchant_name": "Toko Berkah", + "amount": 50000, + "currency": "IDR", + "paid_at": "2026-05-23T10:02:10Z", + "audio_text": "Pembayaran diterima lima puluh ribu rupiah", + "display_text": "Pembayaran diterima Rp50.000" +} +``` + +## 6) Alur Proses Notifikasi +1. Step 2 publish event `transaction.paid` +2. Notification Orchestrator menerima event +3. Validasi `device_id` ada dan memiliki binding aktif +4. Buat record `notifications` dengan status `queued` +5. Publish ke `devices/{deviceId}/downlink/payment/success` +6. Jika sukses, update `sent` dan `sent_at` +7. Jika gagal: + - hitung retry (exponential/linear simple backoff) + - update `retrying` + - lanjut retry maksimum 3x +8. Jika 3x gagal tetap `failed` +9. Return result ke endpoint retry bila dipanggil manual + +## 7) Retry Policy Fase 1 (Sederhana) +- max_attempt = 3 +- retry_interval_seconds = 15, 30, 60 (tetap untuk fase awal) +- retry_count disimpan di `notifications.retry_count` +- tiap percobaan harus idempotent via `event_id` dan `transaction_id` + +## 8) Endpoint Admin +- `POST /admin/transactions/{transactionId}/retry-notification` + - validasi transaksi status harus `paid` + - akan membuat attempt publish baru jika `delivery_status` bukan `acknowledged` + - response: + - `transaction_id` + - `notification_id` + - `delivery_status` + - `next_retry_at` + +## 9) Endpoint Ops Monitoring Minimal +- `GET /admin/devices/{deviceId}/notifications` + - list notif by device + `delivery_status` +- `GET /admin/transactions/{transactionId}` + - tampilkan link notification + timeline status + +## 10) Idempotency dan De-Dupe +- kombinasi key notifikasi: + - `transaction_id + event_id` +- jika event duplicate: + - tidak membuat record baru + - return existing notif status +- publish duplicate harus ditolak di orchestrator (guard). + +## 11) Error Code (saran) +- `NOTIFICATION_DEVICE_UNAVAILABLE` +- `NOTIFICATION_NO_ACTIVE_BINDING` +- `NOTIFICATION_PUBLISH_FAILED` +- `NOTIFICATION_RETRY_EXHAUSTED` + +## 12) Acceptance Criteria Step 3 +- transaksi `paid` menghasilkan event `payment_success` ke topik device +- notification record terbentuk untuk setiap transaksi `paid` +- perangkat yang tidak terikat aktif tidak dipush (state `failed` dengan reason) +- retry berjalan saat publish gagal +- endpoint retry admin merubah status dari `failed/retrying` menjadi `sent` atau tetap `failed` setelah limit + +## 13) Catatan Implementasi Step 3 (Tanpa Design) +- gunakan QoS 1 untuk topic payment success +- hindari retained message untuk event sukses pembayaran +- logging harus menyertakan `transaction_id`, `device_id`, `event_id`, `request_id` +- payload audit (audio/display) boleh berupa default ID locale diff --git a/15-pr-template-fase1-step1-2-3.md b/15-pr-template-fase1-step1-2-3.md new file mode 100644 index 0000000..a9987aa --- /dev/null +++ b/15-pr-template-fase1-step1-2-3.md @@ -0,0 +1,55 @@ +# PR Template — Fase 1 (Step 1–3) + +Gunakan template ini untuk tiap PR agar konsisten dan cepat di-review. + +## 1) Judul dan Lingkup +- [ ] Scope: Fase 1 Step: + - [ ] Step 1 (Core Foundation) + - [ ] Step 2 (Callback & Transaction State) + - [ ] Step 3 (Notification) +- [ ] Ringkas apa yang diubah + +## 2) Perubahan Teknis +- [ ] Service/module yang berubah +- [ ] Endpoint yang ditambah/diubah +- [ ] Tabel/model yang ditambah/diubah +- [ ] Event yang ditambah/diubah +- [ ] Kontrak error/response yang ikut berubah + +## 3) Acceptance (wajib minimal 3) +Isi satu checklist dari scope PR: +- [ ] Happy path utama sesuai dokumentasi: + - [ ] Step 1: CRUD entity dan binding berhasil + - [ ] Step 2: callback sukses -> status `paid` + - [ ] Step 3: paid -> notifikasi record + publish MQTT +- [ ] Edge case: + - [ ] duplikasi idempotency + - [ ] invalid signature (callback) + - [ ] no active binding +- [ ] Idempotency case: + - [ ] request/callback duplikat tidak menambah state/record tidak sesuai aturan + +## 4) Kesesuaian Dokumen +- [ ] Mengikuti [12-fase1-step1-core-foundation-spec.md](/home/wira/work/codex/qris-soundbox-platform/12-fase1-step1-core-foundation-spec.md) +- [ ] Mengikuti [13-fase1-step2-callback-transaction-spec.md](/home/wira/work/codex/qris-soundbox-platform/13-fase1-step2-callback-transaction-spec.md) +- [ ] Mengikuti [14-fase1-step3-notification-spec.md](/home/wira/work/codex/qris-soundbox-platform/14-fase1-step3-notification-spec.md) +- [ ] Decision references: +- [ ] [D-005] Idempotency Rule +- [ ] [D-010] Webhook Signature dan Parsing +- [ ] [D-012] Evented Transaction State Change +- [ ] [D-013] Trigger Notification dari event `paid` + +## 5) Bukti Eksekusi +- [ ] Screenshot/request log dari endpoint manual smoke test +- [ ] Log contoh transaksi sukses (`callback` -> `paid` -> `notification sent`) +- [ ] Lampirkan payload/response sample untuk endpoint utama + +## 6) Dampak +- [ ] Ada migration schema? +- [ ] Ada backward compatibility concern? +- [ ] Berikan langkah rollback sederhana (jika perlu) + +## 7) Catatan Security/Compliance +- [ ] Signature verification tetap aman +- [ ] trace/error format tetap konsisten +- [ ] audit log untuk operasi penting tercatat diff --git a/16-fase1-step4-monitoring-spec.md b/16-fase1-step4-monitoring-spec.md new file mode 100644 index 0000000..64a9aed --- /dev/null +++ b/16-fase1-step4-monitoring-spec.md @@ -0,0 +1,154 @@ +# Fase 1 — Step 4: Device & Ops Monitoring + +Dokumen ini merinci implementasi heartbeat, status device, list/detail monitoring, dan KPI dasar admin untuk selesai dari Step 4. + +## 1) Tujuan Step 4 +- Menyediakan visibilitas operasional untuk device dan transaksi di admin +- Menjadikan heartbeat sebagai sumber status online device +- Menyediakan metrik minimum yang dipakai tim operasi harian +- Menyediakan jalur retry/penelusuran notifikasi pending + +## 2) Heartbeat Ingestion + +### Endpoint +- `POST /device/heartbeat` + +### Request +```json +{ + "device_id": "sbx_001", + "timestamp": "2026-05-23T10:00:00Z", + "firmware_version": "1.0.3", + "network_strength": 78, + "battery_level": 92, + "state": "idle" +} +``` + +### Rules +- Validate token device. +- Simpan ke `device_heartbeats`. +- Update `devices.last_seen_at = timestamp`. +- Jika field missing, simpan `null` dan tetap catat validasi non-kritis: + - `network_strength` / `battery_level` boleh null +- Return response: + - `request_id` + - `device_id` + - `server_time` + +### Status Derivation (Step 4) +- `online`: `last_seen_at >= now - 90 detik` +- `degraded`: `network_strength < 40` **atau** `battery_level < 20` +- `stale`: `last_seen_at < now - 90 detik` dan >= `now - 15 menit` +- `offline`: `last_seen_at < now - 15 menit` +- Kombinasi prioritas: `offline`/`stale`/`degraded`/`online` (offline paling dominan) + +## 3) Endpoint Monitoring Dasar + +### List Device +- `GET /admin/devices` +- query: + - `status` + - `vendor` + - `communication_mode` + - `merchant_id` + - `q` (search) +- response: include: + - `status` + - `last_seen_at` + - `heartbeat_count_24h` + - binding current summary + +### Device Detail +- `GET /admin/devices/{deviceId}` +- response include: + - metadata device + - binding aktif (merchant/outlet/terminal) + - latest heartbeat + - derived status + device health + - `notifications` latest + +### Heartbeat List +- `GET /admin/devices/{deviceId}/heartbeats` +- filter: + - `from`, `to`, `state` +- sort: latest first + +### Transaction List/Detail (minimal lanjutan Step 4) +- `GET /admin/transactions` + - filter `status`, `merchant_id`, `from`, `to`, `partner_reference` +- `GET /admin/transactions/{transactionId}` + - include `transaction_events` timeline + +## 4) KPI Dashboard Minimal (admin) + +### Endpoint +- `GET /admin/dashboard/summary` + +### Metrics +- `transactions_today` +- `success_rate_today` (paid / total attempt) +- `active_devices` +- `pending_notifications` +- `devices_stale` +- `devices_offline` + +### Data Source +- transaksi hari ini dari `transactions.created_at` +- device status dari `devices.last_seen_at` +- pending notification dari `notifications.delivery_status in ('queued','retrying')` + +## 5) Admin Retry-notification (Step 4) + +### Endpoint +- `POST /admin/transactions/{transactionId}/retry-notification` + +### Flow +1. validasi transaksi exists dan status `paid` +2. ambil latest notification `delivery_status` != `acknowledged` +3. jika `delivery_status` = `queued/sent/failed/retrying`: + - trigger publish ulang + - tambah `retry_count` +4. simpan hasil status setelah publish +5. return: + - `transaction_id` + - `notification_id` + - `delivery_status` + - `next_retry_at` + +## 6) Monitoring Notifikasi Gagal +- endpoint ops: + - `GET /admin/notifications/failed` +- filter: + - `device_id` + - `from` + - `to` +- response: + - `notification_id`, `transaction_id`, `device_id`, `delivery_status`, `retry_count`, `reason` + +## 7) Query Efficiency (Prioritas) +- buat index: + - `devices(status, last_seen_at)` + - `device_heartbeats(device_id, received_at desc)` + - `transactions(status, created_at)` + - `notifications(delivery_status, created_at)` + +## 8) Acceptance Criteria Step 4 +- status device berubah sesuai aturan `online/degraded/stale/offline` +- heartbeat tercatat dan `devices.last_seen_at` terupdate +- dashboard summary menampilkan 5 metrik minimal +- `GET /admin/transactions` dan device/transaction detail menampilkan data yang dibutuhkan +- endpoint retry-notification memicu publish ulang untuk status non-final + +## 9) Error Handling +- `DEVICE_NOT_FOUND` +- `INVALID_HEARTBEAT_PAYLOAD` +- `TRANSACTION_NOT_ELIGIBLE_FOR_RETRY` +- `DASHBOARD_CALCULATION_ERROR` (fallback graceful: return nilai default 0 jika query gagal) + +## 10) Keluaran Step 4 (End-State) +- operator bisa cek: + - device aktif/offline + - transaksi dan status payment + - kegagalan notifikasi +- sistem tetap bisa operasi manual meski tanpa UI dashboard kompleks diff --git a/17-fase1-implementation-task-pack.md b/17-fase1-implementation-task-pack.md new file mode 100644 index 0000000..5916917 --- /dev/null +++ b/17-fase1-implementation-task-pack.md @@ -0,0 +1,157 @@ +# Fase 1 — Task Pack (Urutan Implementasi Langsung) + +Dokumen ini adalah daftar kerja yang bisa langsung dipakai sebagai urutan PR/ticket. + +## Paket A — Foundation Core (Step 1) + +## A.1 PR: Shared Foundation + Middleware +- scope: `shared`, tracing, request/error wrapper, idempotency table baseline, audit helper +- target file: + - shared error formatter + - request_id/trace_id generator + - idempotency service CRUD/read +- acceptance: + - semua response punya `request_id` di body meta + - idempotency write/read bisa ditest di local endpoint sample + +## A.2 PR: Auth Baseline +- scope: login/admin token minimal, auth middleware, role dasar +- acceptance: + - endpoint admin menolak tanpa token (`401`) + - token valid memberi akses CRUD Step 1 + +## A.3 PR: Merchant CRUD +- scope: `POST /admin/merchants`, `GET /admin/merchants`, `GET /admin/merchants/{id}`, `PATCH /admin/merchants/{id}` +- acceptance: + - create/read/update berhasil + - duplikasi create dengan idempotency key tidak membuat duplicate + +## A.4 PR: Outlet + Terminal +- scope: + - `POST /admin/merchants/{merchantId}/outlets` + - `GET /admin/outlets`, `GET /admin/outlets/{id}`, `PATCH /admin/outlets/{id}` + - `POST /admin/outlets/{outletId}/terminals` + - `GET /admin/terminals`, `GET /admin/terminals/{id}`, `PATCH /admin/terminals/{id}` +- acceptance: + - outlet/terminal berkait dengan merchant/outlet parent + - error jika parent tidak valid + +## A.5 PR: Device Registry + Binding +- scope: + - `POST /admin/devices`, `GET /admin/devices`, `GET /admin/devices/{id}`, `PATCH /admin/devices/{id}` + - `POST /admin/devices/{id}/bind` + - `POST /admin/devices/{id}/unbind` +- acceptance: + - bind menghasilkan `device_bindings` aktif tunggal + - unbind menonaktifkan binding aktif + +## A.6 PR: Migration + Seed Phase 1 +- scope: + - tables dari Step 1 MVP + - seed: 2 merchant, 2 outlet, 2 terminal, 3 device + - user/admin seed baseline +- acceptance: + - sistem startup clean dengan data seed + - query dasar berjalan + +## Paket B — Transaction + Webhook (Step 2) + +## B.1 PR: Transaction Core & State Machine +- scope: + - tabel `transactions`, `transaction_events` +- states: initiated, awaiting_payment, paid, failed, expired +- acceptance: + - create transaksi dasar + - timeline event tercatat saat state berubah + +## B.2 PR: Callback Receiver +- scope: `POST /integrations/qris/callback` +- rules: + - signature validation + - idempotency callback + - duplicate replay handling +- acceptance: + - valid callback -> state paid + - duplicate callback tidak mengubah state + - signature salah -> 401 + +## B.3 PR: Event Emission (`transaction.paid`) +- scope: + - emit internal event saat status bayar sukses +- acceptance: + - setiap successful callback menghasilkan event sekali (idempotent) + +## Paket C — Notification (Step 3) + +## C.1 PR: Notification Orchestrator +- scope: + - listen `transaction.paid` + - buat `notifications` record +- acceptance: + - transaksi paid menghasilkan record notification + +## C.2 PR: MQTT Publisher +- scope: + - publish ke `devices/{deviceId}/downlink/payment/success` + - payload sesuai kontrak +- acceptance: + - publish sukses update `delivery_status=sent` + - fallback jika publish gagal -> `retrying/failed` + +## C.3 PR: Retry + Admin Retry API +- scope: + - retry loop (15/30/60) + - `POST /admin/transactions/{transactionId}/retry-notification` +- acceptance: + - status update berjalan dari retrying ke sent/gagal + +## Paket D — Device & Ops Monitoring (Step 4) + +## D.1 PR: Heartbeat Ingestion +- scope: + - `POST /device/heartbeat` + - update `devices.last_seen_at` + - simpan `device_heartbeats` +- acceptance: + - status heartbeat dan latest heartbeat terlihat per device + +## D.2 PR: Device Listing + Detail +- scope: + - `GET /admin/devices` + - `GET /admin/devices/{id}` + - derived status online/stale/offline/degraded +- acceptance: + - list/detail bisa difilter dan menampilkan status + +## D.3 PR: Transaction List/Detail + Heartbeat History +- scope: + - `GET /admin/transactions`, `GET /admin/transactions/{id}` + - `GET /admin/devices/{id}/heartbeats` +- acceptance: + - timeline event dan heartbeat history tampil + +## D.4 PR: Dashboard KPI + Failed Notification View +- scope: + - `GET /admin/dashboard/summary` + - `GET /admin/notifications/failed` +- acceptance: + - 5 metrik minimum tampil + - daftar notifikasi failed bisa difilter + +## Urutan Eksekusi Terbuka +1. A.1 → A.2 → A.6 (mendukung semua service berikutnya) +2. A.3 → A.4 → A.5 +3. B.1 → B.2 → B.3 +4. C.1 → C.2 → C.3 +5. D.1 → D.2 → D.3 → D.4 + +## Aturan Dependensi +- B paket tidak boleh dimulai jika A.5 belum selesai (binding + transaksi dasar belum valid) +- C paket tidak boleh dimulai jika B.3 belum siap +- D paket bisa jalan paralel dengan C jika A dan B core sudah jalan + +## Exit Criteria Fase 1 (siap Step 2) +- [ ] static paid flow jalan end-to-end +- [ ] tidak ada duplicate state via callback +- [ ] notification ada untuk paid tx +- [ ] device heartbeat dan status ops visible diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md new file mode 100644 index 0000000..23431bb --- /dev/null +++ b/CODEX_HANDOFF.md @@ -0,0 +1,55 @@ +# CODEx Handoff — QRIS Soundbox Platform + +## Current status +- Fokus terakhir: sinkronisasi UI dan smoke test pada stack yang sudah aktif. +- Implementasi backend dan UI sudah mulai dikerjakan di repository (tidak lagi hanya dokumentasi). +- Smoke test Fase1 jalannya: + - `smoke:cleanup` ✅ + - `smoke:flow` ❌ saat dijalankan langsung (karena server belum jalan di `localhost:3100`) + - `smoke:e2e` ✅ setelah server auto-start di port 3100 (cleanup + full flow berhasil). + +## 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. +- [DECISIONS_LOG.md](/home/wira/work/codex/qris-soundbox-platform/DECISIONS_LOG.md) + - Sudah memuat keputusan merchant bank account: kini arah keputusan ke rekening milik merchant (bukan escrow/terpusat) agar menghindari kebutuhan izin tambahan di awal. + +## 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`. + +## Urutan kerja selanjutnya (disarankan) +1. Backend/backend sanity lanjut dari titik terakhir: + - Pastikan endpoint untuk sinkronisasi screen sudah stabil (terutama filter/search transaksi dan heartbeat/ events). + - Lengkapi pemeriksaan 1–3 (dalam flow kamu, yaitu smoke point 1–3) yang belum dites manual via UI. +2. Ambil data smoke yang sudah tercipta di e2e (`merchant`, `device`, `transaction`) lalu smoke-test: + - Merchant detail page + - Merchant list/filter + - Device technical detail + - Device list + heartbeat view + - Transaction history + outlet/terminal filter +3. Jika ada regresi, cek log server di `/tmp/qris-smoke-e2e-server.log`. +4. Setelah UI flow stabil, lanjut fitur ops: + - `A.6 Migration + Seed` (jika ada gap) + - `B.1–B.3` + `C.1–C.3` + `D.1–D.4` untuk full DoD Fase 1. + +## 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. + +## Selesai untuk off +- Sudah ada gabungan perubahan di repo: doc + UI + API integration + smoke validation. diff --git a/DECISIONS_LOG.md b/DECISIONS_LOG.md new file mode 100644 index 0000000..7ecc64e --- /dev/null +++ b/DECISIONS_LOG.md @@ -0,0 +1,194 @@ +# Decisions Log — QRIS Soundbox Platform + +Log keputusan arsitektur dan implementasi yang harus dijadikan acuan eksekusi. + +## Format Entri +- ID: [D-XXX] +- Tanggal: +- Keputusan: +- Alasan: +- Dampak / implikasi: +- Status: + +## D-001 — Basis Implementasi Fase 0 dan 1 +- Tanggal: 2026-05-23 +- Keputusan: + - Menjalankan eksekusi langsung berdasarkan fase roadmap, tanpa pembuatan jadwal rinci. +- Alasan: + - Tim sudah punya pembagian fase yang jelas dan siap mulai implementasi langsung. +- Dampak / implikasi: + - Fokus pada deliverable per fase dan DoD, bukan timeline. +- Status: Active + +## D-002 — Scope Fase 1 Step 1 +- Tanggal: 2026-05-23 +- Keputusan: + - Step 1 Fase 1 dibatasi pada core foundation: auth baseline, schema MVP, merchant/outlet/terminal/device/binding, observability dasar. +- Alasan: + - Memastikan jalur static payment bisa dipersiapkan stabil sebelum dynamic flow. +- Dampak / implikasi: + - Fitur lain (settlement, dynamic QR, merchant portal) ditunda sampai Step 1 stabil. +- Status: Active + +## D-003 — API Contract Error Standard +- Tanggal: 2026-05-23 +- Keputusan: + - Semua response error mengikuti envelope seragam: + - `code`, `message`, `details`, `request_id`, `timestamp`. +- Alasan: + - Memudahkan debug dan tracing lintas service/device. +- Dampak / implikasi: + - Semua handler API harus mematuhi middleware response formatter yang sama. +- Status: Active + +## D-004 — Traceability Requirement +- Tanggal: 2026-05-23 +- Keputusan: + - Semua request harus membawa `request_id`; webhook/device callback harus dihubungkan dengan `trace_id` jika multi-step. +- Alasan: + - Root-cause analysis perlu konteks end-to-end dari callback sampai notifikasi. +- Dampak / implikasi: + - Framework logging dan parser event wajib men-generate field ini secara konsisten. +- Status: Active + +## D-005 — Idempotency Rule +- Tanggal: 2026-05-23 +- Keputusan: + - Setiap path sensitif terhadap duplicate (create merchant/create device/binding/webhook request/dynamic QR nanti) wajib men-support idempotency key/table. +- Alasan: + - Menghindari double create, double binding, dan state drift akibat retry. +- Dampak / implikasi: + - Menambah kebutuhan service/DB untuk `idempotency_keys` sejak awal Fase 1. +- Status: Active + +## D-006 — Device Binding +- Tanggal: 2026-05-23 +- Keputusan: + - Notifikasi pembayaran hanya boleh dipush ke device dari binding aktif yang valid (active_flag=true). +- Alasan: + - Mencegah notifikasi salah kirim jika device pernah dipindahkan antar outlet/terminal. +- Dampak / implikasi: + - Query notification selalu harus resolve binding aktif secara explicit. +- Status: Active + +## D-007 — Fallback Strategy untuk Fase 1 +- Tanggal: 2026-05-23 +- Keputusan: + - Retry MQTT pada Fase 1 memakai retry sederhana (fixed/backoff pendek), tanpa DLQ kompleks. +- Alasan: + - Menghemat effort awal sambil menjaga fitur core berjalan. +- Dampak / implikasi: + - Fase 4 mengelola policy DLQ dan retry matang. +- Status: Active + +## D-008 — Minimal RBAC untuk Mulai Eksekusi +- Tanggal: 2026-05-23 +- Keputusan: + - Implement RBAC baseline minimum di Fase 1 (admin-only) sambil menjaga token device terpisah. +- Alasan: + - Mengurangi risiko akses silang awal tanpa memperlambat pengembangan. +- Dampak / implikasi: + - Permission matrix akan disempurnakan di Fase 4. +- Status: Active + +## D-009 — Dokumentasi Eksekusi Step 1 +- Tanggal: 2026-05-23 +- Keputusan: + - Spesifikasi detail Step 1 dituangkan di `12-fase1-step1-core-foundation-spec.md`. +- Alasan: + - Menghindari ketidakpastian saat tim mulai coding. +- Dampak / implikasi: + - Semua implementer wajib merujuk dokumen ini saat menyiapkan branch dan PR. +- Status: Active + +## D-010 — Webhook Signature dan Parsing +- Tanggal: 2026-05-23 +- Keputusan: + - Semua callback harus divalidasi signature-nya terlebih dahulu; jika tidak valid, respons harus `401` dan tidak boleh melakukan perubahan state apapun. +- Alasan: + - Payment callback adalah sumber trust utama dan harus dijaga dari spoofing/replay. +- Dampak / implikasi: + - Service callback wajib mengimplementasikan validator HMAC (atau skema cryptographic sesuai partner) sebelum mapping transaksi. +- Status: Active + +## D-011 — Callback Idempotency di Level Transaksi +- Tanggal: 2026-05-23 +- Keputusan: + - Callback duplikat harus diidentifikasi deterministik dari `partner_reference + payment_status + partner_event_id` dan diperlakukan idempotent. +- Alasan: + - Callback retry sangat umum dari partner dan dapat memicu double state update. +- Dampak / implikasi: + - state transition dilakukan hanya jika perubahan state valid sesuai mesin state. +- Status: Active + +## D-012 — Evented Transaction State Change +- Tanggal: 2026-05-23 +- Keputusan: + - Setiap perubahan state transaksi harus menghasilkan `transaction_events` agar Step 3 dan observability bisa berjalan. +- Alasan: + - Auditability dan troubleshooting menuntut timeline per transaksi. +- Dampak / implikasi: + - update transaksi tanpa menulis event dianggap bug di Step 1. +- Status: Active + +## D-013 — Terminal Event `paid` sebagai Trigger Notification +- Tanggal: 2026-05-23 +- Keputusan: + - Notifikasi pembayaran di Step 3 harus dipicu dari event internal status `paid`, bukan dari polling callback. +- Alasan: + - Menghindari race dan duplikasi notifikasi. +- Dampak / implikasi: + - Implementasi Step 3 harus subscribe dan consume event transaction paid. +- Status: Active + +## D-014 — Retry Notifikasi Fase 1 +- Tanggal: 2026-05-23 +- Keputusan: + - Step 3 menggunakan retry dasar maksimum 3 kali dengan jadwal 15/30/60 detik. +- Alasan: + - Menyeimbangkan reliability dan kecepatan operasional tanpa kompleksitas DLQ penuh pada fase awal. +- Dampak / implikasi: + - `notifications.retry_count` wajib ditulis dan dibatasi maksimal 3. +- Status: Active + +## D-015 — Notifikasi Tanpa Ack Fase 1 +- Tanggal: 2026-05-23 +- Keputusan: + - Pada fase 1, absence of MQTT ack tidak dianggap error akhir; status sukses ditentukan dari publish outcome, bukan ack dari device. +- Alasan: + - Device ecosystem belum konsisten untuk ack schema, dan goal fase 1 adalah stabilitas flow utama. +- Dampak / implikasi: + - `ack_status` bisa `not_supported`, dan operasi tetap lanjut dengan monitoring via retry/publish status. +- Status: Active + +## D-016 — Heartbeat Status Threshold Fase 1 +- Tanggal: 2026-05-23 +- Keputusan: + - Definisi status device ditetapkan: online (<90s), degraded (signal/battery buruk), stale (<15 menit), offline (>15 menit) berdasarkan `last_seen_at`. +- Alasan: +- Konsistensi status dibutuhkan untuk ops triage cepat. +- Dampak / implikasi: +- Setiap list/detail device menampilkan status yang dihitung dari rule yang sama. +- Status: Active + +## D-017 — Dashboard KPI Fase 1 +- Tanggal: 2026-05-23 +- Keputusan: + - Dashboard ops minimum menampilkan: transaksi hari ini, success rate hari ini, active devices, pending notifications, stale/offline counts. +- Alasan: + - Tim operasi perlu indikator cepat tanpa menunggu custom analytics. +- Dampak / implikasi: +- Endpoint summary dashboard wajib dihitung dari data transaksi/device/notification inti. +- Status: Active + +## D-018 — Pencairan Dana Non-Tersentral per Merchant +- Tanggal: 2026-05-24 +- Keputusan: + - Pencairan dana tidak dilakukan di rekening perusahaan; setiap merchant memakai rekening tujuan sendiri (atau reference payout miliknya) untuk settlement. +- Alasan: + - Menghindari ketergantungan izin/operasional PJP di tahap awal dan mempercepat launch MVP. +- Dampak / implikasi: + - Schema dan model onboarding merchant memakai `payout_account_reference`/rekening merchant, bukan rekening vault pusat. + - Modul settlement platform difokuskan ke rekonsiliasi, status payout, dan visibility, bukan pengelolaan rekening pusat. + - Callback payout dan payout execution dianggap partner-oriented (tergantung integrasi penyedia), bukan core di fase awal. +- Status: Active diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d5c312 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# QRIS Soundbox Platform Package + +Paket ini berisi blueprint final v1 untuk platform merchant aggregator QRIS + soundbox universal. + +## Isi paket +- 01-executive-blueprint.md +- 02-system-architecture.md +- 03-domain-modules.md +- 04-device-flows.md +- 05-api-contract-draft.md +- 06-mqtt-contract-draft.md +- 07-database-schema-draft.md +- 08-implementation-roadmap.md +- 09-screen-inventory.md +- 10-design-blueprint.md +- 11-low-fi-wireframes.md + +## Tujuan +Dokumen ini dibuat supaya tim bisa langsung mulai: +- desain UI/UX +- breakdown engineering +- desain backend +- integrasi device +- cicil implementasi per fase + +## Quick Start Implementasi (Lanjutan dari CODEx Handoff) + +- Backend bootstrap Fase 1 sudah dibuat di `src/`. +- Fitur awal yang sudah aktif: + - request context + `request_id` di middleware + - error envelope (`code`, `message`, `details`, `request_id`, `timestamp`) + - auth token minimal untuk endpoint admin + - middleware idempotency untuk endpoint sensitif +- endpoint awal: + - `GET /health` + - `GET /admin/health` (dengan `Authorization: Bearer `) + - `POST /admin/login` + - `POST /admin/sample-idempotent` + - `POST /admin/merchants` + - `GET /admin/merchants` + - `GET /admin/merchants/{id}` + - `PATCH /admin/merchants/{id}` + - `POST /admin/merchants/{merchantId}/outlets` + - `POST /admin/merchants/{merchantId}/approve` + - `POST /admin/merchants/{merchantId}/reject` + - `GET /admin/outlets` + - `GET /admin/outlets/{id}` + - `POST /admin/outlets/{outletId}/terminals` + - `GET /admin/terminals` + - `GET /admin/terminals/{id}` + - `POST /admin/devices` + - `GET /admin/devices` + - `GET /admin/devices/{id}` + - `POST /admin/devices/{id}/bind` + - `POST /admin/devices/{id}/unbind` + - `POST /admin/devices/{id}/commands` + - `GET /admin/devices/{id}/commands` + - `GET /admin/devices/{id}/commands/{commandId}` + - `GET /admin/devices/{id}/notifications` + - `GET /admin/transactions` + - `GET /admin/transactions/{transactionId}` + - `POST /admin/transactions` + - `GET /admin/transactions/{transactionId}/events` + - `POST /admin/transactions/{transactionId}/retry-notification` + - `POST /admin/seed` + +### Menjalankan lokal + +```bash +npm install +cp .env.example .env +npm run dev +npm run build && npm start +``` + +### Cleanup data smoke test + +```bash +PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform npm run smoke:cleanup +``` + +Cleanup hanya menarget entitas smoke (`Smoke Merchant`, `PR-`, `DEV-`) agar data seed demo tidak ikut terhapus. + +```bash +PORT=3100 ADMIN_TOKEN=admin-dev-token DEVICE_TOKEN=device-dev-token INTEGRATION_WEBHOOK_SECRET=dev-callback-secret PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform npm run smoke:flow +``` + +Smoke flow akan melakukan create merchant/device/transaction + heartbeat + callback paid + verifikasi event/heartbeat/notification. + +### Smoke test end-to-end (bootstrap + flow + cleanup) + +```bash +PGHOST=127.0.0.1 PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=qris_soundbox_platform npm run smoke:e2e +``` + +Perintah ini menjalankan: + +- cleanup data smoke +- start server lokal di port 3100 +- wait sampai `/health` aktif +- jalankan flow smoke lengkap +- hentikan server setelah selesai + +### Endpoint device lain + +- `POST /device/commands/ack` + +### Quick screen preview +- `GET /ui` => katalog halaman UI dari seluruh `design/*`. +- `GET /ui/:page` => buka halaman berdasarkan slug (contoh: `/ui/admin-login`, `/ui/admin-dashboard-overview`, `/ui/merchant-login`). + +Langkah berikutnya sesuai handoff: lanjut ke Task Pack B.1 (transaction core), lalu C.1–C.3. diff --git a/design/admin_application_review_detail/code.html b/design/admin_application_review_detail/code.html new file mode 100644 index 0000000..33d71c5 --- /dev/null +++ b/design/admin_application_review_detail/code.html @@ -0,0 +1,448 @@ + + + + + + + + + + + + + + + + +
+ +
+
+ +

Review: Coffee & Co. Jakarta

+
+
+
+timer +Pending Review +
+
+ + + +
+
+ +
+ +
+
+ +
+
+business +

Business Details

+
+
+
+ +

PT. Kopi Nusantara Abadi

+
+
+ +

Coffee & Co. Jakarta

+
+
+ +

Food & Beverage (Café)

+
+
+ +

Limited Liability (PT)

+
+
+ +

Jl. Senopati No. 12, Kebayoran Baru, Jakarta Selatan, 12190

+
+
+
+ +
+
+person +

Person in Charge (PIC)

+
+
+
+ +

Bambang Wijaya

+
+
+ +

3174092803850001

+
+
+ +

bambang.w@coffeeandco.id

+
+
+ +

+62 812 3456 7890

+
+
+
+ +
+
+account_balance +

Bank Settlement Info

+
+
+
+ +

Bank Central Asia (BCA)

+
+
+ +

8820 123 456

+
+
+ +

PT KOPI NUSANTARA ABADI

+
+
+
+ +
+
+info +

Application Audit

+
+
+
+RAW PAYLOAD v2.4 + +
+
{
+  "application_id": "APP-2023-9912",
+  "submission_date": "2023-11-24T10:22:31Z",
+  "device_provisioning": true,
+  "mcc_code": "5812",
+  "risk_score": 0.12,
+  "onboarding_channel": "Web Portal Direct"
+}
+
+
+
+
+ +
+
+

+description + Document Verification +

+ +
+
+IDENTITY CARD (KTP) + +
+
+ +
+ +
+
+
+ +
+
+TAX ID (NPWP) + +
+
+ +
+ +
+
+
+ +
+
+STORE FRONT PHOTO + +
+
+ +
+ +
+
+
+
+
+
+ +
+
+
+Assigned To +Self (Admin #042) +
+
+
+ + +
+
+ + +
+ + \ No newline at end of file diff --git a/design/admin_application_review_detail/screen.png b/design/admin_application_review_detail/screen.png new file mode 100644 index 0000000..3d816be Binary files /dev/null and b/design/admin_application_review_detail/screen.png differ diff --git a/design/admin_dashboard_overview/code.html b/design/admin_dashboard_overview/code.html new file mode 100644 index 0000000..a775533 --- /dev/null +++ b/design/admin_dashboard_overview/code.html @@ -0,0 +1,599 @@ + + + + + +Soundbox Ops - Admin Console + + + + + + + + + + + +
+
+
+search + +
+ +
+
+ + +
+
+
+

Admin User

+

Super Administrator

+
+Administrator Profile +
+
+
+ +
+ +
+
+

Operational Overview

+

Real-time status of your QRIS soundbox ecosystem.

+
+
+ + +
+
+ +
+ +
+
+
+store +
+ + +4.2% trending_up + +
+

Total Merchants

+

1,240

+
+ +
+
+
+sensors +
+94.4% Active +
+

Devices Online

+

850 / 900

+
+ +
+
+
+payments +
+ + +12% trending_up + +
+

Today's Transactions

+

Rp450M

+
+ +
+
+
+verified +
+ + Stable check_circle + +
+

Success Rate

+

99.2%

+
+ +
+
+
+hourglass_empty +
+ +
+

Pending Settlements

+

24

+
+
+ +
+ +
+ +
+
+
+

Transaction Volume Trend

+

Last 7 days performance metrics

+
+
+
+ +Current Period +
+
+ +Previous Period +
+
+
+ +
+
+
+
+
+Mon +
+
+
+
+
+Tue +
+
+
+
+
+Wed +
+
+
+
+
+Thu +
+
+
+
+
+Fri +
+
+
+
+
+Sat +
+
+
+
+
+Sun +
+
+
+ +
+
+
+

Pending Merchant Onboarding

+

New applications requiring review

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Merchant NameCategorySubmission DateStatusActions
+
+
KB
+
+

Kopi Bahagia

+

ID: MERCH-9021

+
+
+
F&B - CafeOct 24, 2023 + + Pending Review + + + +
+
+
JM
+
+

Jaya Mart

+

ID: MERCH-8843

+
+
+
Retail - GroceryOct 23, 2023 + + Pending Review + + + +
+
+
AL
+
+

Apotek Lestari

+

ID: MERCH-8712

+
+
+
Healthcare - PharmaOct 23, 2023 + + Pending Review + + + +
+
+
+
+ +
+ +
+

Device Health

+
+ + + + + + +
+

94%

+

Healthy

+
+
+
+
+
+ +Online / Ready +
+850 +
+
+
+ +Degraded / Slow +
+35 +
+
+
+ +Offline / Error +
+15 +
+
+
+ +
+
+

Recent Alerts

+2 Critical +
+
+ +
+
+error +
+

Terminal X-009 Offline

+

Location: Outlet Y (Sudirman Mall)

+
+2 mins ago + +
+
+
+
+ +
+
+warning +
+

Network Latency Spike

+

Impact: Cluster Jakarta Selatan

+
+14 mins ago + +
+
+
+
+ +
+
+error +
+

Repeated Auth Failure

+

Merchant: IndoFresh Mart #44

+
+45 mins ago + +
+
+
+
+ +
+
+info +
+

New FW Update Available

+

Version 2.4.1 (Stable Build)

+
+2 hours ago + +
+
+
+
+
+ +
+
+
+ +
+
+

Audit Activity Stream

+
+Live Stream +Operational +
+
+
+

[14:32:11] SUCCESS: Settlement triggered for Cluster-B (Rp12.4M handled)

+

[14:31:05] INFO: Device X-292 ping response received (latency 42ms)

+

[14:29:44] WARN: Merchant ID 9921 failed KYC validation step 3

+

[14:28:12] SUCCESS: New Admin 'DevOps_Main' logged in via MFA

+
+ +
+terminal +
+
+
+ + \ No newline at end of file diff --git a/design/admin_dashboard_overview/screen.png b/design/admin_dashboard_overview/screen.png new file mode 100644 index 0000000..6b1febe Binary files /dev/null and b/design/admin_dashboard_overview/screen.png differ diff --git a/design/admin_fee_pricing_management/code.html b/design/admin_fee_pricing_management/code.html new file mode 100644 index 0000000..e3ce374 --- /dev/null +++ b/design/admin_fee_pricing_management/code.html @@ -0,0 +1,567 @@ + + + + + +Fee & Pricing Management | Soundbox Ops + + + + + + + + + + +
+ +
+
+
Soundbox Ops
+ +
+
+
+ +search +
+ + +Administrator Avatar +
+
+
+ +
+
+

Fee & Pricing Management

+

Configure transaction MDR, platform fees, and subscription tiers.

+
+
+ + +
+
+ +
+ +
+

Active Pricing Tiers

+
+ +
+
+DEFAULT +more_vert +
+

SME Basic

+

Optimized for small-volume merchants.

+
+
+
Base MDR
+
0.75%
+
+
+
Subscription
+
$12/mo
+
+
+
+
+
+PREMIUM +more_vert +
+

Enterprise Plus

+

High-volume corporate accounts.

+
+
+
Base MDR
+
0.45%
+
+
+
Subscription
+
$150/mo
+
+
+
+
+
+CUSTOM +more_vert +
+

Government Special

+

Public sector specific regulations.

+
+
+
Base MDR
+
0.10%
+
+
+
Subscription
+
$0/mo
+
+
+
+
+
+ +
+ +
+
+
+

Configure Tier: SME Basic

+

Managing global transaction rules for all assigned merchants.

+
+
+ + +
+
+
+ +
+
+percent +

Merchant Discount Rate (MDR)

+
+
+
+ +
+ +% +
+
+
+ +
+ +% +
+
+
+ +
+ +% +
+
+
+
+ +
+
+payments +

Fixed Fees & Subscription

+
+
+
+ +
+$ + +
+
+
+ +
+$ + +
+
+
+ +
+$ + +
+
+
+
+ +
+
+

Tier Logic Restrictions

+ +
+
+
+
+terminal +
+
+

Cap Maximum Fee

+

Limit total MDR to $25.00 per TRX

+
+ +
+
+
+event_repeat +
+
+

Prorated First Month

+

Calculate sub-fee based on join date

+
+ +
+
+
+
+
+ +
+ +
+ +
+update +
+
+
+published_with_changes +

Bulk Fee Update

+
+

Apply a flat-rate adjustment or percentage shift to multiple tiers or specific merchant clusters instantly.

+
+ + +
+
+
+ +
+
+

History of Fee Changes

+Full Log +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampChangeAdmin
+
2023-11-24 14:02
+
+
SME Basic QRIS 0.65% → 0.75%
+
+
+ +J. DOE +
+
+
2023-11-23 09:15
+
+
New Tier Created: Gov Special
+
+
+ +A. CHEN +
+
+
2023-11-22 17:44
+
+
Enterprise Subs $125 → $150
+
+
+
SYS
+SYSTEM +
+
+
+
+
+ +
+
+Active Configuration JSON + +
+
+
{
+  "tier_id": "tier_sme_001",
+  "tier_name": "SME Basic",
+  "pricing_logic": {
+    "mdr": {
+      "qris": 0.0075,
+      "credit_card": 0.0210,
+      "debit_card": 0.0100
+    },
+    "fixed_fees": {
+      "processing": 0.25,
+      "settlement": 0.50
+    },
+    "subscription": {
+      "monthly": 12.00,
+      "currency": "USD"
+    }
+  },
+  "constraints": {
+    "max_fee_cap": 25.00,
+    "prorated_subscription": false
+  },
+  "last_updated": "2023-11-24T14:02:11Z"
+}
+
+
+
+
+
+
+ +
+
+
+

Change Details

+ +
+
+
+ +
+
+
+

Approved by Finance Dir

+

2023-11-24 15:30

+
+
+
+

Requested by J. Doe

+

2023-11-24 14:02

+
+
+
+

Previous Value Verified

+

2023-11-24 13:45

+
+
+
+
+ +

"Adjusting QRIS MDR to reflect new interbank processing costs and maintain margin for small-volume SME segment."

+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/design/admin_fee_pricing_management/screen.png b/design/admin_fee_pricing_management/screen.png new file mode 100644 index 0000000..24ab850 Binary files /dev/null and b/design/admin_fee_pricing_management/screen.png differ diff --git a/design/admin_login_portal/code.html b/design/admin_login_portal/code.html new file mode 100644 index 0000000..b8ccc5d --- /dev/null +++ b/design/admin_login_portal/code.html @@ -0,0 +1,260 @@ + + + + + +Login Admin | Soundbox Ops + + + + + + + + + +
+ + + + +
+ +
+
+
+
+ + \ No newline at end of file diff --git a/design/admin_login_portal/screen.png b/design/admin_login_portal/screen.png new file mode 100644 index 0000000..70aea01 Binary files /dev/null and b/design/admin_login_portal/screen.png differ diff --git a/design/admin_onboarding_review_queue/code.html b/design/admin_onboarding_review_queue/code.html new file mode 100644 index 0000000..ba661da --- /dev/null +++ b/design/admin_onboarding_review_queue/code.html @@ -0,0 +1,528 @@ + + + + + +Merchant Onboarding Review Queue + + + + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+
+ + + +
+
+
+ +
+ +
+

Onboarding Review Queue

+

Verify and authorize new merchant accounts for the soundbox ecosystem.

+
+ +
+ +
+
+
+Pending Reviews +pending +
+
142
+
+
+trending_up ++8% from yesterday +
+
+ +
+
+
+Approved Today +check_circle +
+
28
+
+
+check +Daily quota met +
+
+ +
+
+
+Avg. Review Time +timer +
+
4.2h
+
+
+trending_down +-12m improvement +
+
+
+ +
+
+
+

Review Applications

+
+All: 142 +Urgent: 12 +
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Merchant DetailsSubmission DateCategoryStatusActions
+
+
BK
+
+

Bistro Kopi Central

+

ID: MER-49210

+
+
+
Oct 24, 2023 09:12 AM +Food & Beverage + +
+ + + Pending Review + +
+
+ +
+
+
MS
+
+

Metro Supermarket

+

ID: MER-49211

+
+
+
Oct 24, 2023 10:45 AM +Retail + +
+ + + Pending Review + +
+
+ +
+
+
TL
+
+

TechLogistics Solutions

+

ID: MER-49212

+
+
+
Oct 24, 2023 01:20 PM +Courier Services + +
+ + + Pending Review + +
+
+ +
+
+
FP
+
+

Fresh Produce Mart

+

ID: MER-49213

+
+
+
Oct 24, 2023 03:05 PM +Grocery + +
+ + + Pending Review + +
+
+ +
+
+
+

Showing 4 of 142 pending applications

+
+ + +
+
+
+
+ + +
+ +
+
+

Application Review

+ +
+
+ +
+Merchant Branding +
+

Bistro Kopi Central

+High Priority Review +
+
+ +
+
+verified_user + Entity Verification +
+
+
+

Business Registration

+

REG-2023-99120

+
+
+

Tax ID / PAN

+

BKC991204X

+
+
+
+
+description +

Incorporation_Doc.pdf

+
+ +
+
+ +
+
+code + Risk Score Payload +
+
+ +
{
+  "risk_assessment": {
+    "score": 14,
+    "rating": "LOW",
+    "signals": [
+      "geofence_match: true",
+      "kyc_verification: pass",
+      "velocity_check: pass"
+    ],
+    "last_check": "2023-10-24T09:12:04Z"
+  }
+}
+
+
+ +
+
+history + Timeline +
+
+
+ +check + +

Application Submitted

+

Oct 24, 09:12 AM by Merchant

+
+
+ +robot + +

Automated KYC Check

+

Oct 24, 09:13 AM - System Approved

+
+
+ + + +

Pending Manual Review

+

Assigning to Alex Rivera...

+
+
+
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/design/admin_onboarding_review_queue/screen.png b/design/admin_onboarding_review_queue/screen.png new file mode 100644 index 0000000..1f09372 Binary files /dev/null and b/design/admin_onboarding_review_queue/screen.png differ diff --git a/design/admin_reconciliation_management/code.html b/design/admin_reconciliation_management/code.html new file mode 100644 index 0000000..be9b866 --- /dev/null +++ b/design/admin_reconciliation_management/code.html @@ -0,0 +1,557 @@ + + + + + +Reconciliation | Soundbox Ops + + + + + + + + + + + +
+ +
+
+

Reconciliation

+ +
+
+
+ +search +
+ + +
+
+Administrator Avatar + +
+
+
+
+ +
+
+
+Total Matched +
+check_circle +
+
+
42,892
+
+trending_up ++12.4% +vs last period +
+
+
+
+Discrepancies +
+error_outline +
+
+
148
+
+trending_down +-2.1% +Requires attention +
+
+
+
+Pending Verification +
+hourglass_empty +
+
+
1,024
+
+history +Avg 4h processing +
+
+
+
+Bank Statements +
+account_balance +
+
+
842
+
+cloud_done +12 API Connections +
+
+
+ +
+
+calendar_today +Oct 01, 2023 - Oct 31, 2023 +
+ + +
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Transaction DetailsSystem Record (Internal)Bank Record (External)VarianceStatusActions
+

TXN-90283471

+

Oct 24, 2023 • 14:22:10

+
₹ 14,500.00₹ 14,500.000.00 +MATCHED + + +
+

TXN-88273412

+

Oct 24, 2023 • 11:05:45

+
₹ 8,240.50₹ 8,245.50- 5.00 +EXCEPTION + + +
+

TXN-90112456

+

Oct 23, 2023 • 23:18:02

+
₹ 1,20,000.00Not FoundPending +PENDING + + +
+

TXN-90283472

+

Oct 23, 2023 • 18:45:30

+
₹ 450.00₹ 450.000.00 +MATCHED + + +
+

TXN-90283478

+

Oct 23, 2023 • 16:12:11

+
₹ 22,000.00₹ 21,560.00- 440.00 +EXCEPTION + + +
+
+ +
+

Showing 1 to 5 of 42,892 entries

+
+ + + + + +
+
+
+ +
+ +
+
+

+terminal + Raw Reconciliation Payload (Last Match) +

+ +
+
+
{
+  "reconciliation_id": "RECON-00492-AX",
+  "timestamp": "2023-10-24T14:22:10.452Z",
+  "system_ledger": {
+    "entry_id": "SL-90283471",
+    "amount": 14500.00,
+    "currency": "INR",
+    "hash": "8f3e22...a1"
+  },
+  "bank_statement": {
+    "ref_num": "BANK-TX-5512",
+    "posted_date": "2023-10-24",
+    "amount": 14500.00,
+    "match_confidence": 0.998
+  },
+  "result": "AUTO_MATCH_SUCCESS",
+  "strategy": "EXACT_AMOUNT_REF_MATCH"
+}
+            
+
+
+ +
+

Recent Resolution Activity

+
+
+
+
+

TXN-88273412 Resolved

+

Manual match confirmed by Admin A12

+

12 minutes ago

+
+
+
+
+

Report Exported

+

Full Oct report generated (PDF)

+

45 minutes ago

+
+
+
+

HSBC Sync Completed

+

2,400 statements fetched via API

+

1 hour ago

+
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/design/admin_reconciliation_management/screen.png b/design/admin_reconciliation_management/screen.png new file mode 100644 index 0000000..d5e4ff6 Binary files /dev/null and b/design/admin_reconciliation_management/screen.png differ diff --git a/design/admin_system_audit_logs/code.html b/design/admin_system_audit_logs/code.html new file mode 100644 index 0000000..b60b13a --- /dev/null +++ b/design/admin_system_audit_logs/code.html @@ -0,0 +1,517 @@ + + + + + +Audit Logs | Soundbox Ops + + + + + + + + + + + +
+ +
+
+

Audit Logs

+/ +System Security +
+
+ +
+search + +
+
+ + +
+Administrator Avatar +
+
+
+ +
+ +
+
+Action Type: + +
+
+User Role: + +
+
+calendar_today +Date Range: + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampUser & RoleActionResource IDIP AddressStatusPayload
+
+Oct 24, 2023 +14:22:45.002 +
+
+
+User +
+Felix Chen +Super Admin +
+
+
+Updated Merchant Fee + +MID-88219-X + + 192.168.1.104 + + + Success + + + +
+
+Oct 24, 2023 +13:58:12.881 +
+
+
+User +
+Sarah Miller +Operator +
+
+
+Failed Login Attempt + + 103.24.11.92 + + + Failed + + + +
+
+Oct 24, 2023 +12:10:04.230 +
+
+
+User +
+Jordan Blake +Super Admin +
+
+
+Deleted API Key + +KEY-TEST-992 + + 45.12.88.21 + + + Success + + + +
+
+Oct 24, 2023 +11:45:30.121 +
+
+
+User +
+System Core +Automated +
+
+
+Initiated Daily Settlement + +SETTLE-20231024 + + Internal + + + Pending + + + +
+
+ +
+Showing 1 to 20 of 1,248 entries +
+ + + + + +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/design/admin_system_audit_logs/screen.png b/design/admin_system_audit_logs/screen.png new file mode 100644 index 0000000..f382a6b Binary files /dev/null and b/design/admin_system_audit_logs/screen.png differ diff --git a/design/device_registry_monitoring/code.html b/design/device_registry_monitoring/code.html new file mode 100644 index 0000000..ed26196 --- /dev/null +++ b/design/device_registry_monitoring/code.html @@ -0,0 +1,592 @@ + + + + + +Device Registry | Soundbox Ops + + + + + + + + + + + +
+
+
+search + +
+ +
+
+ + +
+Administrator Profile +
+
+ +
+ +
+
+

Device Registry

+

Manage and monitor all IoT soundbox units across the network.

+
+ +
+ +
+
+
+
+devices +
+ +trending_up + +24 + +
+

Total Registered

+

1,200

+
+
+
+
+
+
+
+router +
+ +check_circle + 75% Rate + +
+

Active Units

+

900

+
+
+
+
+
+
+
+inventory_2 +
+Unassigned +
+

Stock Available

+

300

+
+
+
+
+
+ +
+ +
+
+filter_list +Filters +
+
+ + + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Device IDModelMerchant BindingConnectionStatusHealthLast Seen
+SND-10293 +Soundbox V2 +
+
+store +
+Bakery A +
+
+ +cell_tower + 4G + + + + + Online + + + +favorite + Excellent + +2 min ago + +
+SND-10294 +Soundbox V2 +
+Unassigned +
+
+ +wifi + WiFi + + + + + Offline + + + +heart_broken + N/A + +14 hours ago + +
+SND-10301 +Soundbox V2 Pro +
+
+local_cafe +
+Coffee Shop B +
+
+ +cell_tower + 4G + + + + + Online + + + +heart_minus + Good + +Just now + +
+SND-10315 +Soundbox V2 +
+
+shopping_basket +
+Supermarket X +
+
+ +wifi + WiFi + + + + + Offline + + + +heart_broken + Poor + +45 min ago + +
+
+ +
+Showing 1-4 of 1,200 devices +
+ + + + +... + + +
+
+
+ +
+
+build +
+
+

Scheduled Maintenance

+

Device Heartbeat service will be offline for 15 minutes today at 02:00 UTC for firmware indexing updates.

+
+ +
+
+ +
+
+
+

Device Detail

+ +
+
+
+Soundbox V2 Product +
+

SND-10293

+Soundbox V2 +
+
+
+
+
Binding Info
+
+
+Merchant Name +Bakery A +
+
+MID +MID-882910 +
+
+Activated On +12 Oct 2023 +
+
+
+
+
Live Payload (Audit Control)
+
+ +
{
+  "device_id": "SND-10293",
+  "hb_state": "ACTIVE",
+  "battery": "94%",
+  "rssi": "-62dBm",
+  "fw_ver": "2.4.1-stable",
+  "last_event": "TXN_NOTIFY"
+}
+                            
+
+
+
+
Connectivity Health
+
+
+
+
+
+85% Uptime +
+
+
+
+
+
+ + +
+
+
+ + \ No newline at end of file diff --git a/design/device_registry_monitoring/screen.png b/design/device_registry_monitoring/screen.png new file mode 100644 index 0000000..35d6b02 Binary files /dev/null and b/design/device_registry_monitoring/screen.png differ diff --git a/design/device_technical_detail/code.html b/design/device_technical_detail/code.html new file mode 100644 index 0000000..491fbe5 --- /dev/null +++ b/design/device_technical_detail/code.html @@ -0,0 +1,460 @@ + + + + + +Device Detail | Soundbox Ops + + + + + + + + + + + +
+
+search + +
+
+
+ + +
+
+
+
+

Admin_User

+

Super Admin

+
+Admin Avatar +
+
+
+ +
+
+ + + +
+
+
+speaker_group +
+
+
+

SND-10293

+ + + Online + +
+
+

+settings_input_component + Soundbox V2 Pro +

+

+schedule + Last seen 2 mins ago +

+

+location_on + Mumbai, Central Region +

+
+
+
+
+ + +
+
+ +
+ + + + +
+ +
+ +
+ +
+
+

Signal Strength (4G)

+
+

-78 dBm

+signal_cellular_4_bar +
+

+check_circle + Excellent +

+
+
+

Battery Health

+
+

92%

+battery_5_bar +
+

+bolt + Discharging (External OFF) +

+
+
+

Firmware Version

+
+

v2.4.1

+verified +
+

+info + Latest version available +

+
+
+ +
+
+

Current Merchant Binding

+ +
+
+
+Bakery A Logo +
+
+

Bakery A - Mumbai Outlet

+

Merchant ID: MID-99201-B02

+
+
+

Bound Since

+

12 Oct 2023, 11:45 AM

+
+
+
+ +
+
+
+ +Live Payload Stream +
+
+ + +
+
+
+

[14:02:11] INITIALIZING WEBSOCKET CONNECTION...

+

[14:02:12] CONNECTED TO SND-10293_GATEWAY_V4

+

[14:02:15] RECV: {"event": "heartbeat", "status": "online", "v_batt": 4.12, "rssi": -78, "ts": 1715421255}

+

[14:03:01] SEND: {"cmd": "ack_config", "token": "fx-2291"}

+

[14:03:02] RECV: {"event": "tx_confirm", "tx_id": "QR-90112", "amount": 12.50, "currency": "INR"}

+

[14:04:15] IDLE: STANDBY MODE ACTIVE

+
+
+
+ +
+ +
+
+

Remote Actions

+
+
+ + + +
+ +
+
+
+ +
+

Device Events

+
+ +
+
+check +
+

Successful Transaction

+

QR payment processed (₹45.00)

+

Today, 02:03 PM

+
+ +
+
+sync +
+

Config Synchronized

+

Volume set to 80%

+

Today, 11:20 AM

+
+ +
+
+power_off +
+

AC Power Disconnected

+

Switched to battery backup

+

Yesterday, 09:45 PM

+
+ +
+
+bolt +
+

Firmware Update Scheduled

+

Expected 15 May

+
+
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/design/device_technical_detail/screen.png b/design/device_technical_detail/screen.png new file mode 100644 index 0000000..c161dbd Binary files /dev/null and b/design/device_technical_detail/screen.png differ diff --git a/design/device_ui_payment_success/code.html b/design/device_ui_payment_success/code.html new file mode 100644 index 0000000..c3e1c2e --- /dev/null +++ b/design/device_ui_payment_success/code.html @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+check +
+
+ +
+

Payment Successful

+

Merchant: Brew & Bean Cafe

+
+ +
+

Amount Paid

+
+Rp +50,000 +
+
+ +
+
+Reference No. +FTX-8829-0012 +
+
+Date & Time +Oct 24, 2023 · 14:32 +
+
+Method +
+QRIS QuickPay +
+
+
+
+
+ +
+
+wifi +Connected +
+
+88% +battery_5_bar +
+
+ +
+
+ +
+

IOT SOUNDBOX GEN-3

+
+ + \ No newline at end of file diff --git a/design/device_ui_payment_success/screen.png b/design/device_ui_payment_success/screen.png new file mode 100644 index 0000000..f6201b1 Binary files /dev/null and b/design/device_ui_payment_success/screen.png differ diff --git a/design/device_ui_qr_payment_display/code.html b/design/device_ui_qr_payment_display/code.html new file mode 100644 index 0000000..d4863ae --- /dev/null +++ b/design/device_ui_qr_payment_display/code.html @@ -0,0 +1,469 @@ + + + + + +FinOps Admin - Device QR State + + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+

Merchant Control Center

+
+ + + +
+
+
+ +
+ +
+
+
+Device Fleet +chevron_right +Soundbox SB-9021 +
+

Live Device Simulation

+
+
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+Merchant +wifi +
+

Central Coffee Roasters

+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+qr_code_2 +
+
+
+ +
+
+
+
+ +
+
+ +
+

Total Payment

+

Rp 50.000

+
+
+ +
+pending +Waiting for payment... +
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ +
+

+ Device Diagnostics + Active +

+
+
+Signal Strength +
+
+
+
+
+
+
+-74 dBm +
+
+
+Battery Level +
+94% +battery_5_bar +
+
+
+Firmware +v2.4.1-stable +
+
+Uptime +14d 06h 22m +
+
+
+ +
+
+

Recent Logs

+ +
+
+
+ +
+
+

[14:22:01] CMD_REQ_QR_GEN: amount=50000

+

[14:22:02] QR_API_RES: 200 OK - data_hash=7f2a1...

+

[14:22:02] UI_STATE_UPD: display_qr_waiting

+

[14:22:05] HEARTBEAT: latency=42ms

+

[14:22:15] HEARTBEAT: latency=38ms

+

[14:22:18] SOCKET_EVT: client_polling_started

+
+
+
+ +
+
+
+block +
+
+

Cancel Trans.

+

Void current QR

+
+
+
+
+volume_up +
+
+

Test Speaker

+

Play chime

+
+
+
+
+
+ +
+
+

Recent Merchant Transactions

+
+Daily Target: 82% +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Transaction IDTimestampMethodAmountStatus
TXN-9842105514:18:42 +
+qr_code +QRIS Static +
+
Rp 24,500 + + Settled + +
TXN-9842104214:05:11 +
+qr_code +QRIS Dynamic +
+
Rp 120,000 + + Settled + +
TXN-9842102113:58:30 +
+qr_code +QRIS Dynamic +
+
Rp 50,000 + + Failed + +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/design/device_ui_qr_payment_display/screen.png b/design/device_ui_qr_payment_display/screen.png new file mode 100644 index 0000000..53dee1d Binary files /dev/null and b/design/device_ui_qr_payment_display/screen.png differ diff --git a/design/merchant_dashboard_portal/code.html b/design/merchant_dashboard_portal/code.html new file mode 100644 index 0000000..5b07b3d --- /dev/null +++ b/design/merchant_dashboard_portal/code.html @@ -0,0 +1,476 @@ + + + + + +Soundbox Ops | Merchant Portal + + + + + + + + + + +
+
+
+search + +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+

Merchant Portal

+

Good morning, John's Coffee

+
+ +
+ +
+ +
+
+
+payments +
+ +trending_up + 12.4% + +
+

Today's GMV

+

₹42,850.50

+

vs. ₹38,120.00 yesterday

+
+ +
+
+
+receipt_long +
+ +trending_up + 8% + +
+

Transaction Count

+

184

+

Avg. Ticket: ₹232.88

+
+ +
+
+
+speaker_group +
+
+ + Live +
+
+

Active Soundboxes

+

04 / 05

+

+error + 1 Device Offline (Main Exit) +

+
+ +
+
+
+account_balance +
+Pending +
+

Next Settlement

+

₹38,200

+

Scheduled: Oct 24, 06:00 AM

+
+
+ +
+ +
+
+

Recent Transactions

+View All +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Transaction IDTimeAmountStatusAction
+

#TXN-88421

+

UPI • GPay

+
10:24:12 AM₹450.00 + + + Settled + + + +
+

#TXN-88419

+

UPI • PhonePe

+
10:22:05 AM₹1,200.00 + + + Settled + + + +
+

#TXN-88418

+

UPI • Paytm

+
10:18:44 AM₹85.50 + + + Pending + + + +
+

#TXN-88415

+

UPI • BHIM

+
09:55:12 AM₹210.00 + + + Failed + + + +
+
+
+ +
+
+

Device Health

+
+ +
+
+
+speaker +
+
+

Main Counter

+

SN: SB-2091-AX

+
+
+
+

Online

+

88% Batt

+
+
+ +
+
+
+speaker +
+
+

Patio Station

+

SN: SB-2092-AX

+
+
+
+

Online

+

42% Batt

+
+
+ +
+
+
+speaker_group +
+
+

Main Exit

+

SN: SB-2104-CZ

+
+
+
+

Offline

+

Low Signal

+
+
+
+ +
+ +
+
+Special Offer +
Upgrade to Gen-2 Soundboxes
+

Get 20% off and 5G connectivity for better reliability.

+ +
+ +
+
+
+
+ +
+
+
+history_edu +
+
+

Compliance Status

+

Your KYC verification is active and valid until Dec 2025.

+
+
+
+Verified +more_vert +
+
+
+ + \ No newline at end of file diff --git a/design/merchant_dashboard_portal/screen.png b/design/merchant_dashboard_portal/screen.png new file mode 100644 index 0000000..2c66f71 Binary files /dev/null and b/design/merchant_dashboard_portal/screen.png differ diff --git a/design/merchant_detail_view/code.html b/design/merchant_detail_view/code.html new file mode 100644 index 0000000..b93daa5 --- /dev/null +++ b/design/merchant_detail_view/code.html @@ -0,0 +1,427 @@ + + + + + +Merchant Detail | Soundbox Ops + + + + + + + + + + + + +
+
+
+search + +
+
+
+
+ + +
+
+
+

Admin User

+

Super Administrator

+
+Administrator Profile +
+
+
+ +
+ +
+ +
+
+

Kopi Kenangan - GI Mall

+
+ + Active + + + Fee Profile: Flat 0.7% + +ID: MID-98234-KK +
+
+
+ + +
+
+
+ +
+
+

GMV Today

+
+

Rp12.500.000

+

+12.4%

+
+
+
+

Active Devices

+
+

4

+

/ 5 Registered

+
+
+
+

Success Rate

+
+

100%

+

Optimal

+
+
+
+ +
+ + + + + +
+ +
+ +
+
+
+

Business Information

+
+
+
+

Legal Entity Name

+

PT Bumi Berkah Boga

+
+
+

Tax ID (NPWP)

+

01.234.567.8-012.000

+
+
+

Registered Business Address

+

Grand Indonesia, East Mall, Level LG. Jl. M.H. Thamrin No.1, Jakarta Pusat, 10310

+
+
+

Business Category

+

Food & Beverage (Coffee Shop)

+
+
+

Onboarding Date

+

Oct 12, 2023

+
+
+
+ +
+
+

Settlement Bank Account

+
+
+
+account_balance +
+
+

Bank Central Asia (BCA)

+

Account Number: 5020129481

+

Beneficiary: PT Bumi Berkah Boga

+
+
+ Verified +
+
+
+
+ +
+ +
+
+

PIC Contact

+contact_page +
+
+
+
JD
+
+

John Doe

+

Operations Manager

+
+
+
+
+mail +j.doe@kopikenangan.id +
+
+phone ++62 812-3456-7890 +
+
+
+
+ +
+
+ +
+
+
+

GPS: -6.1951, 106.8231

+ +
+
+
+
+ +
+
+
+terminal +

Audit Logs & Raw Payloads

+
+ +
+
+
+[2023-11-24 14:22:01] +INFO: + Merchant record MID-98234-KK status changed from PENDING to ACTIVE by user Admin_S7. +
+
+[2023-11-24 10:15:44] +DEBUG: + Bank verification payload received for BCA-5020129481. Response: {"status":"verified","match_score":1.0,"ref_id":"VB-9832"} +
+
+[2023-11-23 09:05:12] +INFO: + Device provisioning started for Soundbox_SN:9877234. Associated to MID-98234-KK. +
+
+[2023-11-22 16:45:00] +TRACE: + Internal system audit triggered. No anomalies detected for merchant cluster. +
+
+
+
+ +
+
+check_circle +
+
+

Update Successful

+

Merchant details have been synced.

+
+
+ + \ No newline at end of file diff --git a/design/merchant_detail_view/screen.png b/design/merchant_detail_view/screen.png new file mode 100644 index 0000000..ba896b7 Binary files /dev/null and b/design/merchant_detail_view/screen.png differ diff --git a/design/merchant_list_management/code.html b/design/merchant_list_management/code.html new file mode 100644 index 0000000..5365776 --- /dev/null +++ b/design/merchant_list_management/code.html @@ -0,0 +1,539 @@ + + + + + +Merchant Management | Soundbox Ops + + + + + + + + + + + +
+
+
+search + +
+
+
+ + +
+
+
+

Alex Rivera

+

Senior Administrator

+
+Administrator Profile +
+
+
+ +
+ +
+
+

Merchant Management

+

Oversee and manage the complete merchant lifecycle and compliance.

+
+ +
+ +
+
+search + +
+
+ + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDMerchant NameCategoryOutletsDevicesStatusLast TransactionActions
MCH-88291 +
+
+ +
+Artisan Brew Co. +
+
+F&B +1245 + + ACTIVE + +Oct 24, 2023 14:22 + +
MCH-44120 +
+
+ +
+Urban Threads +
+
+Retail +0408 + + INACTIVE + +Oct 20, 2023 09:15 + +
MCH-99012 +
+
+ +
+Swift Logistics Ltd. +
+
+Services +0102 + + PENDING + +-- + +
MCH-23841 +
+
+ +
+Green Garden Market +
+
+Retail +25110 + + ACTIVE + +Oct 24, 2023 16:45 + +
MCH-11005 +
+
+ +
+Pixel Perfect Media +
+
+E-commerce +0202 + + ACTIVE + +Oct 23, 2023 11:30 + +
+
+ +
+

+ Showing 1 - 5 of 124 merchants +

+
+ + + + +... + + +
+
+
+ +
+
+

Total Merchants

+

1,248

+
+trending_up ++12.5% +vs last month +
+
+
+

Active Devices

+

4,812

+
+trending_up ++4.2% +vs last month +
+
+
+

Pending KYM

+

24

+
+priority_high +Needs attention +
+
+
+

Total Volume

+

$2.4M

+
+trending_up ++18.4% +vs last month +
+
+
+
+ +
+
+
+

Merchant Details

+ +
+
+ +
+
+ +
+
+

Artisan Brew Co.

+ACTIVE +
+
+
+
+
+

Merchant ID

+

MCH-88291

+
+
+

Tax ID

+

TX-992-001

+
+
+
+

Operational Health

+
+
+
+

94% Positive Uptime

+
+
+

Recent System Audit

+
+ +
{
+  "event": "SETTLEMENT_BATCH_CLOSE",
+  "batch_id": "BT-7721",
+  "total": 4500.22,
+  "status": "SUCCESS",
+  "timestamp": "2023-10-24T14:22:11Z"
+}
+
+
+
+
+
+ + +
+
+
+ + + \ No newline at end of file diff --git a/design/merchant_list_management/screen.png b/design/merchant_list_management/screen.png new file mode 100644 index 0000000..da633f2 Binary files /dev/null and b/design/merchant_list_management/screen.png differ diff --git a/design/merchant_login_portal/code.html b/design/merchant_login_portal/code.html new file mode 100644 index 0000000..bf35b47 --- /dev/null +++ b/design/merchant_login_portal/code.html @@ -0,0 +1,277 @@ + + + + + +Soundbox Ops - Merchant Login + + + + + + + + + +
+ +
+ +
+
+ +
+Soundbox Ops Logo +

Merchant Control Center

+

Selamat datang kembali. Silakan masuk untuk mengelola transaksi Anda.

+
+ +
+
+ +
+mail + +
+
+
+
+ +Lupa Kata Sandi? +
+
+lock + + +
+
+
+ + +
+ +
+ +
+

+ Belum memiliki akun merchant? + Register as Merchant +

+
+
+
+ + +
+ + + + \ No newline at end of file diff --git a/design/merchant_login_portal/screen.png b/design/merchant_login_portal/screen.png new file mode 100644 index 0000000..df31b19 Binary files /dev/null and b/design/merchant_login_portal/screen.png differ diff --git a/design/merchant_settlement_history/code.html b/design/merchant_settlement_history/code.html new file mode 100644 index 0000000..fc48790 --- /dev/null +++ b/design/merchant_settlement_history/code.html @@ -0,0 +1,624 @@ + + + + + +Merchant Settlement History - Soundbox Ops + + + + + + + + + + +
+ +
+
+

Settlement History

+
+
+
+ +search +
+
+notifications +settings +
+Administrator Avatar +
+
+
+
+ +
+ +
+ +
+
+
+

Available Balance

+

$12,480.50

+
+
+account_balance +
+
+
+ +trending_up + 8.2% + +vs last week +
+
+ +
+
+
+

Next Payout Date

+

Oct 24, 2023

+
+
+event +
+
+
+

Estimated: $3,150.00

+
+
+ +
+
+
+

Total Settled (MTD)

+

$45,210.00

+
+
+payments +
+
+
+ +trending_up + 12.5% + +vs last month +
+
+
+ +
+
+
+Period: + +
+
+Status: + +
+
+
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Settlement IDDateBank AccountGross AmountNet AmountStatusActions
SET-902341Oct 21, 2023HDFC •••• 4492$2,450.00$2,401.00 + + Processed + + + +
SET-902339Oct 20, 2023HDFC •••• 4492$1,800.00$1,764.00 + + Pending + + +Awaiting Bank +
SET-902331Oct 19, 2023ICICI •••• 1102$4,120.00$4,037.60 + + Processed + + + +
SET-902325Oct 18, 2023HDFC •••• 4492$500.00$490.00 + + Failed + + + +
SET-902311Oct 17, 2023HDFC •••• 4492$3,600.00$3,528.00 + + Processed + + + +
+
+ +
+

Showing 1 - 5 of 124 disbursements

+
+ + + + + +
+
+
+ +
+
+
+

Weekly Settlement Volume

+
+ + Gross + + + Net + +
+
+
+ +
+
+
+
+
+Mon +
+
+
+
+
+
+Tue +
+
+
+
+
+
+Wed +
+
+
+
+
+
+Thu +
+
+
+
+
+
+Fri +
+
+
+
+
+
+Sat +
+
+
+
+
+
+Sun +
+
+
+ +
+

+info + Settlement Cycle +

+
+
+
+
1
+
+
+
+

Daily Batching

+

Transactions from 12:00 AM to 11:59 PM are batched for settlement.

+
+
+
+
+
2
+
+
+
+

Fee Deduction

+

Processing fees (approx. 2%) are automatically calculated and deducted.

+
+
+
+
+
3
+
+
+

Direct Deposit

+

Funds reach your bank account within T+1 working days.

+
+
+
+ +
+
+
+ +
+
+

Settlement Details

+ +
+
+
+

Net Amount Paid

+

$2,401.00

+ + Successful Transfer + +
+
+

Breakdown

+
+Gross Processing Volume +$2,450.00 +
+
+Platform Fees (2%) +-$49.00 +
+
+Adjustment/Refunds +$0.00 +
+
+Total Disbursed +$2,401.00 +
+
+
+

Destination

+
+
+account_balance +
+
+

HDFC Bank India

+

Checking Account •••• 4492

+
+
+
+
+

Transfer Log

+
+
+
+

Transfer Initiated

+

Oct 21, 2023 • 09:12 AM

+
+
+
+

Bank Processing

+

Oct 21, 2023 • 11:45 AM

+
+
+
+

Funds Credited

+

Oct 21, 2023 • 04:30 PM

+
+
+
+
+ + +
+
+
+ + +
+ + \ No newline at end of file diff --git a/design/merchant_settlement_history/screen.png b/design/merchant_settlement_history/screen.png new file mode 100644 index 0000000..eab7831 Binary files /dev/null and b/design/merchant_settlement_history/screen.png differ diff --git a/design/onboarding_bank_account/code.html b/design/onboarding_bank_account/code.html new file mode 100644 index 0000000..a232f52 --- /dev/null +++ b/design/onboarding_bank_account/code.html @@ -0,0 +1,324 @@ + + + + + +Soundbox Ops - Merchant Onboarding + + + + + + + + + +
+
+account_balance +
+Soundbox Ops +MERCHANT ONBOARDING +
+
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+check +
+Business Profile +
+ +
+
+check +
+Verification Docs +
+ +
+
+3 +
+Settlement Account +
+ +
+
+4 +
+Device Selection +
+
+ +
+ +
+

Settlement Details

+

+ Link the bank account where you wish to receive daily settlements from your Soundbox transactions. +

+
+
+verified_user +
+Secure Verification +

Real-time penny-drop validation via IMPS/NEFT networks.

+
+
+
+schedule +
+Daily T+1 Settlement +

Funds reach your account within 24 hours of merchant closure.

+
+
+
+
+ +
+
+ +
+ACCOUNT INFORMATION +
+
+ +
+ +
+ +
+ +expand_more +
+
+ +
+ + +
+ +
+ + +

Account number is encrypted and processed via secure channels.

+
+
+ +
+ + + +
+ +
+ + +
+
+
+
+ +
+
+
+lock +PCI-DSS COMPLIANT +
+
+security +AES-256 ENCRYPTION +
+
+

© 2024 Soundbox Ops Infrastructure. All transaction data is processed through authorized banking gateways.

+
+
+
+ + \ No newline at end of file diff --git a/design/onboarding_bank_account/screen.png b/design/onboarding_bank_account/screen.png new file mode 100644 index 0000000..bb84770 Binary files /dev/null and b/design/onboarding_bank_account/screen.png differ diff --git a/design/onboarding_business_info/code.html b/design/onboarding_business_info/code.html new file mode 100644 index 0000000..1239631 --- /dev/null +++ b/design/onboarding_business_info/code.html @@ -0,0 +1,301 @@ + + + + + +Soundbox Ops - Merchant Onboarding + + + + + + + + + + + +
+ +
+ +
+
+ +
+ +
+ +
+
1
+Business Info +
+
+
2
+PIC Details +
+
+
3
+Bank Account +
+
+
4
+Documents +
+
+
+ +
+
+

Business Information

+

Please provide the legal details of your business to begin the onboarding process.

+
+
+
+ +
+ + +

Must match the name on your NIB or Akta Pendirian.

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+
+
+ + +
+ + + \ No newline at end of file diff --git a/design/onboarding_business_info/screen.png b/design/onboarding_business_info/screen.png new file mode 100644 index 0000000..aa46c85 Binary files /dev/null and b/design/onboarding_business_info/screen.png differ diff --git a/design/onboarding_document_upload/code.html b/design/onboarding_document_upload/code.html new file mode 100644 index 0000000..5a7841f --- /dev/null +++ b/design/onboarding_document_upload/code.html @@ -0,0 +1,371 @@ + + + + + +Merchant Onboarding - KYC Documentation + + + + + + + + + +
+
+
+soundpod +
+Soundbox Ops +Merchant Onboarding +
+
+ +
+ +
+
+
+ +
+
+ +
+
+check +
+Business info +
+
+
+check +
+Bank Account +
+
+
+check +
+Address +
+
+
4
+KYC Upload +
+
+
5
+Review +
+
+
+
+
+
+ +
+

Verify Your Business Identity

+

Please upload the required documents to comply with financial regulations and secure your merchant account. Ensure all photos are clear and legible.

+
+
+ +
+
+
+
+badge +
+
+

Identity Card (KTP)

+

Valid national ID card of the business owner or director.

+
+
+
+Formats: JPG, PNG, PDF +Max size: 5MB +
+
+
+
+ +
+upload_file +
+

Click to upload or drag and drop

+

KTP Front View. Ensure name and photo are visible.

+
+
+
+ +
+
+ +
+
+
+
+account_balance_wallet +
+
+

Tax ID (NPWP)

+

Company or individual tax identification card.

+
+
+
+Formats: JPG, PNG, PDF +Max size: 5MB +
+
+
+
+ +
+file_present +
+

Click to upload or drag and drop

+

Scan or photo of the NPWP card.

+
+
+
+
+
+ +
+
+
+
+add_a_photo +
+
+

Business Location Photo

+

Storefront photo showing business name and signage.

+
+
+
+Formats: JPG, PNG +Max size: 10MB +
+
+
+
+
+ +
+ + +
+
+Validated +
+
+
+ +
+photo_camera +

Upload interior or signage photo

+
+
+
+
+
+
+ +
+info +
+

Our Review Process

+

KYC documents are reviewed within 24-48 business hours. You will receive an email notification once your account status is updated. Providing high-quality, un-cropped photos will expedite this process.

+
+
+ +
+ +
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/design/onboarding_document_upload/screen.png b/design/onboarding_document_upload/screen.png new file mode 100644 index 0000000..c5a1124 Binary files /dev/null and b/design/onboarding_document_upload/screen.png differ diff --git a/design/onboarding_pic_details/code.html b/design/onboarding_pic_details/code.html new file mode 100644 index 0000000..0c9285c --- /dev/null +++ b/design/onboarding_pic_details/code.html @@ -0,0 +1,359 @@ + + + + + +Merchant Onboarding - PIC Details + + + + + + + + + +
+
+
+payments +
+
+Soundbox Ops +Merchant Portal +
+
+
+
+help_outline +Support +
+
+
+
+

Session ID

+

#OB-99281

+
+
+
+
+
+ +
+
+ +
+
+ +
+
+check +
+Business Info +
+ +
+
+2 +
+PIC & Contact +
+ +
+
+3 +
+Documents +
+ +
+
+4 +
+Review +
+
+
+
+ +
+
+
+

Person in Charge (PIC)

+

Please provide the contact details of the primary person responsible for operational decisions.

+
+
+ +
+ +
+person + +
+
+ +
+
+ + +
+
+ +
+badge + +
+
+
+ +
+ +
+mail + +
+

This will be used for official settlements and system alerts.

+
+ +
+
+
+ +
+
+ID Flag ++62 +
+ +
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+ +
+
+verified_user +
+
+

Why we need this

+

+ To maintain a secure financial network, we require a verified Point of Contact. This ensures operational alerts, settlement reports, and device updates reach the authorized personnel promptly. +

+
+
+ +
+
+
+shield +Enterprise Security +
+

+ Your data is encrypted using AES-256 standards. We never share personal contact details with third-party marketers. +

+
+ +
+lock +
+
+ +
+

Need help with the onboarding process?

+ +
+
+
+
+ +
+
+ + \ No newline at end of file diff --git a/design/onboarding_pic_details/screen.png b/design/onboarding_pic_details/screen.png new file mode 100644 index 0000000..4fb68f8 Binary files /dev/null and b/design/onboarding_pic_details/screen.png differ diff --git a/design/outlet_branch_management/code.html b/design/outlet_branch_management/code.html new file mode 100644 index 0000000..35acccb --- /dev/null +++ b/design/outlet_branch_management/code.html @@ -0,0 +1,655 @@ + + + + + +Outlet Management | Soundbox Ops + + + + + + + + + + + +
+
+search + +
+
+
+ + +
+
+
+
+

Alex Thompson

+

System Administrator

+
+Administrator Profile +
+
+
+ +
+ +
+
+ +

Outlet Registry

+

Managing 14 operational nodes across 3 regions for Global Retail Group.

+
+
+ + +
+
+ +
+
+

Total Outlets

+
+14 + +trending_up + 2 + +
+

Active across 4 merchant accounts

+
+
+

Active Devices

+
+142 + +check_circle + 98% + +
+

3 currently in maintenance

+
+
+

Daily GMV

+
+$124,502 + +trending_up + 12.4% + +
+

Vs. previous 24h average

+
+
+

Health Score

+
+A+ + +verified + Stable + +
+

Network latency: 142ms avg

+
+
+ +
+
+

Outlet Fleet

+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Outlet NameLocationActive DevicesDaily GMVStatusActions
+
+
+store +
+
+

Downtown Flagship

+

OUT-49201-DF

+
+
+
+
+location_on +San Francisco, CA +
+
+
+24 +
+
+
+
+
+
+
+
+22
+
+
+
+

$12,450.00

+

+trending_up + +4.2% +

+
+ + + Active + + + +
+
+
+store +
+
+

Eastside Mall Branch

+

OUT-49202-EM

+
+
+
+
+location_on +Austin, TX +
+
+
+18 +
+
+
+
+
+17
+
+
+
+

$8,920.45

+

+trending_down + -2.1% +

+
+ + + Pending + + + +
+
+
+store +
+
+

Airport Terminal A

+

OUT-49203-AA

+
+
+
+
+location_on +Chicago, IL +
+
+
+32 +
+
+
+
+
+31
+
+
+
+

$24,102.18

+

+trending_up + +18.4% +

+
+ + + Active + + + +
+
+
+store +
+
+

Legacy Square Node

+

OUT-49105-LS

+
+
+
+
+location_on +Boston, MA +
+
+
+0 +
+
+
+
+
+

$0.00

+

+horizontal_rule + 0% +

+
+ + + Decommissioned + + + +
+
+ +
+

Showing 1-4 of 14 outlets

+
+ + + + + +
+
+
+
+ + + +
+
+ +
+
+

Outlet Details

+

Registry Details & Performance

+
+ +
+ +
+ +
+Outlet Location Map +
+

+location_on + View on Live Map +

+
+
+ +
+
+

Weekly Volume

+

$84,203.44

+

+arrow_upward + 8.2% +

+
+
+

Avg. Transaction

+

$42.10

+

Based on 2.4k txn

+
+
+ +
+

Device Fleet Status

+
+
+
+
+
+
+
+

22 Active Soundboxes

+

Transmitting real-time payment alerts.

+

LATENCY: 114ms

+
+
+
+
+
+
+
+
+

2 Provisioning

+

Awaiting SIM activation & merchant link.

+

Action Required

+
+
+
+
+
+
+
+

Next Scheduled Audit

+

Compliance check and hardware diagnostic.

+

DUE IN: 14 DAYS

+
+
+
+
+ +
+
+

Configuration Metadata

+ +
+
+
{
+  "outlet_id": "OUT-49201-DF",
+  "merchant_group": "GLOBAL_RETAIL_001",
+  "region": "US-WEST-1",
+  "capabilities": ["SOUND_ALERTS", "QR_DYNAMIC"],
+  "encryption_level": "AES-256",
+  "last_heartbeat": "2023-11-24T10:42:12Z",
+  "status": "OPERATIONAL"
+}
+
+
+
+ +
+ + +
+
+
+ + \ No newline at end of file diff --git a/design/outlet_branch_management/screen.png b/design/outlet_branch_management/screen.png new file mode 100644 index 0000000..29b0429 Binary files /dev/null and b/design/outlet_branch_management/screen.png differ diff --git a/design/qris_soundbox_logo/code.html b/design/qris_soundbox_logo/code.html new file mode 100644 index 0000000..3505ebf --- /dev/null +++ b/design/qris_soundbox_logo/code.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/design/qris_soundbox_logo/screen.png b/design/qris_soundbox_logo/screen.png new file mode 100644 index 0000000..705534e Binary files /dev/null and b/design/qris_soundbox_logo/screen.png differ diff --git a/design/settlement_batch_management/code.html b/design/settlement_batch_management/code.html new file mode 100644 index 0000000..9318ce4 --- /dev/null +++ b/design/settlement_batch_management/code.html @@ -0,0 +1,484 @@ + + + + + +Soundbox Ops - Disbursement Batches + + + + + + + + + + + + + + + + +
+ +
+
+
+search + +
+ +
+
+ + +
+Administrator Profile +
+
+ +
+ +
+
+

Disbursement Batches

+

Manage and track bulk merchant payouts across all bank partners.

+
+ +
+ +
+
+

Pending Payouts

+
+

₹ 14.2M

+ +trending_up 12% + +
+
+
+

Processing

+
+

24

+ +sync Active + +
+
+
+

Avg. Success Rate

+
+

99.4%

+ +check_circle Stable + +
+
+
+

Total Fees (MTD)

+
+

₹ 420K

+vs ₹ 380K +
+
+
+ +
+
+
+account_balance + +
+
+calendar_month +Oct 01 - Oct 15, 2023 +
+ +
+
+Sorted by Batch Date + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Batch IDPeriodMerchantsGross AmountTotal FeesPayout AmountStatus
#BAT-20231015-01Oct 15, 08:00 - 12:001,240₹ 4,24,500.00₹ 1,240.00₹ 4,23,260.00 + + Completed + + +chevron_right +
#BAT-20231015-02Oct 15, 12:00 - 16:00850₹ 2,12,000.00₹ 850.00₹ 2,11,150.00 + + Processing + + +chevron_right +
#BAT-20231014-42Oct 14, 20:00 - 23:59412₹ 95,400.00₹ 412.00₹ 94,988.00 + + Failed + + +chevron_right +
#BAT-20231014-41Oct 14, 16:00 - 20:002,104₹ 12,45,200.00₹ 2,104.00₹ 12,43,096.00 + + Completed + + +chevron_right +
#BAT-20231014-40Oct 14, 12:00 - 16:001,892₹ 8,12,050.00₹ 1,892.00₹ 8,10,158.00 + + Completed + + +chevron_right +
+ +
+

Showing 1 - 5 of 248 batches

+
+ + + + +... + + +
+
+
+ +
+
+

Batch Details

+ +
+
+
+
+Batch ID +#BAT-20231015-01 +
+
+Settlement Bank + +HDFC + HDFC Bank Ltd. + +
+
+
+

Verification Progress

+
+
+
+

100% KYC Verified & Cleaned

+
+
+

Timeline

+
+
+ +

Batch Initialized

+

Oct 15, 2023 • 08:00 AM

+
+
+ +

Merchant Ledger Locked

+

Oct 15, 2023 • 08:15 AM

+
+
+ +

Bank File Uploaded (SFTP)

+

Oct 15, 2023 • 09:30 AM

+
+
+ +

Batch Settled

+

Oct 15, 2023 • 11:45 AM

+
+
+
+
+

Raw API Response

+
+ +
{
+  "batch_id": "BAT-20231015-01",
+  "status": "COMPLETED",
+  "merchant_count": 1240,
+  "net_payout": 423260.00,
+  "currency": "INR",
+  "bank_ref": "HDFC_91230491_SET"
+}
+
+
+
+
+ + +
+
+
+
+ + \ No newline at end of file diff --git a/design/settlement_batch_management/screen.png b/design/settlement_batch_management/screen.png new file mode 100644 index 0000000..868fbcb Binary files /dev/null and b/design/settlement_batch_management/screen.png differ diff --git a/design/soundbox_ops/DESIGN.md b/design/soundbox_ops/DESIGN.md new file mode 100644 index 0000000..058a5ff --- /dev/null +++ b/design/soundbox_ops/DESIGN.md @@ -0,0 +1,201 @@ +--- +name: Soundbox Ops +colors: + surface: '#faf8ff' + surface-dim: '#d9d9e5' + surface-bright: '#faf8ff' + surface-container-lowest: '#ffffff' + surface-container-low: '#f3f3fe' + surface-container: '#ededf9' + surface-container-high: '#e7e7f3' + surface-container-highest: '#e1e2ed' + on-surface: '#191b23' + on-surface-variant: '#434655' + inverse-surface: '#2e3039' + inverse-on-surface: '#f0f0fb' + outline: '#737686' + outline-variant: '#c3c6d7' + surface-tint: '#0053db' + primary: '#004ac6' + on-primary: '#ffffff' + primary-container: '#2563eb' + on-primary-container: '#eeefff' + inverse-primary: '#b4c5ff' + secondary: '#505f76' + on-secondary: '#ffffff' + secondary-container: '#d0e1fb' + on-secondary-container: '#54647a' + tertiary: '#943700' + on-tertiary: '#ffffff' + tertiary-container: '#bc4800' + on-tertiary-container: '#ffede6' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#dbe1ff' + primary-fixed-dim: '#b4c5ff' + on-primary-fixed: '#00174b' + on-primary-fixed-variant: '#003ea8' + secondary-fixed: '#d3e4fe' + secondary-fixed-dim: '#b7c8e1' + on-secondary-fixed: '#0b1c30' + on-secondary-fixed-variant: '#38485d' + tertiary-fixed: '#ffdbcd' + tertiary-fixed-dim: '#ffb596' + on-tertiary-fixed: '#360f00' + on-tertiary-fixed-variant: '#7d2d00' + background: '#F8FAFC' + on-background: '#191b23' + surface-variant: '#e1e2ed' + success: '#16A34A' + warning: '#F59E0B' + danger: '#DC2626' + info: '#0EA5E9' + slate-900: '#0F172A' + slate-700: '#334155' + slate-500: '#64748B' + slate-200: '#E2E8F0' + slate-100: '#F1F5F9' +typography: + display-lg: + fontFamily: Plus Jakarta Sans + fontSize: 36px + fontWeight: '600' + lineHeight: 44px + letterSpacing: -0.02em + headline-lg: + fontFamily: Plus Jakarta Sans + fontSize: 28px + fontWeight: '600' + lineHeight: 36px + headline-md: + fontFamily: Plus Jakarta Sans + fontSize: 20px + fontWeight: '600' + lineHeight: 28px + body-lg: + fontFamily: Inter + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + body-md: + fontFamily: Inter + fontSize: 14px + fontWeight: '400' + lineHeight: 20px + label-md: + fontFamily: Inter + fontSize: 12px + fontWeight: '500' + lineHeight: 16px + letterSpacing: 0.01em + metric-lg: + fontFamily: Inter + fontSize: 32px + fontWeight: '600' + lineHeight: 40px + metric-sm: + fontFamily: Inter + fontSize: 14px + fontWeight: '600' + lineHeight: 20px +rounded: + sm: 0.125rem + DEFAULT: 0.25rem + md: 0.375rem + lg: 0.5rem + xl: 0.75rem + full: 9999px +spacing: + topbar-height: 72px + page-padding: 24px + card-padding: 20px + row-height: 52px + gutter: 24px +--- + +## Brand & Style + +The design system is engineered for the high-stakes environment of fintech operations. The brand personality is **Precise, Reliable, and Proactive**, focusing on the seamless bridge between digital ledgers and physical IoT soundboxes. + +The chosen style is **Corporate / Modern** with a heavy emphasis on **Data-Centricity**. This is achieved through a structured information hierarchy that prioritizes rapid scanning of transaction states and device health. The interface utilizes high-density layouts, subtle tonal layering to separate operational controls from data displays, and a functional aesthetic that avoids unnecessary decoration in favor of utility and speed. + +**Key visual principles:** +- **Clarity over Flourish:** Every pixel must serve a functional purpose in the monitoring workflow. +- **Immediate Feedback:** Use of distinct semantic colors to highlight system anomalies or successful settlements. +- **Operational Efficiency:** Minimizing clicks through the use of drawers and context-aware action bars. + +## Colors + +The palette is anchored by **Primary Blue (#2563EB)** to evoke trust and established financial authority. The neutral foundation is built exclusively on the **Slate** scale, providing a cool, professional backdrop that allows semantic alerts to stand out. + +**Usage Guidance:** +- **Primary:** Actionable elements, active navigation states, and primary buttons. +- **Semantic Colors:** Reserved strictly for status indicators (Success for 'Settled', Warning for 'Degraded', Danger for 'Offline/Failed'). +- **Neutrals:** `Slate-900` for primary headings, `Slate-700` for body text, and `Slate-100/200` for borders and subtle backgrounds. +- **Background:** The `F8FAFC` base provides a low-strain environment for long-duration operational shifts. + +## Typography + +This design system employs a dual-font strategy. **Plus Jakarta Sans** is used for headings to provide a modern, clean fintech feel, while **Inter** is used for all body and functional text for maximum legibility at small sizes. + +**Tabular Numbers:** +Crucial for a platform dealing with currency (IDR) and device IDs, all metric-related typography must have `font-variant-numeric: tabular-nums` enabled. This ensures that columns of numbers align perfectly in data tables and KPI cards, facilitating easier comparison. + +**Scale and Hierarchy:** +- Headlines use Semibold (`600`) to anchor page sections. +- Body text remains Regular (`400`) for extended reading in audit logs. +- Labels use Medium (`500`) with slight letter spacing for specialized metadata. + +## Layout & Spacing + +The system follows a **12-column fluid grid** for the main content area, allowing the platform to scale from large monitoring displays to standard laptops. + +**Key Layout Rules:** +- **Fixed Infrastructure:** The topbar is locked at `72px` and the sidebar remains visible for Admin/Merchant portals to provide persistent global context. +- **Density:** We utilize a "Comfortable-Compact" rhythm. Page margins are set to `24px`, but internal card components and data tables use tighter spacing to maximize information density without sacrificing touch/click targets. +- **Vertical Rhythm:** A base `4px` / `8px` grid governs all spacing. Data tables utilize a consistent `52px` row height to accommodate status chips and action buttons comfortably. + +## Elevation & Depth + +To maintain a "fast and clean" feel, the system avoids heavy drop shadows. Instead, it utilizes **Tonal Layers** and **Low-Contrast Outlines**. + +- **Level 0 (Surface):** `#F8FAFC` (Background) - The lowest layer. +- **Level 1 (Cards/Container):** `#FFFFFF` with a `1px` border of `Slate-200`. This is the primary surface for all data widgets. +- **Level 2 (Interactive):** Elements that require focus (like drawers or modals) use a very soft, diffused shadow (`0 10px 15px -3px rgb(0 0 0 / 0.1)`) to lift them above the data grid. +- **Depth via Border:** Sticky headers in tables use a `1px` bottom border in `Slate-200` rather than a shadow to indicate they are "above" the scrolling content while maintaining a flat, professional profile. + +## Shapes + +The design system adopts a **Soft** shape language. A `0.25rem` (4px) base radius is used for most UI elements, which maintains a professional, structured look while appearing modern. + +- **Components:** Buttons, Input fields, and KPI cards use the base `rounded` (4px) or `rounded-lg` (8px). +- **Status Chips:** Use `rounded-full` (pill-shaped) to distinguish them from interactive buttons. +- **Icons:** Should follow the same geometric principles—no sharp 90-degree corners, but not overly rounded or "bubbly." + +## Components + +### KPI Cards +Standardized containers for top-level metrics. They include a `label-md` for title, a `metric-lg` for the value, and a bottom section for `metric-sm` trend indicators (e.g., +12% vs last month). + +### Data Tables +- **Sticky Header:** Background remains white with a `Slate-200` bottom border during scroll. +- **Numeric Alignment:** Amounts and percentages are right-aligned using tabular numbers. +- **Row Hover:** Use `Slate-50` background on hover to facilitate line scanning. + +### Status Chips +Pill-shaped badges with low-opacity backgrounds and high-contrast text: +- **Active/Online/Settled:** Green (Success) +- **Pending/Retrying:** Orange (Warning) +- **Failed/Offline:** Red (Danger) +- **Default/Inactive:** Slate-500 (Neutral) + +### Vertical Timelines +Used for transaction history and device lifecycle. Dots represent states; solid lines represent completed paths, and dashed lines represent pending/future steps. + +### Audit Blocks (Raw Payload Viewer) +Monospaced font (`Courier Prime` or `JetBrains Mono` as an alternative), contained in a `Slate-900` box with a 'Copy' action always present in the top-right corner. + +### Detail Drawers +Standardized side-panel that slides from the right. It allows for "quick inspection" of a merchant or device without losing the context of the main list. \ No newline at end of file diff --git a/design/transaction_history_monitoring/code.html b/design/transaction_history_monitoring/code.html new file mode 100644 index 0000000..cbca232 --- /dev/null +++ b/design/transaction_history_monitoring/code.html @@ -0,0 +1,657 @@ + + + + + +Transactions | Soundbox Ops + + + + + + + + + + + +
+
+
+search + +
+ +
+
+ + +
+
+
+

Admin User

+

Super Admin

+
+Administrator Profile +
+
+
+ +
+ +
+
+

Total Volume (24h)

+
+

Rp 1.42B

+ +trending_up + +12.4% + +
+
+
+

Success Rate

+
+

99.92%

+ +check_circle + Stable + +
+
+
+

Pending Settlements

+
+

142

+ +schedule + -5.2% + +
+
+
+

Active QRIS Soundboxes

+
+

1,894

+ +sensors + 98% Online + +
+
+
+ +
+
+Status: + +
+
+Method: + +
+
+Range: + +
+
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampTransaction IDMerchant NameAmountFeeNetStatus
+

Oct 31, 2023

+

14:22:05

+
+TXN-98421054 +

RRN: 310542918420

+
+
+
+coffee +
+
+

Brew & Co. Central

+

QRIS Dynamic

+
+
+
+

Rp 45.000

+
+

Rp 315

+
+

Rp 44.685

+
+
+ + + Settled + +
+
+ +
+

Oct 31, 2023

+

14:18:12

+
+TXN-98421053 +

RRN: 310542918421

+
+
+
+shopping_basket +
+
+

Mart Express #42

+

Static QRIS

+
+
+
+

Rp 128.400

+
+

Rp 899

+
+

Rp 127.501

+
+
+ + + Pending + +
+
+ +
+

Oct 31, 2023

+

14:05:44

+
+TXN-98421052 +

RRN: 310542918422

+
+
+
+local_gas_station +
+
+

Shell Kemang

+

Virtual Account

+
+
+
+

Rp 350.000

+
+

Rp 4.500

+
+

Rp 345.500

+
+
+ + + Failed + +
+
+ +
+

Oct 31, 2023

+

13:58:31

+
+TXN-98421051 +

RRN: 310542918423

+
+
+
+restaurant +
+
+

Sushi Tei GI

+

QRIS Dynamic

+
+
+
+

Rp 742.800

+
+

Rp 5.200

+
+

Rp 737.600

+
+
+ + + Settled + +
+
+ +
+
+ +
+

Showing 1 - 10 of 1,284 transactions

+
+ + + + + +
+
+
+
+ +
+ + + + \ No newline at end of file diff --git a/design/transaction_history_monitoring/screen.png b/design/transaction_history_monitoring/screen.png new file mode 100644 index 0000000..3348734 Binary files /dev/null and b/design/transaction_history_monitoring/screen.png differ diff --git a/dist/app.js b/dist/app.js new file mode 100644 index 0000000..40a4fd7 --- /dev/null +++ b/dist/app.js @@ -0,0 +1,65 @@ +import express from "express"; +import helmet from "helmet"; +import morgan from "morgan"; +import { requestContext } from "./shared/middleware/requestContext"; +import { handleErrors, successResponse } from "./shared/middleware/errorMiddleware"; +import adminRoutes from "./routes/admin"; +import integrationRoutes from "./routes/integrations"; +import deviceRoutes from "./routes/device"; +import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator"; +import path from "node:path"; +import fs from "node:fs"; +const app = express(); +startNotificationOrchestrator(); +app.use(helmet()); +app.use(express.json()); +app.use(morgan("dev")); +app.use(requestContext); +app.get("/", (_req, res) => { + res.json(successResponse(_req, { status: "ok" })); +}); +function resolveUiPageFile(slug) { + const workspaceRoot = process.cwd(); + const candidates = [ + path.resolve(workspaceRoot, "ui", slug, "index.html"), + path.resolve(workspaceRoot, "ui", slug.replace(/_/g, "-"), "index.html"), + path.resolve(workspaceRoot, "ui", slug.replace(/-/g, "_"), "index.html") + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) || null; +} +app.get("/ui", (_req, res) => { + const filePath = path.resolve(process.cwd(), "ui/index.html"); + res.sendFile(filePath); +}); +app.get("/ui/hub", (_req, res) => { + const filePath = path.resolve(process.cwd(), "ui/hub.html"); + res.sendFile(filePath); +}); +app.get("/ui/:page", (req, res, next) => { + const filePath = resolveUiPageFile(req.params.page); + if (!filePath) { + return next(); + } + res.sendFile(filePath); +}); +app.use("/admin", adminRoutes); +app.use("/integrations", integrationRoutes); +app.use("/device", deviceRoutes); +app.use((err, _req, res, next) => { + handleErrors(err, _req, res, next); +}); +app.get("/health", (req, res) => { + res.status(200).json(successResponse(req, { + status: "healthy", + time: new Date().toISOString() + })); +}); +app.use((req, res) => { + res.status(404).json({ + code: "NOT_FOUND", + message: `Route ${req.path} not found`, + request_id: req.requestId, + timestamp: new Date().toISOString() + }); +}); +export default app; diff --git a/dist/config/env.js b/dist/config/env.js new file mode 100644 index 0000000..2a1463c --- /dev/null +++ b/dist/config/env.js @@ -0,0 +1,17 @@ +export const env = { + PORT: Number(process.env.PORT || 3000), + ADMIN_TOKEN: process.env.ADMIN_TOKEN || "admin-dev-token", + DEVICE_TOKEN: process.env.DEVICE_TOKEN || "device-dev-token", + 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_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), + DATABASE_URL: process.env.DATABASE_URL || "", + PGHOST: process.env.PGHOST || "127.0.0.1", + PGPORT: Number(process.env.PGPORT || 5432), + PGUSER: process.env.PGUSER || "postgres", + PGPASSWORD: process.env.PGPASSWORD || "postgres", + PGDATABASE: process.env.PGDATABASE || "qris_soundbox_platform" +}; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..bc00cf3 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,13 @@ +import { createServer } from "node:http"; +import app from "./app"; +import { ensureSchema } from "./shared/db/pool"; +import { env } from "./config/env"; +const port = env.PORT; +const server = createServer(app); +async function bootstrap() { + await ensureSchema(); + server.listen(port, () => { + console.log(`QRIS Soundbox Platform bootstrap ready on :${port}`); + }); +} +void bootstrap(); diff --git a/dist/routes/admin.js b/dist/routes/admin.js new file mode 100644 index 0000000..e31bd16 --- /dev/null +++ b/dist/routes/admin.js @@ -0,0 +1,1107 @@ +import { Router } from "express"; +import { randomUUID } from "node:crypto"; +import { ApiError } from "../shared/errors"; +import { requireAdminToken } from "../shared/middleware/auth"; +import { successResponse } from "../shared/middleware/errorMiddleware"; +import { env } from "../config/env"; +import { idempotency } from "../shared/middleware/idempotency"; +import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMerchantPayload } from "../shared/store/merchantStore"; +import { createOutlet, createTerminal, getOutletById, getTerminalById, listOutlets, listTerminals, patchOutlet, patchTerminal, toOutletPayload, toTerminalPayload } from "../shared/store/locationStore"; +import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBindingPayload, unbindDevice } from "../shared/store/bindingStore"; +import { createDevice, getDeviceById, listDevices, patchDevice, toDevicePayload } from "../shared/store/deviceStore"; +import { deriveDeviceStatus, getHeartbeatCountForDeviceLastHours, getLatestHeartbeatByDeviceId, listHeartbeats, createDeviceHeartbeat } from "../shared/store/heartbeatStore"; +import { createDeviceCommand, getDeviceCommandById, listDeviceCommands, toDeviceCommandPayload, toDeviceCommandPayloadBrief } from "../shared/store/deviceCommandStore"; +import { createTransaction, getTransactionById, listTransactions, toTransactionEventPayload, toTransactionPayload, getTransactionEvents } from "../shared/store/transactionStore"; +import { getNotificationByTransactionId, listNotifications, listNotificationsByDevice, toNotificationPayload } from "../shared/store/notificationStore"; +import { retryNotificationByTransactionId } from "../shared/orchestrators/notificationOrchestrator"; +const router = Router(); +function parseIdempotentReplay(req) { + return req.body.__idempotentReplay; +} +function getReplayResponse(req) { + return req.body.__idempotentResponse; +} +function isIsoDate(value) { + if (!value) { + return false; + } + return Number.isFinite(Date.parse(value)); +} +function isTxInDateRange(tx, from, to) { + const createdAt = Date.parse(tx.created_at); + if (Number.isNaN(createdAt)) { + return false; + } + if (from && createdAt < Date.parse(from)) { + return false; + } + if (to && createdAt > Date.parse(to)) { + return false; + } + return true; +} +function parseDeviceStatusFilter(value) { + if (value === "online" || value === "offline" || value === "degraded" || value === "stale") { + return value; + } + return undefined; +} +function parseCommunicationModeFilter(value) { + if (value === "static" || value === "mqtt" || value === "api") { + return value; + } + return undefined; +} +function parseDeviceCommunicationMode(value) { + if (value === "static" || value === "mqtt" || value === "api") { + return value; + } + return undefined; +} +function parseDeviceStatusValue(value) { + if (value === "active" || value === "inactive") { + return value; + } + return undefined; +} +function parseOutletStatusFilter(value) { + if (value === "active" || value === "inactive") { + return value; + } + return undefined; +} +function parseTerminalStatusFilter(value) { + if (value === "active" || value === "inactive") { + return value; + } + return undefined; +} +function parseTerminalModeFilter(value) { + if (value === "static" || value === "dynamic_mqtt" || value === "dynamic_api") { + return value; + } + return undefined; +} +function parseTransactionStatusFilter(value) { + if (value === "initiated" || + value === "awaiting_payment" || + value === "paid" || + value === "failed" || + value === "expired" || + value === "reversed") { + return value; + } + return undefined; +} +function parseCommandStatusFilter(value) { + if (value === "accepted" || value === "delivered" || value === "failed" || value === "timeout") { + return value; + } + return undefined; +} +function buildBindingSummary(binding) { + if (!binding) { + return null; + } + return { + id: binding.id, + merchant_id: binding.merchant_id, + outlet_id: binding.outlet_id, + terminal_id: binding.terminal_id + }; +} +async function buildDeviceAdminPayload(device) { + const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); + return { + ...toDevicePayload(device), + derived_status: deriveDeviceStatus({ + last_seen_at: device.last_seen_at, + network_strength: latestHeartbeat?.network_strength ?? null, + battery_level: latestHeartbeat?.battery_level ?? null + }), + heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id), + binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)), + latest_heartbeat: latestHeartbeat + ? { + id: latestHeartbeat.id, + timestamp: latestHeartbeat.timestamp, + received_at: latestHeartbeat.received_at, + state: latestHeartbeat.state, + network_strength: latestHeartbeat.network_strength, + battery_level: latestHeartbeat.battery_level, + firmware_version: latestHeartbeat.firmware_version + } + : null + }; +} +async function deriveDeviceStatusesForDashboard() { + const devices = await listDevices(); + return Promise.all(devices.map(async (device) => { + const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); + return { + device, + status: deriveDeviceStatus({ + last_seen_at: device.last_seen_at, + network_strength: latestHeartbeat?.network_strength ?? null, + battery_level: latestHeartbeat?.battery_level ?? null + }) + }; + })); +} +function buildDashboardRange() { + const start = new Date(); + start.setUTCHours(0, 0, 0, 0); + const end = new Date(start); + end.setUTCDate(start.getUTCDate() + 1); + return { start, end }; +} +function toStartEndDateFilter(from, to) { + if (from && Number.isNaN(Date.parse(from))) { + return null; + } + if (to && Number.isNaN(Date.parse(to))) { + return null; + } + return { + fromTs: from ? Date.parse(from) : null, + toTs: to ? Date.parse(to) : null + }; +} +function normalizeMerchantMode(payloadMode) { + return payloadMode || "merchant_direct"; +} +function validatePayoutConfig(payload) { + const mode = normalizeMerchantMode(payload.payout_mode); + if (mode === "merchant_direct") { + if (!payload.settlement_account_reference || !payload.settlement_account_type) { + throw new ApiError("BAD_REQUEST", "settlement_account_reference and settlement_account_type required when payout_mode=merchant_direct", 400); + } + } +} +async function ensureMerchant(req, next) { + const merchantId = req.params.merchantId; + const merchant = await getMerchantById(merchantId); + if (!merchant) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + return merchant; +} +router.post("/login", async (req, res, next) => { + const { username, password } = req.body; + if (username !== "admin" || password !== "admin") { + return next(new ApiError("UNAUTHORIZED", "Invalid credentials", 401)); + } + const token = env.ADMIN_TOKEN; + res.json(successResponse(req, { + token + })); +}); +router.use(async (req, res, next) => { + if (req.path === "/login") { + return next(); + } + return requireAdminToken(req, res, next); +}); +router.get("/health", requireAdminToken, async (_req, res) => { + res.json(successResponse(_req, { + ok: true, + now: new Date().toISOString() + })); +}); +router.post("/sample-idempotent", requireAdminToken, idempotency({ scope: "admin.sample", required: false }), async (req, res) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + const id = randomUUID(); + res.json(successResponse(req, { id, generated_at: new Date().toISOString() })); +}); +router.post("/merchants", requireAdminToken, idempotency({ scope: "merchant.create", required: false }), async (req, res, next) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + const payload = req.body; + if (!payload?.legal_name) { + return next(new ApiError("BAD_REQUEST", "legal_name is required", 400)); + } + try { + validatePayoutConfig(payload); + } + catch (err) { + return next(err); + } + const created = await createMerchant({ + legal_name: payload.legal_name, + brand_name: payload.brand_name, + settlement_account_reference: payload.settlement_account_reference, + settlement_account_type: payload.settlement_account_type, + payout_mode: normalizeMerchantMode(payload.payout_mode), + fee_profile_id: payload.fee_profile_id, + status: payload.status, + onboarding_status: payload.onboarding_status + }); + res.status(201).json(successResponse(req, toMerchantPayload(created))); +}); +router.get("/merchants", requireAdminToken, async (_req, res) => { + res.json(successResponse(_req, (await listMerchants()).map(toMerchantPayload))); +}); +router.get("/merchants/:merchantId", requireAdminToken, async (req, res, next) => { + const merchant = await getMerchantById(req.params.merchantId); + if (!merchant) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + res.json(successResponse(req, toMerchantPayload(merchant))); +}); +router.patch("/merchants/:merchantId", requireAdminToken, async (req, res, next) => { + const payload = req.body; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + const existing = await getMerchantById(req.params.merchantId); + if (!existing) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + const normalized = { + ...payload, + payout_mode: payload.payout_mode ? payload.payout_mode : existing.payout_mode + }; + if (normalized.payout_mode === "merchant_direct") { + normalized.settlement_account_reference = + normalized.settlement_account_reference || existing.settlement_account_reference; + normalized.settlement_account_type = + normalized.settlement_account_type || existing.settlement_account_type; + } + try { + validatePayoutConfig(normalized); + } + catch (err) { + return next(err); + } + const updated = await patchMerchant(req.params.merchantId, normalized); + res.json(successResponse(req, toMerchantPayload(updated))); +}); +router.post("/merchants/:merchantId/approve", requireAdminToken, async (req, res, next) => { + const existing = await getMerchantById(req.params.merchantId); + if (!existing) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + if (existing.onboarding_status === "approved") { + return res.json(successResponse(req, toMerchantPayload(existing))); + } + const updated = await patchMerchant(req.params.merchantId, { + onboarding_status: "approved" + }); + res.json(successResponse(req, toMerchantPayload(updated))); +}); +router.post("/merchants/:merchantId/reject", requireAdminToken, async (req, res, next) => { + const payload = req.body; + if (!payload?.reason || payload.reason.trim() === "") { + return next(new ApiError("BAD_REQUEST", "reason is required", 400)); + } + const existing = await getMerchantById(req.params.merchantId); + if (!existing) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + const updated = await patchMerchant(req.params.merchantId, { + onboarding_status: "rejected", + status: "inactive" + }); + res.json(successResponse(req, { + ...toMerchantPayload(updated), + rejection_reason: payload.reason + })); +}); +router.post("/seed", requireAdminToken, idempotency({ scope: "seed.demo", required: false }), async (req, res, next) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + const payload = req.body || {}; + const includeHeartbeat = payload.include_demo_heartbeat !== false; + const includeTransactions = payload.include_demo_transactions !== false; + if ((await listMerchants()).length > 0 || (await listDevices()).length > 0 || (await listOutlets()).length > 0 || (await listTerminals()).length > 0) { + return next(new ApiError("BAD_REQUEST", "seed requires empty demo environment", 400)); + } + const merchantA = await createMerchant({ + legal_name: "Seed Merchant A", + brand_name: "Seed A", + settlement_account_reference: "seed-bank:111111111", + settlement_account_type: "merchant_bank_account", + payout_mode: "merchant_direct", + status: "active", + onboarding_status: "approved" + }); + const merchantB = await createMerchant({ + legal_name: "Seed Merchant B", + brand_name: "Seed B", + settlement_account_reference: "seed-bank:222222222", + settlement_account_type: "merchant_bank_account", + payout_mode: "manual", + status: "active", + onboarding_status: "pending" + }); + const outletA = await createOutlet({ + merchant_id: merchantA.id, + name: "Outlet Seed A", + address: "Jl. Contoh Nomor 1" + }); + const outletB = await createOutlet({ + merchant_id: merchantB.id, + name: "Outlet Seed B", + address: "Jl. Contoh Nomor 2" + }); + const terminalA = await createTerminal({ + outlet_id: outletA.id, + terminal_code: "TERM_SEED_A", + qr_mode: "static" + }); + const terminalB = await createTerminal({ + outlet_id: outletB.id, + terminal_code: "TERM_SEED_B", + qr_mode: "static" + }); + const deviceA = await createDevice({ + device_code: "DEV_SEED_A", + vendor: "seed-maker", + model: "v1", + communication_mode: "mqtt", + status: "active" + }); + const deviceB = await createDevice({ + device_code: "DEV_SEED_B", + vendor: "seed-maker", + model: "v1", + communication_mode: "mqtt", + status: "active" + }); + const deviceC = await createDevice({ + device_code: "DEV_SEED_C", + vendor: "seed-maker", + model: "v1", + communication_mode: "mqtt", + status: "inactive" + }); + await bindDevice({ + device_id: deviceA.id, + merchant_id: merchantA.id, + outlet_id: outletA.id, + terminal_id: terminalA.id + }); + await bindDevice({ + device_id: deviceB.id, + merchant_id: merchantB.id, + outlet_id: outletB.id, + terminal_id: terminalB.id + }); + if (includeHeartbeat) { + await createDeviceHeartbeat({ + device_id: deviceA.id, + timestamp: new Date().toISOString(), + firmware_version: "1.0.0", + network_strength: 92, + battery_level: 89, + state: "idle" + }); + await createDeviceHeartbeat({ + device_id: deviceB.id, + timestamp: new Date().toISOString(), + firmware_version: "1.0.0", + network_strength: 83, + battery_level: 76, + state: "idle" + }); + } + const transactions = includeTransactions + ? [ + await createTransaction({ + merchant_id: merchantA.id, + outlet_id: outletA.id, + terminal_id: terminalA.id, + device_id: deviceA.id, + partner_reference: "seed-pr-001", + amount: 25000, + currency: "IDR", + qr_mode: "static", + initiation_mode: "static", + status: "initiated" + }), + await createTransaction({ + merchant_id: merchantB.id, + outlet_id: outletB.id, + terminal_id: terminalB.id, + device_id: deviceB.id, + partner_reference: "seed-pr-002", + amount: 50000, + currency: "IDR", + qr_mode: "static", + initiation_mode: "static", + status: "awaiting_payment" + }) + ] + : []; + const seeded = { + merchants: [toMerchantPayload(merchantA), toMerchantPayload(merchantB)], + outlets: [outletA, outletB], + terminals: [terminalA, terminalB], + devices: [deviceA, deviceB, deviceC], + transactions: transactions.map((tx) => toTransactionPayload(tx)), + include_demo_heartbeat: includeHeartbeat, + include_demo_transactions: includeTransactions + }; + res.status(201).json(successResponse(req, seeded)); +}); +router.get("/seed/status", requireAdminToken, async (_req, res) => { + res.json(successResponse(_req, { + merchants: (await listMerchants()).length, + outlets: (await listOutlets()).length, + terminals: (await listTerminals()).length, + devices: (await listDevices()).length, + transactions: (await listTransactions()).length, + heartbeats: (await listHeartbeats()).length, + notifications: (await listNotifications()).length, + seed_eligible: (await listMerchants()).length === 0 && (await listDevices()).length === 0 && (await listOutlets()).length === 0 && (await listTerminals()).length === 0 + })); +}); +router.post("/merchants/:merchantId/outlets", requireAdminToken, idempotency({ scope: "outlet.create", required: false }), async (req, res, next) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + const merchant = await ensureMerchant(req, next); + if (!merchant) { + return; + } + const payload = req.body; + if (!payload?.name) { + return next(new ApiError("BAD_REQUEST", "name is required", 400)); + } + if (payload.status) { + if (!parseOutletStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + } + const outlet = await createOutlet({ + merchant_id: merchant.id, + name: payload.name, + address: payload.address, + outlet_code: payload.outlet_code, + status: payload.status + }); + res.status(201).json(successResponse(req, outlet)); +}); +router.get("/outlets", requireAdminToken, async (req, res) => { + const merchantId = req.query.merchant_id; + res.json(successResponse(req, (await listOutlets({ + merchant_id: merchantId + })).map(toOutletPayload))); +}); +router.get("/outlets/:outletId", requireAdminToken, async (req, res, next) => { + const outlet = await getOutletById(req.params.outletId); + if (!outlet) { + return next(new ApiError("NOT_FOUND", "outlet not found", 404)); + } + res.json(successResponse(req, toOutletPayload(outlet))); +}); +router.patch("/outlets/:outletId", requireAdminToken, async (req, res, next) => { + const payload = req.body; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + if (payload.status && !parseOutletStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + try { + const updated = await patchOutlet(req.params.outletId, payload); + res.json(successResponse(req, toOutletPayload(updated))); + } + catch (err) { + if (err instanceof Error && err.message === "OUTLET_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "outlet not found", 404)); + } + return next(err); + } +}); +router.post("/outlets/:outletId/terminals", requireAdminToken, idempotency({ scope: "terminal.create", required: false }), async (req, res, next) => { + if (parseIdempotentReplay(req)) { + return res.status(201).json(getReplayResponse(req)); + } + const outlet = await getOutletById(req.params.outletId); + if (!outlet) { + return next(new ApiError("NOT_FOUND", "outlet not found", 404)); + } + const payload = req.body; + if (!payload || typeof payload !== "object") { + return next(new ApiError("BAD_REQUEST", "terminal payload required", 400)); + } + if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) { + return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400)); + } + if (payload.status && !parseTerminalStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + const terminal = await createTerminal({ + outlet_id: outlet.id, + terminal_code: payload.terminal_code, + qr_mode: payload.qr_mode, + partner_reference: payload.partner_reference, + status: payload.status + }); + res.status(201).json(successResponse(req, toTerminalPayload(terminal))); +}); +router.get("/terminals", requireAdminToken, async (req, res) => { + const outletId = req.query.outlet_id; + res.json(successResponse(req, (await listTerminals({ + outlet_id: outletId + })).map(toTerminalPayload))); +}); +router.get("/terminals/:terminalId", requireAdminToken, async (req, res, next) => { + const terminal = await getTerminalById(req.params.terminalId); + if (!terminal) { + return next(new ApiError("NOT_FOUND", "terminal not found", 404)); + } + res.json(successResponse(req, toTerminalPayload(terminal))); +}); +router.patch("/terminals/:terminalId", requireAdminToken, async (req, res, next) => { + const payload = req.body; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) { + return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400)); + } + if (payload.status && !parseTerminalStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + try { + const updated = await patchTerminal(req.params.terminalId, payload); + res.json(successResponse(req, toTerminalPayload(updated))); + } + catch (err) { + if (err instanceof Error && err.message === "TERMINAL_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "terminal not found", 404)); + } + return next(err); + } +}); +router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", required: false }), async (req, res, next) => { + if (parseIdempotentReplay(req)) { + return res.status(201).json(getReplayResponse(req)); + } + const payload = req.body; + if (!payload) { + return next(new ApiError("BAD_REQUEST", "device payload required", 400)); + } + if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) { + return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400)); + } + if (payload.status && !parseDeviceStatusValue(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + const created = await createDevice(payload); + res.status(201).json(successResponse(req, toDevicePayload(created))); +}); +router.get("/devices", requireAdminToken, async (req, res) => { + const status = parseDeviceStatusFilter(req.query.status); + const vendor = req.query.vendor?.trim(); + const communicationMode = parseCommunicationModeFilter(req.query.communication_mode); + const merchantId = req.query.merchant_id?.trim(); + const q = req.query.q?.trim(); + const rawDevices = await listDevices(); + const evaluated = await Promise.all(rawDevices.map(async (device) => { + const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); + const binding = merchantId ? await getActiveBindingByDevice(device.id) : null; + return { + device, + latestHeartbeat, + binding, + derivedStatus: deriveDeviceStatus({ + last_seen_at: device.last_seen_at, + network_strength: latestHeartbeat?.network_strength ?? null, + battery_level: latestHeartbeat?.battery_level ?? null + }) + }; + })); + const data = evaluated + .filter((entry) => { + const { device, derivedStatus, binding } = entry; + if (status && derivedStatus !== status) { + return false; + } + if (vendor && device.vendor !== vendor) { + return false; + } + if (communicationMode && device.communication_mode !== communicationMode) { + return false; + } + if (merchantId && (!binding || binding.merchant_id !== merchantId)) { + return false; + } + if (q) { + const text = q.toLowerCase(); + const codeMatch = device.device_code.toLowerCase().includes(text); + const serialMatch = device.serial_number?.toLowerCase().includes(text); + if (!codeMatch && !serialMatch) { + return false; + } + } + return true; + }) + .map((entry) => entry.device); + const payloads = await Promise.all(data + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((device) => buildDeviceAdminPayload(device))); + res.json(successResponse(req, payloads)); +}); +router.get("/devices/:deviceId", requireAdminToken, async (req, res, next) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const activeBinding = await getActiveBindingByDevice(device.id); + const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); + const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id); + const notifications = (await listNotificationsByDevice(device.id)) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .slice(0, 10) + .map(toNotificationPayload); + res.json(successResponse(req, { + ...toDevicePayload(device), + derived_status: deriveDeviceStatus({ + last_seen_at: device.last_seen_at, + network_strength: latestHeartbeat?.network_strength ?? null, + battery_level: latestHeartbeat?.battery_level ?? null + }), + active_binding: activeBinding ? toBindingPayload(activeBinding) : null, + latest_heartbeat: latestHeartbeat + ? { + id: latestHeartbeat.id, + timestamp: latestHeartbeat.timestamp, + received_at: latestHeartbeat.received_at, + state: latestHeartbeat.state, + network_strength: latestHeartbeat.network_strength, + battery_level: latestHeartbeat.battery_level, + firmware_version: latestHeartbeat.firmware_version + } + : null, + heartbeat_count_24h: heartbeatCount24h, + notifications_latest: notifications + })); +}); +router.get("/devices/:deviceId/heartbeats", requireAdminToken, async (req, res, next) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const from = req.query.from; + const to = req.query.to; + const state = req.query.state; + if (from && Number.isNaN(Date.parse(from))) { + return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); + } + if (to && Number.isNaN(Date.parse(to))) { + return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); + } + res.json(successResponse(req, { + device_id: device.id, + heartbeats: await listHeartbeats({ + device_id: device.id, + from, + to, + state + }) + })); +}); +router.patch("/devices/:deviceId", requireAdminToken, async (req, res, next) => { + const payload = req.body; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) { + return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400)); + } + if (payload.status && !parseDeviceStatusValue(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + try { + const updated = await patchDevice(req.params.deviceId, payload); + res.json(successResponse(req, toDevicePayload(updated))); + } + catch (err) { + if (err instanceof Error && err.message === "DEVICE_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + return next(err); + } +}); +router.post("/devices/:deviceId/bind", requireAdminToken, idempotency({ scope: "device.bind", required: false }), async (req, res, next) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const payload = req.body; + if (!payload?.merchant_id || !payload.outlet_id || !payload.terminal_id) { + return next(new ApiError("BAD_REQUEST", "merchant_id, outlet_id, terminal_id required", 400)); + } + const merchant = await getMerchantById(payload.merchant_id); + const outlet = await getOutletById(payload.outlet_id); + const terminal = await getTerminalById(payload.terminal_id); + if (!merchant || !outlet || !terminal) { + return next(new ApiError("BAD_REQUEST", "merchant/outlet/terminal reference invalid", 400)); + } + if (outlet.merchant_id !== merchant.id) { + return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400)); + } + if (terminal.outlet_id !== outlet.id) { + return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400)); + } + const binding = await bindDevice({ + device_id: device.id, + merchant_id: merchant.id, + outlet_id: outlet.id, + terminal_id: terminal.id + }); + res.json(successResponse(req, toBindingPayload(binding))); +}); +router.post("/devices/:deviceId/unbind", requireAdminToken, async (req, res, next) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const binding = await unbindDevice(device.id); + if (!binding) { + return next(new ApiError("BAD_REQUEST", "device has no active binding", 400)); + } + res.json(successResponse(req, toBindingPayload(binding))); +}); +router.post("/devices/:deviceId/commands", requireAdminToken, async (req, res, next) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const payload = req.body; + if (!payload || typeof payload.command !== "string" || payload.command.trim() === "") { + return next(new ApiError("BAD_REQUEST", "command is required", 400)); + } + const command = await createDeviceCommand({ + device_id: device.id, + command: payload.command.trim(), + payload: payload.payload || {} + }); + res.status(201).json(successResponse(req, toDeviceCommandPayload(command))); +}); +router.get("/devices/:deviceId/commands", requireAdminToken, async (req, res, next) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const statusFilter = parseCommandStatusFilter(req.query.status); + const limitRaw = req.query.limit; + const limit = limitRaw ? Number(limitRaw) : 50; + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + const commands = (await listDeviceCommands(device.id)) + .filter((command) => !statusFilter || command.status === statusFilter) + .slice(0, Math.min(limit, 200)) + .map(toDeviceCommandPayloadBrief); + res.json(successResponse(req, { device_id: device.id, commands })); +}); +router.get("/devices/:deviceId/commands/:commandId", requireAdminToken, async (req, res, next) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const command = await getDeviceCommandById(device.id, req.params.commandId); + if (!command) { + return next(new ApiError("NOT_FOUND", "command not found", 404)); + } + res.json(successResponse(req, toDeviceCommandPayload(command))); +}); +router.get("/devices/:deviceId/notifications", requireAdminToken, async (req, res, next) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const limitRaw = req.query.limit; + const limit = limitRaw ? Number(limitRaw) : 50; + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + const notifications = (await listNotificationsByDevice(device.id)) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .slice(0, Math.min(limit, 200)) + .map(toNotificationPayload); + res.json(successResponse(req, { device_id: device.id, notifications })); +}); +router.post("/transactions", requireAdminToken, idempotency({ scope: "transaction.create", required: false }), async (req, res, next) => { + if (parseIdempotentReplay(req)) { + return res.status(201).json(getReplayResponse(req)); + } + const payload = req.body; + if (!payload || + !payload.partner_reference || + !payload.merchant_id || + !payload.outlet_id || + !payload.terminal_id) { + return next(new ApiError("BAD_REQUEST", "partner_reference, merchant_id, outlet_id, terminal_id required", 400)); + } + const merchant = await getMerchantById(payload.merchant_id); + if (!merchant) { + return next(new ApiError("BAD_REQUEST", "merchant not found", 400)); + } + const outlet = await getOutletById(payload.outlet_id); + if (!outlet) { + return next(new ApiError("BAD_REQUEST", "outlet not found", 400)); + } + if (outlet.merchant_id !== merchant.id) { + return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400)); + } + const terminal = await getTerminalById(payload.terminal_id); + if (!terminal) { + return next(new ApiError("BAD_REQUEST", "terminal not found", 400)); + } + if (terminal.outlet_id !== outlet.id) { + return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400)); + } + if (payload.device_id && !await getDeviceById(payload.device_id)) { + return next(new ApiError("BAD_REQUEST", "device not found", 400)); + } + const amount = Number(payload.amount); + if (!Number.isFinite(amount) || amount <= 0) { + return next(new ApiError("BAD_REQUEST", "amount must be a positive number", 400)); + } + if (payload.status && !parseTransactionStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "invalid status", 400)); + } + const created = await createTransaction({ + merchant_id: merchant.id, + outlet_id: outlet.id, + terminal_id: terminal.id, + device_id: payload.device_id, + partner_reference: payload.partner_reference, + amount, + currency: payload.currency, + qr_mode: payload.qr_mode || "static", + initiation_mode: payload.initiation_mode || "static", + status: payload.status || "initiated" + }); + res.status(201).json(successResponse(req, toTransactionPayload(created))); +}); +router.get("/transactions", requireAdminToken, async (req, res, next) => { + const status = req.query.status; + const merchantId = req.query.merchant_id; + const from = req.query.from; + const to = req.query.to; + const partnerReference = req.query.partner_reference; + const normalizedStatus = typeof status === "string" ? parseTransactionStatusFilter(status) : undefined; + 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)); + } + const normalizedPartnerRef = partnerReference?.trim(); + res.json(successResponse(req, (await listTransactions({ + status: normalizedStatus, + merchant_id: merchantId + })) + .filter((tx) => isTxInDateRange(tx, from, to)) + .filter((tx) => !normalizedPartnerRef || tx.partner_reference === normalizedPartnerRef) + .map(toTransactionPayload))); +}); +router.get("/transactions/:transactionId", requireAdminToken, async (req, res, next) => { + const tx = await getTransactionById(req.params.transactionId); + if (!tx) { + return next(new ApiError("NOT_FOUND", "transaction not found", 404)); + } + const events = (await getTransactionEvents(tx.id)).map(toTransactionEventPayload); + const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null; + const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id; + const heartbeatHistory = heartbeatDeviceId + ? (await listHeartbeats({ device_id: heartbeatDeviceId })).map((heartbeat) => ({ + id: heartbeat.id, + device_id: heartbeat.device_id, + timestamp: heartbeat.timestamp, + state: heartbeat.state, + network_strength: heartbeat.network_strength, + battery_level: heartbeat.battery_level, + firmware_version: heartbeat.firmware_version, + received_at: heartbeat.received_at + })) + : []; + res.json(successResponse(req, { + transaction: toTransactionPayload(tx), + events, + heartbeat_device_id: heartbeatDeviceId, + heartbeat_history: heartbeatHistory + })); +}); +router.get("/transactions/:transactionId/events", requireAdminToken, async (req, res, next) => { + const tx = await getTransactionById(req.params.transactionId); + if (!tx) { + return next(new ApiError("NOT_FOUND", "transaction not found", 404)); + } + const events = (await getTransactionEvents(tx.id)) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .map(toTransactionEventPayload); + res.json(successResponse(req, { transaction_id: tx.id, events })); +}); +router.get("/transactions/:transactionId/heartbeats", requireAdminToken, async (req, res, next) => { + const tx = await getTransactionById(req.params.transactionId); + if (!tx) { + return next(new ApiError("NOT_FOUND", "transaction not found", 404)); + } + const from = req.query.from; + const to = req.query.to; + const state = req.query.state; + const limitRaw = req.query.limit; + const limit = limitRaw ? Number(limitRaw) : 100; + if (from && Number.isNaN(Date.parse(from))) { + return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); + } + if (to && Number.isNaN(Date.parse(to))) { + return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); + } + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null; + const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id; + if (!heartbeatDeviceId) { + return res.json(successResponse(req, { + transaction_id: tx.id, + heartbeat_device_id: null, + heartbeats: [] + })); + } + const heartbeats = (await listHeartbeats({ + device_id: heartbeatDeviceId, + from, + to, + state + })) + .slice(0, Math.min(limit, 500)) + .map((heartbeat) => ({ + id: heartbeat.id, + device_id: heartbeat.device_id, + timestamp: heartbeat.timestamp, + received_at: heartbeat.received_at, + state: heartbeat.state, + network_strength: heartbeat.network_strength, + battery_level: heartbeat.battery_level, + firmware_version: heartbeat.firmware_version + })); + res.json(successResponse(req, { + transaction_id: tx.id, + heartbeat_device_id: heartbeatDeviceId, + heartbeats + })); +}); +router.post("/transactions/:transactionId/retry-notification", requireAdminToken, async (req, res, next) => { + const transactionId = req.params.transactionId; + try { + const tx = await getTransactionById(transactionId); + if (!tx) { + return next(new ApiError("NOT_FOUND", "transaction not found", 404)); + } + if (tx.status !== "paid") { + return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400)); + } + const before = await getNotificationByTransactionId(transactionId); + if (!before) { + return next(new ApiError("NOT_FOUND", "notification not found for this transaction", 404)); + } + if (before.delivery_status === "acknowledged") { + return res.status(200).json(successResponse(req, { + transaction_id: tx.id, + notification_id: before.id, + delivery_status: before.delivery_status, + next_retry_at: before.next_retry_at || null + })); + } + const updated = await retryNotificationByTransactionId(transactionId); + if (!updated) { + return next(new ApiError("NOTIFICATION_PUBLISH_FAILED", "notification retry could not be executed", 500)); + } + res.json(successResponse(req, { + transaction_id: tx.id, + notification_id: updated.id, + delivery_status: updated.delivery_status, + next_retry_at: updated.next_retry_at || null + })); + } + catch (error) { + if (error instanceof Error && error.message === "NOTIFICATION_PUBLISH_CONDITION") { + return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400)); + } + return next(error); + } +}); +router.get("/dashboard/summary", requireAdminToken, async (req, res, next) => { + try { + const { start, end } = buildDashboardRange(); + const startTs = start.getTime(); + const endTs = end.getTime(); + const todayTransactions = (await listTransactions()).filter((tx) => { + const createdTs = Date.parse(tx.created_at); + return createdTs >= startTs && createdTs < endTs; + }); + const paidToday = todayTransactions.filter((tx) => tx.status === "paid").length; + const transactionsToday = todayTransactions.length; + const successRateToday = transactionsToday > 0 ? (paidToday / transactionsToday) * 100 : 0; + const statuses = await deriveDeviceStatusesForDashboard(); + 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; + const pendingNotifications = (await listNotifications()).filter((notification) => { + return notification.delivery_status === "queued" || notification.delivery_status === "retrying"; + }).length; + res.json(successResponse(req, { + transactions_today: transactionsToday, + success_rate_today: Number(successRateToday.toFixed(2)), + active_devices: activeDevices, + pending_notifications: pendingNotifications, + devices_stale: devicesStale, + devices_offline: devicesOffline + })); + } + catch (error) { + return next(error); + } +}); +router.get("/notifications/failed", requireAdminToken, async (req, res, next) => { + const deviceId = req.query.device_id; + const from = req.query.from; + const to = req.query.to; + const range = toStartEndDateFilter(from, to); + if ((from || to) && range === null) { + return next(new ApiError("BAD_REQUEST", "from/to must be valid ISO datetime", 400)); + } + const filtered = (await listNotifications()) + .filter((notification) => notification.delivery_status === "failed") + .filter((notification) => !deviceId || notification.device_id === deviceId) + .filter((notification) => { + if (!range) { + return true; + } + const createdTs = Date.parse(notification.created_at); + if (range.fromTs && createdTs < range.fromTs) { + return false; + } + if (range.toTs && createdTs > range.toTs) { + return false; + } + return true; + }) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .map((notification) => ({ + notification_id: notification.id, + transaction_id: notification.transaction_id, + device_id: notification.device_id, + delivery_status: notification.delivery_status, + retry_count: notification.retry_count, + reason: notification.reason + })); + res.json(successResponse(req, filtered)); +}); +export default router; diff --git a/dist/routes/device.js b/dist/routes/device.js new file mode 100644 index 0000000..dad8330 --- /dev/null +++ b/dist/routes/device.js @@ -0,0 +1,126 @@ +import { Router } from "express"; +import { ApiError } from "../shared/errors"; +import { requireDeviceToken } from "../shared/middleware/auth"; +import { successResponse } from "../shared/middleware/errorMiddleware"; +import { getDeviceById, patchDevice } from "../shared/store/deviceStore"; +import { createDeviceHeartbeat } from "../shared/store/heartbeatStore"; +import { acknowledgeDeviceCommand } from "../shared/store/deviceCommandStore"; +const router = Router(); +function normalizeNumberOrNull(value) { + if (typeof value === "string") { + const parsed = Number(value); + if (!Number.isNaN(parsed) && Number.isFinite(parsed)) { + return parsed; + } + return null; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + return null; +} +function normalizeSignalStrength(value) { + const normalized = normalizeNumberOrNull(value); + if (normalized === null) { + return null; + } + if (normalized < 0 || normalized > 100) { + throw new Error("NETWORK_STRENGTH_OUT_OF_RANGE"); + } + return normalized; +} +function normalizeBatteryLevel(value) { + const normalized = normalizeNumberOrNull(value); + if (normalized === null) { + return null; + } + if (normalized < 0 || normalized > 100) { + throw new Error("BATTERY_LEVEL_OUT_OF_RANGE"); + } + return normalized; +} +router.post("/heartbeat", requireDeviceToken, async (req, res, next) => { + const payload = req.body; + if (!payload || !payload.device_id) { + return next(new ApiError("BAD_REQUEST", "device_id is required", 400)); + } + const device = await getDeviceById(payload.device_id); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const eventTs = payload.timestamp ? new Date(payload.timestamp) : new Date(); + if (Number.isNaN(eventTs.getTime())) { + return next(new ApiError("BAD_REQUEST", "timestamp must be valid ISO datetime", 400)); + } + let payloadNetworkStrength; + let payloadBattery; + try { + payloadNetworkStrength = normalizeSignalStrength(payload.network_strength); + payloadBattery = normalizeBatteryLevel(payload.battery_level); + } + catch (error) { + if (error instanceof Error && error.message === "NETWORK_STRENGTH_OUT_OF_RANGE") { + return next(new ApiError("BAD_REQUEST", "network_strength must be between 0 and 100", 400)); + } + if (error instanceof Error && error.message === "BATTERY_LEVEL_OUT_OF_RANGE") { + return next(new ApiError("BAD_REQUEST", "battery_level must be between 0 and 100", 400)); + } + return next(error); + } + const heartbeat = await createDeviceHeartbeat({ + device_id: payload.device_id, + timestamp: eventTs.toISOString(), + firmware_version: payload.firmware_version, + network_strength: payloadNetworkStrength, + battery_level: payloadBattery, + state: payload.state, + payload_json: { + network_strength_raw: payload.network_strength, + battery_level_raw: payload.battery_level, + state: payload.state, + firmware_version: payload.firmware_version, + timestamp: payload.timestamp, + request_id: req.requestId + } + }); + await patchDevice(payload.device_id, { + last_seen_at: heartbeat.timestamp, + firmware_version: payload.firmware_version || device.firmware_version + }); + res.json(successResponse(req, { + heartbeat_id: heartbeat.id, + device_id: heartbeat.device_id, + request_id: req.requestId, + server_time: heartbeat.received_at + })); +}); +router.post("/commands/ack", requireDeviceToken, async (req, res, next) => { + const payload = req.body; + if (!payload || !payload.command_id || !payload.device_id || !payload.status) { + return next(new ApiError("BAD_REQUEST", "command_id, device_id, status are required", 400)); + } + if (!["delivered", "failed", "timeout"].includes(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be delivered, failed, or timeout", 400)); + } + const device = await getDeviceById(payload.device_id); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + const updated = await acknowledgeDeviceCommand({ + device_id: device.id, + command_id: payload.command_id, + status: payload.status, + reason: payload.reason, + result_payload: payload.result_payload + }); + if (!updated) { + return next(new ApiError("NOT_FOUND", "command not found", 404)); + } + res.json(successResponse(req, { + command_id: updated.id, + device_id: updated.device_id, + status: updated.status, + acknowledged_at: updated.acknowledged_at + })); +}); +export default router; diff --git a/dist/routes/integrations.js b/dist/routes/integrations.js new file mode 100644 index 0000000..858afde --- /dev/null +++ b/dist/routes/integrations.js @@ -0,0 +1,242 @@ +import { Router } from "express"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { ApiError } from "../shared/errors"; +import { successResponse } from "../shared/middleware/errorMiddleware"; +import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempotencyStore"; +import { addTransactionEvent, findTransactionByPartnerReference, getTransactionEvents, updateTransactionStatus } from "../shared/store/transactionStore"; +import { emitTransactionPaid } from "../shared/events/transactionEvents"; +import { env } from "../config/env"; +const router = Router(); +function parsePaymentStatus(rawStatus) { + const normalized = String(rawStatus || "").toLowerCase(); + if (["paid", "success", "successful", "settled", "completed"].includes(normalized)) { + return { status: "paid" }; + } + if (["failed", "declined", "rejected", "error", "cancelled"].includes(normalized)) { + return { status: "failed" }; + } + if (["expired", "timeout", "stale"].includes(normalized)) { + return { status: "expired" }; + } + return { status: "failed", reason: "UNKNOWN_STATUS" }; +} +function verifySignature(payload, signature) { + if (!signature) { + throw new ApiError("WEBHOOK_SIGNATURE_INVALID", "missing X-Partner-Signature", 401); + } + const { signature: _ignoredSignature, ...signaturePayload } = payload; + const secret = env.INTEGRATION_WEBHOOK_SECRET; + const expected = createHmac("sha256", secret) + .update(JSON.stringify(signaturePayload)) + .digest("hex"); + const a = Buffer.from(signature.toLowerCase(), "utf8"); + const b = Buffer.from(expected.toLowerCase(), "utf8"); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new ApiError("WEBHOOK_SIGNATURE_INVALID", "invalid X-Partner-Signature", 401); + } +} +function parseCallback(payload) { + const partnerReference = payload.partner_reference; + if (!partnerReference) { + throw new ApiError("CALLBACK_PARTNER_DATA_INVALID", "partner_reference is required", 400); + } + const amount = Number(payload.amount || 0); + if (!Number.isFinite(amount) || amount <= 0) { + throw new ApiError("CALLBACK_PARTNER_DATA_INVALID", "amount must be > 0", 400); + } + const parsed = { + partnerReference, + partnerTxnId: payload.partner_txn_id, + status: parsePaymentStatus(payload.payment_status || payload.status), + amount, + paidAt: payload.paid_at, + currency: typeof payload.currency === "string" && payload.currency.length > 0 ? payload.currency.toUpperCase() : "IDR" + }; + return parsed; +} +function makeIdempotencyKey(payload, headerValue) { + if (headerValue) { + return headerValue; + } + return `${payload.partnerReference}:${payload.partnerTxnId || ""}:${payload.status.status}`; +} +function buildCallbackResponse(req, transactionId, eventId, note, reason) { + const response = { + status: "accepted", + event_id: eventId, + transaction_id: transactionId, + request_id: req.requestId, + timestamp: new Date().toISOString() + }; + if (note) { + response.note = note; + } + if (reason) { + response.reason = reason; + } + return response; +} +function writeCallbackResult(idempotencyKey, response, transactionId) { + writeIdempotency("callback.processing", idempotencyKey, { response, transaction_id: transactionId }, env.IDEMPOTENCY_TTL_MS); +} +async function makeResponseEventId(txId, fallbackTag) { + const events = await getTransactionEvents(txId); + return events.at(-1)?.id || `${fallbackTag}_${Date.now()}`; +} +router.post("/qris/callback", async (req, res, next) => { + const incoming = req.body; + const signature = req.header("X-Partner-Signature"); + let parsed; + try { + verifySignature(incoming, signature); + parsed = parseCallback(incoming); + } + catch (error) { + return next(error); + } + const idempotencyKey = makeIdempotencyKey(parsed, req.header("Idempotency-Key") || undefined); + const cache = readIdempotency("callback.processing", idempotencyKey); + if (cache && typeof cache === "object" && "response" in cache) { + const cached = cache; + const transactionId = typeof cached.transaction_id === "string" ? cached.transaction_id : null; + if (transactionId) { + await addTransactionEvent({ + transaction_id: transactionId, + event_type: "CALLBACK_DUPLICATE", + source: "webhook", + payload_json: { + idempotency_key: idempotencyKey, + source_payload: incoming + } + }); + } + return res.json(successResponse(req, cached.response)); + } + const tx = await findTransactionByPartnerReference(parsed.partnerReference); + if (!tx) { + const response = buildCallbackResponse(req, null, `callback_no_tx_${Date.now()}`, "TRANSACTION_NOT_FOUND"); + writeCallbackResult(idempotencyKey, response, null); + return res.json(successResponse(req, response)); + } + if (parsed.amount !== tx.amount) { + const response = buildCallbackResponse(req, tx.id, `callback_amount_mismatch_${Date.now()}`, "AMOUNT_MISMATCH"); + await addTransactionEvent({ + transaction_id: tx.id, + event_type: "CALLBACK_REJECTED", + source: "webhook", + payload_json: { + idempotency_key: idempotencyKey, + received_amount: parsed.amount, + expected_amount: tx.amount + } + }); + writeCallbackResult(idempotencyKey, response, tx.id); + return res.status(409).json(successResponse(req, response)); + } + await addTransactionEvent({ + transaction_id: tx.id, + event_type: "CALLBACK_RECEIVED", + source: "webhook", + payload_json: { + idempotency_key: idempotencyKey, + partner_reference: parsed.partnerReference, + partner_txn_id: parsed.partnerTxnId, + candidate_status: parsed.status.status, + status_reason: parsed.status.reason, + partner_payload: incoming + } + }); + const eventContext = { + partner_reference: parsed.partnerReference, + partner_txn_id: parsed.partnerTxnId, + idempotency_key: idempotencyKey, + candidate_currency: parsed.currency, + reason: parsed.status.reason + }; + if (parsed.status.status === "paid") { + const wasPaid = tx.status === "paid"; + let updated; + try { + updated = await updateTransactionStatus(tx.id, "paid", { + source: "webhook", + eventContext, + paid_at: parsed.paidAt + }); + } + catch (error) { + if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { + const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected"); + writeCallbackResult(idempotencyKey, response, tx.id); + return res.status(409).json(successResponse(req, response)); + } + throw error; + } + if (!wasPaid) { + emitTransactionPaid({ + transaction_id: updated.id, + merchant_id: updated.merchant_id, + outlet_id: updated.outlet_id, + terminal_id: updated.terminal_id, + device_id: updated.device_id, + amount: updated.amount, + currency: updated.currency, + partner_reference: updated.partner_reference, + paid_at: updated.paid_at + }); + await addTransactionEvent({ + transaction_id: updated.id, + event_type: "PUSH_QUEUED", + source: "system", + payload_json: { + event_type: "transaction.paid", + transaction_id: updated.id, + partner_reference: updated.partner_reference, + device_id: updated.device_id + } + }); + } + const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event")); + writeCallbackResult(idempotencyKey, response, updated.id); + return res.json(successResponse(req, response)); + } + if (parsed.status.status === "expired") { + let updated; + try { + updated = await updateTransactionStatus(tx.id, "expired", { + source: "webhook", + eventContext, + expired_at: new Date().toISOString() + }); + } + catch (error) { + if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { + const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected"); + writeCallbackResult(idempotencyKey, response, tx.id); + return res.status(409).json(successResponse(req, response)); + } + throw error; + } + const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event")); + writeCallbackResult(idempotencyKey, response, updated.id); + return res.json(successResponse(req, response)); + } + let updated; + try { + updated = await updateTransactionStatus(tx.id, "failed", { + source: "webhook", + eventContext + }); + } + catch (error) { + if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { + const response = buildCallbackResponse(req, tx.id, `callback_transition_${Date.now()}`, "transaction state transition rejected"); + writeCallbackResult(idempotencyKey, response, tx.id); + return res.status(409).json(successResponse(req, response)); + } + throw error; + } + const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event"), undefined, parsed.status.reason); + writeCallbackResult(idempotencyKey, response, updated.id); + return res.json(successResponse(req, response)); +}); +export default router; diff --git a/dist/shared/db/pool.js b/dist/shared/db/pool.js new file mode 100644 index 0000000..34fa81c --- /dev/null +++ b/dist/shared/db/pool.js @@ -0,0 +1,195 @@ +import { Pool } from "pg"; +import { env } from "../../config/env"; +let pool = null; +function buildPoolConfig() { + if (env.DATABASE_URL) { + return { + connectionString: env.DATABASE_URL + }; + } + return { + host: env.PGHOST, + port: env.PGPORT, + user: env.PGUSER, + password: env.PGPASSWORD, + database: env.PGDATABASE + }; +} +export function getPool() { + if (!pool) { + const config = buildPoolConfig(); + pool = new Pool(config); + } + return pool; +} +export async function withClient(work) { + const client = await getPool().connect(); + try { + return await work(client); + } + finally { + client.release(); + } +} +export async function ensureSchema() { + const pool = getPool(); + await pool.query(MIGRATIONS_SQL); +} +const MIGRATIONS_SQL = ` +BEGIN; + +CREATE TABLE IF NOT EXISTS merchants ( + id TEXT PRIMARY KEY, + merchant_code TEXT NOT NULL UNIQUE, + legal_name TEXT NOT NULL, + brand_name TEXT, + settlement_account_reference TEXT, + settlement_account_type TEXT, + payout_mode TEXT NOT NULL DEFAULT 'merchant_direct' CHECK (payout_mode IN ('merchant_direct', 'manual')), + fee_profile_id TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + onboarding_status TEXT NOT NULL DEFAULT 'pending' CHECK (onboarding_status IN ('pending', 'approved', 'rejected')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS outlets ( + id TEXT PRIMARY KEY, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + outlet_code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + address TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS terminals ( + id TEXT PRIMARY KEY, + outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, + terminal_code TEXT NOT NULL UNIQUE, + qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')), + partner_reference TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + device_code TEXT NOT NULL UNIQUE, + serial_number TEXT, + vendor TEXT, + model TEXT, + 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, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + last_seen_at TIMESTAMPTZ, + firmware_version TEXT, + created_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 TABLE IF NOT EXISTS device_bindings ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, + terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE, + active_flag BOOLEAN NOT NULL DEFAULT false, + bound_at TIMESTAMPTZ NOT NULL, + unbound_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_device_active_binding ON device_bindings (device_id) WHERE active_flag = TRUE; +CREATE INDEX IF NOT EXISTS idx_device_bindings_terminal_active ON device_bindings (terminal_id, active_flag); + +CREATE TABLE IF NOT EXISTS device_heartbeats ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + timestamp TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL, + firmware_version TEXT, + network_strength INTEGER, + battery_level INTEGER, + state TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_device_heartbeats_device ON device_heartbeats (device_id, received_at DESC); + +CREATE TABLE IF NOT EXISTS device_commands ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + command TEXT NOT NULL, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'accepted' CHECK (status IN ('accepted', 'delivered', 'failed', 'timeout')), + requested_at TIMESTAMPTZ NOT NULL, + acknowledged_at TIMESTAMPTZ, + result_payload_json JSONB, + reason TEXT +); + +CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC); + +CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + transaction_code TEXT NOT NULL UNIQUE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, + terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE, + device_id TEXT REFERENCES devices (id) ON DELETE SET NULL, + qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic')), + initiation_mode TEXT NOT NULL DEFAULT 'static' CHECK (initiation_mode IN ('static', 'manual', 'dynamic_api', 'dynamic_mqtt')), + partner_reference TEXT NOT NULL UNIQUE, + amount NUMERIC(20,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'IDR', + status TEXT NOT NULL DEFAULT 'initiated' CHECK (status IN ('initiated', 'awaiting_payment', 'paid', 'failed', 'expired', 'reversed')), + created_at TIMESTAMPTZ NOT NULL, + paid_at TIMESTAMPTZ, + expired_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_transactions_partner_ref ON transactions (partner_reference); +CREATE INDEX IF NOT EXISTS idx_transactions_merchant_created ON transactions (merchant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_transactions_status_created ON transactions (status, created_at DESC); + +CREATE TABLE IF NOT EXISTS transaction_events ( + id TEXT PRIMARY KEY, + transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + source TEXT NOT NULL, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_transaction_events_tx ON transaction_events (transaction_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, + device_id TEXT REFERENCES devices (id) ON DELETE SET NULL, + delivery_channel TEXT NOT NULL DEFAULT 'mqtt' CHECK (delivery_channel IN ('mqtt')), + payload_type TEXT NOT NULL DEFAULT 'payment_success' CHECK (payload_type IN ('payment_success')), + delivery_status TEXT NOT NULL CHECK (delivery_status IN ('queued', 'sent', 'acknowledged', 'failed', 'retrying')), + retry_count INT NOT NULL DEFAULT 0, + ack_status TEXT NOT NULL DEFAULT 'not_needed' CHECK (ack_status IN ('pending', 'received', 'not_supported', 'not_needed')), + event_id TEXT NOT NULL, + reason TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + sent_at TIMESTAMPTZ, + ack_at TIMESTAMPTZ, + next_retry_at TIMESTAMPTZ, + CONSTRAINT notifications_unique_tx_event UNIQUE (transaction_id, event_id) +); + +CREATE INDEX IF NOT EXISTS idx_notifications_device_status ON notifications (device_id, delivery_status); +CREATE INDEX IF NOT EXISTS idx_notifications_status_created ON notifications (delivery_status, created_at DESC); + +COMMIT; +`; diff --git a/dist/shared/errors/index.js b/dist/shared/errors/index.js new file mode 100644 index 0000000..13e2397 --- /dev/null +++ b/dist/shared/errors/index.js @@ -0,0 +1,21 @@ +export class ApiError extends Error { + statusCode; + code; + details; + constructor(code, message, statusCode = 400, details) { + super(message); + this.code = code; + this.statusCode = statusCode; + this.details = details; + Error.captureStackTrace(this, this.constructor); + } +} +export function errorEnvelope(error, requestId) { + return { + code: error.code, + message: error.message, + details: error.details, + request_id: requestId, + timestamp: new Date().toISOString() + }; +} diff --git a/dist/shared/events/transactionEvents.js b/dist/shared/events/transactionEvents.js new file mode 100644 index 0000000..57b0dad --- /dev/null +++ b/dist/shared/events/transactionEvents.js @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +const transactionPaidEvents = new Map(); +const transactionPaidIndex = new Map(); +const transactionPaidSubscribers = new Set(); +function nowIso() { + return new Date().toISOString(); +} +function cloneInternalEvent(event) { + return { + ...event, + payload_json: { ...event.payload_json } + }; +} +export function subscribeTransactionPaid(handler) { + transactionPaidSubscribers.add(handler); + return () => transactionPaidSubscribers.delete(handler); +} +export function emitTransactionPaid(payload) { + const existingId = transactionPaidIndex.get(payload.transaction_id); + if (existingId) { + const events = transactionPaidEvents.get(payload.transaction_id) || []; + const existing = events.find((event) => event.id === existingId); + if (existing) { + return cloneInternalEvent(existing); + } + } + const event = { + id: randomUUID(), + event_type: "transaction.paid", + transaction_id: payload.transaction_id, + payload_json: payload, + created_at: nowIso() + }; + const bucket = transactionPaidEvents.get(payload.transaction_id) || []; + bucket.push(event); + transactionPaidEvents.set(payload.transaction_id, bucket); + transactionPaidIndex.set(payload.transaction_id, event.id); + const frozen = cloneInternalEvent(event); + for (const listener of Array.from(transactionPaidSubscribers)) { + listener(frozen); + } + return frozen; +} +export function getTransactionPaidEvents(transactionId) { + if (transactionId) { + return (transactionPaidEvents.get(transactionId) || []).map(cloneInternalEvent); + } + return Array.from(transactionPaidEvents.values()).flatMap((bucket) => bucket.map(cloneInternalEvent)); +} +export function getTransactionPaidEventByTransactionId(transactionId) { + const events = transactionPaidEvents.get(transactionId) || []; + return events.length > 0 ? cloneInternalEvent(events[events.length - 1]) : null; +} diff --git a/dist/shared/idempotency/idempotencyStore.js b/dist/shared/idempotency/idempotencyStore.js new file mode 100644 index 0000000..e3b0475 --- /dev/null +++ b/dist/shared/idempotency/idempotencyStore.js @@ -0,0 +1,26 @@ +const store = new Map(); +export function makeIdempotencyKey(scope, key) { + return `${scope}:${key}`; +} +export function readIdempotency(scope, key) { + const entry = store.get(makeIdempotencyKey(scope, key)); + if (!entry) { + return null; + } + if (entry.expiresAt < Date.now()) { + store.delete(makeIdempotencyKey(scope, key)); + return null; + } + return entry.value; +} +export function writeIdempotency(scope, key, value, ttlMs) { + store.set(makeIdempotencyKey(scope, key), { + key, + scope, + value, + expiresAt: Date.now() + ttlMs + }); +} +export function clearIdempotency(scope, key) { + store.delete(makeIdempotencyKey(scope, key)); +} diff --git a/dist/shared/middleware/auth.js b/dist/shared/middleware/auth.js new file mode 100644 index 0000000..60dc476 --- /dev/null +++ b/dist/shared/middleware/auth.js @@ -0,0 +1,30 @@ +import { ApiError } from "../errors"; +import { env } from "../../config/env"; +function extractAdminToken(req) { + const raw = req.header("authorization") || ""; + if (raw.startsWith("Bearer ")) { + return raw.slice(7); + } + return raw || req.header("x-admin-token") || ""; +} +export function requireAdminToken(req, _res, next) { + const token = extractAdminToken(req); + if (!token) { + return next(new ApiError("UNAUTHORIZED", "Missing admin bearer token", 401)); + } + if (token !== env.ADMIN_TOKEN) { + return next(new ApiError("UNAUTHORIZED", "Invalid admin token", 401)); + } + return next(); +} +export function requireDeviceToken(req, _res, next) { + const raw = req.header("authorization") || ""; + const token = raw.startsWith("Bearer ") ? raw.slice(7) : raw; + if (!token) { + return next(new ApiError("UNAUTHORIZED", "Missing device bearer token", 401)); + } + if (token !== env.DEVICE_TOKEN) { + return next(new ApiError("UNAUTHORIZED", "Invalid device token", 401)); + } + return next(); +} diff --git a/dist/shared/middleware/errorMiddleware.js b/dist/shared/middleware/errorMiddleware.js new file mode 100644 index 0000000..a608447 --- /dev/null +++ b/dist/shared/middleware/errorMiddleware.js @@ -0,0 +1,22 @@ +import { ApiError, errorEnvelope } from "../errors"; +export function successResponse(req, data) { + return { + data, + request_id: req.requestId, + timestamp: new Date().toISOString() + }; +} +export function handleErrors(err, req, res, _next) { + if (err instanceof ApiError) { + res.status(err.statusCode).json(errorEnvelope({ + ...err + }, req.requestId)); + return; + } + res.status(500).json({ + code: "INTERNAL_ERROR", + message: err.message || "Unexpected server error", + request_id: req.requestId, + timestamp: new Date().toISOString() + }); +} diff --git a/dist/shared/middleware/idempotency.js b/dist/shared/middleware/idempotency.js new file mode 100644 index 0000000..cdb7b8f --- /dev/null +++ b/dist/shared/middleware/idempotency.js @@ -0,0 +1,37 @@ +import { ApiError } from "../errors"; +import { readIdempotency, writeIdempotency } from "../idempotency/idempotencyStore"; +import { env } from "../../config/env"; +export function idempotency(options) { + return function idempotencyMiddleware(req, _res, next) { + const idempotencyKey = req.header("idempotency-key"); + if (!idempotencyKey) { + if (options.required === false) { + return next(); + } + return next(new ApiError("DUPLICATE_REQUEST", "Missing Idempotency-Key", 400)); + } + const cached = readIdempotency(options.scope, idempotencyKey); + if (cached) { + const cachedPayload = cached.response ?? cached; + const cachedStatus = cached.statusCode || 200; + return _res.status(cachedStatus).json(cachedPayload); + } + req.body = { ...(req.body || {}), __idempotencyKey: idempotencyKey }; + const originalJson = _res.json.bind(_res); + const originalStatus = _res.status.bind(_res); + let statusCode = 200; + _res.status = function statusWithStore(code) { + statusCode = code; + return originalStatus(code); + }; + _res.json = function jsonWithStore(payload) { + writeIdempotency(options.scope, idempotencyKey, { + response: payload, + statusCode, + at: Date.now() + }, options.ttlMs || env.IDEMPOTENCY_TTL_MS); + return originalJson(payload); + }; + next(); + }; +} diff --git a/dist/shared/middleware/requestContext.js b/dist/shared/middleware/requestContext.js new file mode 100644 index 0000000..4d35468 --- /dev/null +++ b/dist/shared/middleware/requestContext.js @@ -0,0 +1,11 @@ +import { randomUUID } from "node:crypto"; +import { env } from "../../config/env"; +export function requestContext(req, _res, next) { + const requestId = req.header(env.TRACE_HEADER) || + req.header("x-trace-id") || + randomUUID(); + const traceId = req.header("x-trace-id") || requestId; + req.requestId = requestId; + req.traceId = traceId; + next(); +} diff --git a/dist/shared/orchestrators/notificationOrchestrator.js b/dist/shared/orchestrators/notificationOrchestrator.js new file mode 100644 index 0000000..ba226ec --- /dev/null +++ b/dist/shared/orchestrators/notificationOrchestrator.js @@ -0,0 +1,274 @@ +import { getActiveBindingByDevice, getActiveBindingByTerminal } from "../store/bindingStore"; +import { createNotification, getNotificationByTransactionAndEvent, getNotificationByTransactionId, listNotifications, toNotificationPayload, updateNotification } from "../store/notificationStore"; +import { getMerchantById } from "../store/merchantStore"; +import { getTransactionById, listTransactions, toTransactionPayload } from "../store/transactionStore"; +import { buildPaymentSuccessPayload, publishPaymentSuccess } from "../services/mqttPublisher"; +import { subscribeTransactionPaid } from "../events/transactionEvents"; +import { env } from "../../config/env"; +const RETRY_INTERVAL_MS = [ + env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000, + (env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000) * 2, + (env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000) * 4 +]; +const RETRY_POLL_INTERVAL_MS = 5000; +const MAX_RETRY = 3; +async function resolveNotificationDevice(payload) { + if (payload.device_id) { + const binding = await getActiveBindingByDevice(payload.device_id); + if (binding && binding.terminal_id === payload.terminal_id) { + return { + deviceId: payload.device_id, + source: "tx_device" + }; + } + } + const terminalBinding = await getActiveBindingByTerminal(payload.terminal_id); + if (!terminalBinding) { + return null; + } + return { + deviceId: terminalBinding.device_id, + source: "binding_device" + }; +} +function buildNotificationPayload(txPayload, deviceId) { + return { + message_type: "payment_success", + transaction_id: txPayload.transaction_id, + merchant_id: txPayload.merchant_id, + terminal_id: txPayload.terminal_id, + amount: txPayload.amount, + currency: txPayload.currency, + paid_at: txPayload.paid_at, + partner_reference: txPayload.partner_reference, + target_device_id: deviceId + }; +} +function resolveDeliveryStatus(bindingResolved) { + return bindingResolved ? "queued" : "failed"; +} +function resolveFailureReason(bindingResolved) { + if (!bindingResolved) { + return "NOTIFICATION_NO_ACTIVE_BINDING"; + } + return undefined; +} +function makeNextRetryDate(retryCount) { + const intervalMs = RETRY_INTERVAL_MS[Math.min(retryCount, RETRY_INTERVAL_MS.length - 1)] || 60000; + return new Date(Date.now() + intervalMs).toISOString(); +} +async function getNotificationMerchantName(merchantId) { + const merchant = await getMerchantById(merchantId); + return merchant?.brand_name || merchant?.legal_name || merchantId; +} +function mapMqttFailureState(retryCount, reason) { + const nextRetryCount = retryCount + 1; + if (nextRetryCount >= MAX_RETRY) { + return { + status: "failed", + retry_count: nextRetryCount, + reason: reason || "NOTIFICATION_RETRY_EXHAUSTED" + }; + } + return { + status: "retrying", + retry_count: nextRetryCount, + next_retry_at: makeNextRetryDate(retryCount), + reason + }; +} +async function markNotificationSent(notification, publishResult) { + await updateNotification(notification.id, { + delivery_status: "sent", + retry_count: notification.retry_count, + ack_status: "not_supported", + sent_at: publishResult.publishedAt + }); +} +async function markNotificationFailed(notification, publishResult) { + const next = mapMqttFailureState(notification.retry_count, publishResult.reason); + await updateNotification(notification.id, { + delivery_status: next.status, + retry_count: next.retry_count, + reason: next.reason, + ack_status: "not_supported", + next_retry_at: next.next_retry_at + }); +} +async function markNoDeviceFailure(notification) { + await updateNotification(notification.id, { + delivery_status: "failed", + retry_count: 0, + reason: "NOTIFICATION_NO_ACTIVE_BINDING", + ack_status: "not_needed" + }); +} +async function resolveNotificationFromTransaction(notification) { + const tx = await getTransactionById(notification.transaction_id); + if (!tx) { + return null; + } + return { + transaction_id: tx.id, + merchant_id: tx.merchant_id, + outlet_id: tx.outlet_id, + terminal_id: tx.terminal_id, + device_id: tx.device_id, + amount: tx.amount, + currency: tx.currency, + paid_at: tx.paid_at, + partner_reference: tx.partner_reference + }; +} +async function getMqttPayloadFromNotification(notification, paidAt) { + return buildPaymentSuccessPayload({ + transaction_id: String(notification.payload_json.transaction_id || ""), + merchant_id: String(notification.payload_json.merchant_id || ""), + merchant_name: await getNotificationMerchantName(String(notification.payload_json.merchant_id || "")), + device_id: String(notification.device_id || ""), + amount: Number(notification.payload_json.amount || 0), + currency: String(notification.payload_json.currency || "IDR"), + paid_at: paidAt, + partner_reference: String(notification.payload_json.partner_reference || ""), + event_id: notification.event_id + }); +} +async function publishNotificationNow(notification, eventPayload) { + if (!notification.device_id) { + const resolvedFromTransaction = await resolveNotificationFromTransaction(notification); + if (!resolvedFromTransaction) { + await markNoDeviceFailure(notification); + return; + } + const resolved = await resolveNotificationDevice(resolvedFromTransaction); + if (!resolved) { + await markNoDeviceFailure(notification); + return; + } + notification = await updateNotification(notification.id, { + device_id: resolved.deviceId, + delivery_status: "queued", + reason: undefined + }); + } + const effectivePayload = eventPayload ?? (await resolveNotificationFromTransaction(notification)); + if (!effectivePayload) { + await markNoDeviceFailure(notification); + return; + } + const mqttPayload = await buildPaymentSuccessPayload({ + transaction_id: effectivePayload.transaction_id, + merchant_id: effectivePayload.merchant_id, + merchant_name: await getNotificationMerchantName(effectivePayload.merchant_id), + device_id: notification.device_id || String(effectivePayload.device_id || ""), + amount: effectivePayload.amount, + currency: effectivePayload.currency, + paid_at: effectivePayload.paid_at, + partner_reference: effectivePayload.partner_reference, + event_id: notification.event_id + }); + const result = await publishPaymentSuccess(mqttPayload); + if (!result.ok) { + await markNotificationFailed(notification, result); + return; + } + await markNotificationSent(notification, result); +} +async function onTransactionPaid(event) { + const payload = event.payload_json; + const existing = await getNotificationByTransactionAndEvent(payload.transaction_id, event.id); + if (existing) { + return; + } + const resolved = await resolveNotificationDevice(payload); + const deliveryStatus = resolveDeliveryStatus(resolved); + const created = await createNotification({ + transaction_id: payload.transaction_id, + device_id: resolved?.deviceId || null, + event_id: event.id, + delivery_status: deliveryStatus, + reason: resolveFailureReason(resolved), + payload_json: buildNotificationPayload(payload, resolved?.deviceId || ""), + ack_status: resolved ? "not_supported" : "not_needed" + }); + await publishNotificationNow(created, payload); +} +async function bootstrapNotificationForPaidTransaction(transaction) { + const existing = await getNotificationByTransactionId(transaction.id); + if (existing) { + return; + } + const payload = { + transaction_id: transaction.id, + merchant_id: transaction.merchant_id, + outlet_id: transaction.outlet_id, + terminal_id: transaction.terminal_id, + device_id: transaction.device_id, + amount: transaction.amount, + currency: transaction.currency, + partner_reference: transaction.partner_reference, + paid_at: transaction.paid_at + }; + const eventId = `bootstrap_${transaction.id}`; + const resolved = await resolveNotificationDevice(payload); + const deliveryStatus = resolveDeliveryStatus(resolved); + const created = await createNotification({ + transaction_id: transaction.id, + device_id: resolved?.deviceId || null, + event_id: eventId, + delivery_status: deliveryStatus, + reason: resolveFailureReason(resolved), + payload_json: buildNotificationPayload(payload, resolved?.deviceId || ""), + ack_status: resolved ? "not_supported" : "not_needed" + }); + await publishNotificationNow(created, payload); +} +async function seedPaidTransactions() { + const paidTransactions = await listTransactions({ status: "paid" }); + for (const tx of paidTransactions) { + await bootstrapNotificationForPaidTransaction(toTransactionPayload(tx)); + } +} +async function processRetryCycle() { + const now = new Date().toISOString(); + const retrying = await listNotifications({ + delivery_status: "retrying" + }); + const due = retrying.filter((notification) => { + if (!notification.next_retry_at) { + return true; + } + return notification.next_retry_at <= now; + }); + for (const notification of due) { + await publishNotificationNow(notification, null); + } +} +export async function retryNotificationByTransactionId(transactionId) { + const tx = await getTransactionById(transactionId); + if (!tx) { + return null; + } + if (tx.status !== "paid") { + throw new Error("NOTIFICATION_PUBLISH_CONDITION"); + } + const notification = await getNotificationByTransactionId(transactionId); + if (!notification) { + return null; + } + if (notification.delivery_status === "acknowledged") { + return toNotificationPayload(notification); + } + await publishNotificationNow(notification, null); + const updated = await getNotificationByTransactionId(transactionId); + return updated ? toNotificationPayload(updated) : null; +} +export function startNotificationOrchestrator() { + void seedPaidTransactions(); + void subscribeTransactionPaid((event) => { + void onTransactionPaid(event); + }); + setInterval(() => { + void processRetryCycle(); + }, RETRY_POLL_INTERVAL_MS); +} diff --git a/dist/shared/services/mqttPublisher.js b/dist/shared/services/mqttPublisher.js new file mode 100644 index 0000000..77fedce --- /dev/null +++ b/dist/shared/services/mqttPublisher.js @@ -0,0 +1,52 @@ +import { env } from "../../config/env"; +const forcedFailAll = String(env.MQTT_PUBLISH_FORCE_FAIL_ALL).toLowerCase() === "true"; +const forcedFailDevices = new Set(String(env.MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS) + .split(",") + .map((item) => item.trim()) + .filter(Boolean)); +function shouldForceFail(deviceId) { + return forcedFailAll || forcedFailDevices.has(deviceId); +} +export function buildPaymentSuccessPayload(input) { + const displayAmount = `${input.amount.toLocaleString("id-ID")}`; + return { + message_type: "payment_success", + device_id: input.device_id, + event_id: input.event_id, + transaction_id: input.transaction_id, + merchant_id: input.merchant_id, + merchant_name: input.merchant_name, + amount: input.amount, + currency: input.currency, + paid_at: input.paid_at, + partner_reference: input.partner_reference, + audio_text: `Pembayaran diterima ${input.currency} ${displayAmount}`, + display_text: `Pembayaran diterima Rp${displayAmount}` + }; +} +export function makePaymentSuccessTopic(deviceId) { + return `devices/${deviceId}/downlink/payment/success`; +} +export async function publishPaymentSuccess(payload) { + const publishedAt = new Date().toISOString(); + const topic = makePaymentSuccessTopic(payload.device_id); + if (shouldForceFail(payload.device_id)) { + return { + ok: false, + topic, + qos: 1, + retained: false, + publishedAt, + reason: "MQTT_PUBLISH_SIMULATED_FAILURE", + payload + }; + } + return { + ok: true, + topic, + qos: 1, + retained: false, + publishedAt, + payload + }; +} diff --git a/dist/shared/store/bindingStore.js b/dist/shared/store/bindingStore.js new file mode 100644 index 0000000..afda826 --- /dev/null +++ b/dist/shared/store/bindingStore.js @@ -0,0 +1,97 @@ +import { randomUUID } from "node:crypto"; +import { getPool, withClient } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function mapBinding(row) { + return { + id: row.id, + device_id: row.device_id, + merchant_id: row.merchant_id, + outlet_id: row.outlet_id, + terminal_id: row.terminal_id, + active_flag: row.active_flag, + bound_at: row.bound_at, + unbound_at: row.unbound_at || undefined + }; +} +export async function getActiveBindingByDevice(deviceId) { + const { rows } = await getPool().query(`SELECT * FROM device_bindings + WHERE device_id = $1 AND active_flag = TRUE + ORDER BY bound_at DESC + LIMIT 1`, [deviceId]); + return rows[0] ? mapBinding(rows[0]) : null; +} +export async function getActiveBindingByTerminal(terminalId) { + const { rows } = await getPool().query(`SELECT * FROM device_bindings + WHERE terminal_id = $1 AND active_flag = TRUE + ORDER BY bound_at DESC + LIMIT 1`, [terminalId]); + return rows[0] ? mapBinding(rows[0]) : null; +} +export async function getBindingsByDeviceId(deviceId) { + const { rows } = await getPool().query(`SELECT * FROM device_bindings + WHERE device_id = $1 + ORDER BY bound_at DESC`, [deviceId]); + return rows.map(mapBinding); +} +export async function bindDevice(payload) { + const now = nowIso(); + const result = await withClient(async (client) => { + const existing = await client.query(`SELECT * FROM device_bindings + WHERE device_id = $1 AND active_flag = TRUE + ORDER BY bound_at DESC + LIMIT 1`, [payload.device_id]); + const same = existing.rows[0] + ? mapBinding(existing.rows[0]) + : null; + if (same && + same.merchant_id === payload.merchant_id && + same.outlet_id === payload.outlet_id && + same.terminal_id === payload.terminal_id) { + return same; + } + await client.query("BEGIN"); + try { + if (existing.rows[0]) { + await client.query(`UPDATE device_bindings + SET active_flag = FALSE, unbound_at = $2 + WHERE id = $1`, [same.id, now]); + } + const id = randomUUID(); + const inserted = await client.query(`INSERT INTO device_bindings ( + id, + device_id, + merchant_id, + outlet_id, + terminal_id, + active_flag, + bound_at + ) VALUES ($1,$2,$3,$4,$5,TRUE,$6) + RETURNING *`, [id, payload.device_id, payload.merchant_id, payload.outlet_id, payload.terminal_id, now]); + await client.query("COMMIT"); + return mapBinding(inserted.rows[0]); + } + catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); + return result; +} +export async function unbindDevice(deviceId) { + const now = nowIso(); + const { rows } = await getPool().query(`UPDATE device_bindings + SET active_flag = FALSE, + unbound_at = $2 + WHERE device_id = $1 AND active_flag = TRUE + RETURNING *`, [deviceId, now]); + return rows[0] ? mapBinding(rows[0]) : null; +} +export async function getBindingById(id) { + const { rows } = await getPool().query("SELECT * FROM device_bindings WHERE id = $1", [id]); + return rows[0] ? mapBinding(rows[0]) : null; +} +export function toBindingPayload(binding) { + return { ...binding }; +} diff --git a/dist/shared/store/deviceCommandStore.js b/dist/shared/store/deviceCommandStore.js new file mode 100644 index 0000000..93c516d --- /dev/null +++ b/dist/shared/store/deviceCommandStore.js @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function normalizeStatus(status) { + if (status === "accepted" || status === "delivered" || status === "failed" || status === "timeout") { + return status; + } + return "accepted"; +} +function mapCommand(row) { + return { + id: row.id, + device_id: row.device_id, + command: row.command, + payload: row.payload_json || {}, + status: row.status, + requested_at: row.requested_at, + acknowledged_at: row.acknowledged_at || null, + result_payload: row.result_payload_json || null, + reason: row.reason || null + }; +} +export async function createDeviceCommand(payload) { + const entity = { + id: `cmd_${randomUUID()}`, + device_id: payload.device_id, + command: payload.command, + payload: payload.payload || {}, + status: normalizeStatus(payload.status || "accepted"), + requested_at: nowIso(), + acknowledged_at: null, + result_payload: null, + reason: null + }; + const { rows } = await getPool().query(`INSERT INTO device_commands ( + id, + device_id, + command, + payload_json, + status, + requested_at, + acknowledged_at, + result_payload_json, + reason + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, [ + entity.id, + entity.device_id, + entity.command, + entity.payload, + entity.status, + entity.requested_at, + entity.acknowledged_at, + entity.result_payload, + entity.reason + ]); + return mapCommand(rows[0]); +} +export async function listDeviceCommands(deviceId) { + const { rows } = await getPool().query("SELECT * FROM device_commands WHERE device_id = $1 ORDER BY requested_at DESC", [deviceId]); + return rows.map(mapCommand); +} +export async function getDeviceCommandById(deviceId, commandId) { + const { rows } = await getPool().query("SELECT * FROM device_commands WHERE device_id = $1 AND id = $2", [deviceId, commandId]); + return rows[0] ? mapCommand(rows[0]) : null; +} +export function toDeviceCommandPayload(command) { + return mapCommand(command); +} +export function toDeviceCommandPayloadBrief(command) { + return { + command_id: command.id, + device_id: command.device_id, + command: command.command, + status: command.status, + requested_at: command.requested_at, + acknowledged_at: command.acknowledged_at + }; +} +export async function acknowledgeDeviceCommand(payload) { + const now = nowIso(); + const { rows } = await getPool().query(`UPDATE device_commands + SET status = $3, + acknowledged_at = $4, + result_payload_json = $5, + reason = $6 + WHERE device_id = $1 AND id = $2 + RETURNING *`, [payload.device_id, payload.command_id, payload.status, now, payload.result_payload || null, payload.reason || null]); + return rows[0] ? mapCommand(rows[0]) : null; +} diff --git a/dist/shared/store/deviceStore.js b/dist/shared/store/deviceStore.js new file mode 100644 index 0000000..66311ac --- /dev/null +++ b/dist/shared/store/deviceStore.js @@ -0,0 +1,106 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function makeCode(id) { + return `d_${id.slice(0, 6)}`; +} +function mapDevice(row) { + return { + id: row.id, + device_code: row.device_code, + serial_number: row.serial_number || undefined, + vendor: row.vendor || undefined, + model: row.model || undefined, + communication_mode: row.communication_mode, + capability_profile_json: row.capability_profile_json || {}, + auth_method: row.auth_method || undefined, + status: row.status, + last_seen_at: row.last_seen_at || undefined, + firmware_version: row.firmware_version || undefined, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +export async function createDevice(payload) { + const id = randomUUID(); + const now = nowIso(); + const { rows } = await getPool().query(`INSERT INTO devices ( + id, + device_code, + serial_number, + vendor, + model, + communication_mode, + capability_profile_json, + auth_method, + status, + last_seen_at, + firmware_version, + created_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING *`, [ + id, + payload.device_code || makeCode(id), + payload.serial_number, + payload.vendor, + payload.model, + payload.communication_mode || "static", + payload.capability_profile_json || {}, + payload.auth_method || "token", + payload.status || "active", + payload.last_seen_at || null, + payload.firmware_version, + now, + now + ]); + return mapDevice(rows[0]); +} +export async function listDevices() { + const { rows } = await getPool().query("SELECT * FROM devices ORDER BY created_at DESC"); + return rows.map(mapDevice); +} +export async function getDeviceById(id) { + const { rows } = await getPool().query("SELECT * FROM devices WHERE id = $1", [id]); + return rows[0] ? mapDevice(rows[0]) : null; +} +export async function patchDevice(id, patch) { + const existing = await getDeviceById(id); + if (!existing) { + throw new Error("DEVICE_NOT_FOUND"); + } + const merged = { ...existing, ...patch, updated_at: nowIso() }; + const { rows } = await getPool().query(`UPDATE devices + SET device_code = $2, + serial_number = $3, + vendor = $4, + model = $5, + communication_mode = $6, + capability_profile_json = $7, + auth_method = $8, + status = $9, + firmware_version = $10, + last_seen_at = $11, + updated_at = $12 + WHERE id = $1 + RETURNING *`, [ + id, + merged.device_code, + merged.serial_number, + merged.vendor, + merged.model, + merged.communication_mode || "static", + merged.capability_profile_json || {}, + merged.auth_method, + merged.status, + merged.firmware_version, + merged.last_seen_at || null, + merged.updated_at + ]); + return mapDevice(rows[0]); +} +export function toDevicePayload(device) { + return { ...device }; +} diff --git a/dist/shared/store/heartbeatStore.js b/dist/shared/store/heartbeatStore.js new file mode 100644 index 0000000..17a116e --- /dev/null +++ b/dist/shared/store/heartbeatStore.js @@ -0,0 +1,102 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function mapHeartbeat(row) { + return { + id: row.id, + device_id: row.device_id, + timestamp: row.timestamp, + received_at: row.received_at, + firmware_version: row.firmware_version || undefined, + network_strength: row.network_strength, + battery_level: row.battery_level, + state: row.state || undefined + }; +} +export async function createDeviceHeartbeat(payload) { + const now = nowIso(); + const id = randomUUID(); + const { rows } = await getPool().query(`INSERT INTO device_heartbeats ( + id, + device_id, + timestamp, + received_at, + firmware_version, + network_strength, + battery_level, + state, + payload_json + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, [ + id, + payload.device_id, + payload.timestamp, + now, + payload.firmware_version, + payload.network_strength, + payload.battery_level, + payload.state, + payload.payload_json || {} + ]); + return mapHeartbeat(rows[0]); +} +export async function getLatestHeartbeatByDeviceId(deviceId) { + const { rows } = await getPool().query(`SELECT * FROM device_heartbeats + WHERE device_id = $1 + ORDER BY received_at DESC + LIMIT 1`, [deviceId]); + return rows[0] ? mapHeartbeat(rows[0]) : null; +} +export async function getHeartbeatCountForDeviceLastHours(deviceId, hours = 24) { + const { rows } = await getPool().query(`SELECT COUNT(*)::INT AS cnt + FROM device_heartbeats + WHERE device_id = $1 + AND (NOW() AT TIME ZONE 'utc' - timestamp) <= ($2 || ' hours')::interval`, [deviceId, hours]); + return Number(rows[0]?.cnt || 0); +} +export async function listHeartbeats(filter) { + const clauses = []; + const params = []; + let i = 1; + if (filter?.device_id) { + clauses.push(`device_id = $${i++}`); + params.push(filter.device_id); + } + if (filter?.state) { + clauses.push(`state = $${i++}`); + params.push(filter.state); + } + if (filter?.from) { + clauses.push(`timestamp >= $${i++}`); + params.push(filter.from); + } + if (filter?.to) { + clauses.push(`timestamp <= $${i++}`); + params.push(filter.to); + } + const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const limitSql = filter?.limit ? `LIMIT ${Number(filter.limit)}` : ""; + const { rows } = await getPool().query(`SELECT * FROM device_heartbeats ${whereSql} ORDER BY received_at DESC ${limitSql}`, params); + return rows.map(mapHeartbeat); +} +export function deriveDeviceStatus(input) { + const now = Date.now(); + const lastSeen = Date.parse(input?.last_seen_at || ""); + if (!Number.isFinite(lastSeen)) { + return "offline"; + } + const ageSeconds = (now - lastSeen) / 1000; + if (ageSeconds > 900) { + return "offline"; + } + if (ageSeconds > 90) { + return "stale"; + } + if ((typeof input?.network_strength === "number" && input.network_strength < 40) || + (typeof input?.battery_level === "number" && input.battery_level < 20)) { + return "degraded"; + } + return "online"; +} diff --git a/dist/shared/store/locationStore.js b/dist/shared/store/locationStore.js new file mode 100644 index 0000000..3c41ba3 --- /dev/null +++ b/dist/shared/store/locationStore.js @@ -0,0 +1,146 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function makeCode(prefix, id) { + return `${prefix}_${id.slice(0, 6)}`; +} +function mapOutlet(row) { + return { + id: row.id, + merchant_id: row.merchant_id, + outlet_code: row.outlet_code, + name: row.name, + address: row.address || undefined, + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +function mapTerminal(row) { + return { + id: row.id, + outlet_id: row.outlet_id, + terminal_code: row.terminal_code, + qr_mode: row.qr_mode, + partner_reference: row.partner_reference || undefined, + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +export async function createOutlet(payload) { + const id = randomUUID(); + const now = nowIso(); + const { rows } = await getPool().query(`INSERT INTO outlets (id, merchant_id, outlet_code, name, address, status, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING *`, [ + id, + payload.merchant_id, + payload.outlet_code || makeCode("out", id), + payload.name, + payload.address, + payload.status || "active", + now, + now + ]); + return mapOutlet(rows[0]); +} +export async function createTerminal(payload) { + const id = randomUUID(); + const now = nowIso(); + const { rows } = await getPool().query(`INSERT INTO terminals (id, outlet_id, terminal_code, qr_mode, partner_reference, status, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING *`, [ + id, + payload.outlet_id, + payload.terminal_code || makeCode("term", id), + payload.qr_mode || "static", + payload.partner_reference || null, + payload.status || "active", + now, + now + ]); + return mapTerminal(rows[0]); +} +export async function listOutlets(filter) { + if (filter?.merchant_id) { + const { rows } = await getPool().query("SELECT * FROM outlets WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id]); + return rows.map(mapOutlet); + } + const { rows } = await getPool().query("SELECT * FROM outlets ORDER BY created_at DESC"); + return rows.map(mapOutlet); +} +export async function listTerminals(filter) { + if (filter?.outlet_id) { + const { rows } = await getPool().query("SELECT * FROM terminals WHERE outlet_id = $1 ORDER BY created_at DESC", [filter.outlet_id]); + return rows.map(mapTerminal); + } + const { rows } = await getPool().query("SELECT * FROM terminals ORDER BY created_at DESC"); + return rows.map(mapTerminal); +} +export async function getOutletById(id) { + const { rows } = await getPool().query("SELECT * FROM outlets WHERE id = $1", [id]); + return rows[0] ? mapOutlet(rows[0]) : null; +} +export async function getTerminalById(id) { + const { rows } = await getPool().query("SELECT * FROM terminals WHERE id = $1", [id]); + return rows[0] ? mapTerminal(rows[0]) : null; +} +export async function patchOutlet(id, patch) { + const existing = await getOutletById(id); + if (!existing) { + throw new Error("OUTLET_NOT_FOUND"); + } + const merged = { ...existing, ...patch, updated_at: nowIso() }; + const { rows } = await getPool().query(`UPDATE outlets + SET merchant_id = $2, + outlet_code = $3, + name = $4, + address = $5, + status = $6, + updated_at = $7 + WHERE id = $1 + RETURNING *`, [ + id, + merged.merchant_id, + merged.outlet_code, + merged.name, + merged.address || null, + merged.status, + merged.updated_at + ]); + return mapOutlet(rows[0]); +} +export async function patchTerminal(id, patch) { + const existing = await getTerminalById(id); + if (!existing) { + throw new Error("TERMINAL_NOT_FOUND"); + } + const merged = { ...existing, ...patch, updated_at: nowIso() }; + const { rows } = await getPool().query(`UPDATE terminals + SET outlet_id = $2, + terminal_code = $3, + qr_mode = $4, + partner_reference = $5, + status = $6, + updated_at = $7 + WHERE id = $1 + RETURNING *`, [ + id, + merged.outlet_id, + merged.terminal_code, + merged.qr_mode, + merged.partner_reference || null, + merged.status, + merged.updated_at + ]); + return mapTerminal(rows[0]); +} +export function toOutletPayload(outlet) { + return { ...outlet }; +} +export function toTerminalPayload(terminal) { + return { ...terminal }; +} diff --git a/dist/shared/store/merchantStore.js b/dist/shared/store/merchantStore.js new file mode 100644 index 0000000..eb6423d --- /dev/null +++ b/dist/shared/store/merchantStore.js @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function makeCode(id) { + return `m_${id.slice(0, 6)}`; +} +function toPublic(entity) { + return entity; +} +function mapRowToMerchant(row) { + return { + id: row.id, + merchant_code: row.merchant_code, + legal_name: row.legal_name, + brand_name: row.brand_name || undefined, + settlement_account_reference: row.settlement_account_reference || undefined, + settlement_account_type: row.settlement_account_type || undefined, + payout_mode: row.payout_mode, + fee_profile_id: row.fee_profile_id || undefined, + status: row.status, + onboarding_status: row.onboarding_status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} +export async function createMerchant(payload) { + const id = randomUUID(); + const now = nowIso(); + const payoutMode = payload.payout_mode || "merchant_direct"; + const { rows } = await getPool().query(`INSERT INTO merchants ( + id, + merchant_code, + legal_name, + brand_name, + settlement_account_reference, + settlement_account_type, + payout_mode, + fee_profile_id, + status, + onboarding_status, + created_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, [ + id, + makeCode(id), + payload.legal_name, + payload.brand_name, + payload.settlement_account_reference, + payload.settlement_account_type, + payoutMode, + payload.fee_profile_id, + payload.status || "active", + payload.onboarding_status || "pending", + now, + now + ]); + return toPublic(mapRowToMerchant(rows[0])); +} +export async function getMerchantById(id) { + const { rows } = await getPool().query("SELECT * FROM merchants WHERE id = $1", [id]); + if (rows.length === 0) { + return null; + } + return mapRowToMerchant(rows[0]); +} +export async function listMerchants() { + const { rows } = await getPool().query("SELECT * FROM merchants ORDER BY created_at DESC"); + return rows.map(mapRowToMerchant); +} +export async function patchMerchant(id, patch) { + const existing = await getMerchantById(id); + if (!existing) { + throw new Error("MERCHANT_NOT_FOUND"); + } + const merged = { ...existing, ...patch, updated_at: nowIso() }; + const { rows } = await getPool().query(`UPDATE merchants + SET legal_name = $2, + brand_name = $3, + settlement_account_reference = $4, + settlement_account_type = $5, + payout_mode = $6, + fee_profile_id = $7, + status = $8, + onboarding_status = $9, + updated_at = $10 + WHERE id = $1 + RETURNING *`, [ + id, + merged.legal_name, + merged.brand_name, + merged.settlement_account_reference, + merged.settlement_account_type, + merged.payout_mode, + merged.fee_profile_id, + merged.status, + merged.onboarding_status, + merged.updated_at + ]); + return mapRowToMerchant(rows[0]); +} +export function toMerchantPayload(m) { + return { + id: m.id, + merchant_code: m.merchant_code, + legal_name: m.legal_name, + brand_name: m.brand_name, + settlement_account_reference: m.settlement_account_reference, + settlement_account_type: m.settlement_account_type, + payout_mode: m.payout_mode, + fee_profile_id: m.fee_profile_id, + status: m.status, + onboarding_status: m.onboarding_status, + created_at: m.created_at, + updated_at: m.updated_at + }; +} diff --git a/dist/shared/store/notificationStore.js b/dist/shared/store/notificationStore.js new file mode 100644 index 0000000..836962f --- /dev/null +++ b/dist/shared/store/notificationStore.js @@ -0,0 +1,151 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function cloneNotification(notification) { + return { + ...notification, + payload_json: { ...notification.payload_json } + }; +} +function mapNotification(row) { + return { + id: row.id, + transaction_id: row.transaction_id, + device_id: row.device_id || null, + delivery_channel: "mqtt", + payload_type: "payment_success", + delivery_status: row.delivery_status, + retry_count: row.retry_count, + ack_status: row.ack_status, + event_id: row.event_id, + reason: row.reason || undefined, + payload_json: row.payload_json || {}, + created_at: row.created_at, + updated_at: row.updated_at, + sent_at: row.sent_at || undefined, + ack_at: row.ack_at || undefined, + next_retry_at: row.next_retry_at || undefined + }; +} +export async function createNotification(payload) { + const now = nowIso(); + const insert = await getPool().query(`INSERT INTO notifications ( + id, + transaction_id, + device_id, + delivery_status, + retry_count, + ack_status, + event_id, + reason, + payload_json, + created_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ON CONFLICT (transaction_id, event_id) DO UPDATE + SET updated_at = EXCLUDED.updated_at + RETURNING *`, [ + randomUUID(), + payload.transaction_id, + payload.device_id, + payload.delivery_status, + 0, + payload.ack_status || "not_needed", + payload.event_id, + payload.reason || null, + payload.payload_json || {}, + now, + now + ]); + if (insert.rowCount && insert.rowCount > 0) { + return mapNotification(insert.rows[0]); + } + const { rows } = await getPool().query("SELECT * FROM notifications WHERE transaction_id = $1 AND event_id = $2", [payload.transaction_id, payload.event_id]); + return mapNotification(rows[0]); +} +export async function getNotificationById(notificationId) { + const { rows } = await getPool().query("SELECT * FROM notifications WHERE id = $1", [notificationId]); + return rows[0] ? cloneNotification(mapNotification(rows[0])) : null; +} +export async function updateNotification(notificationId, patch) { + const existing = await getNotificationById(notificationId); + if (!existing) { + throw new Error("NOTIFICATION_NOT_FOUND"); + } + const next = { + ...existing, + ...patch, + id: existing.id, + transaction_id: existing.transaction_id, + device_id: existing.device_id, + delivery_channel: existing.delivery_channel, + payload_type: existing.payload_type, + event_id: existing.event_id, + payload_json: existing.payload_json, + created_at: existing.created_at, + updated_at: nowIso() + }; + const { rows } = await getPool().query(`UPDATE notifications + SET delivery_status = $2, + retry_count = $3, + ack_status = $4, + device_id = COALESCE($5, device_id), + reason = $6, + sent_at = $7, + ack_at = $8, + next_retry_at = $9, + updated_at = $10 + WHERE id = $1 + RETURNING *`, [ + notificationId, + next.delivery_status, + next.retry_count, + next.ack_status, + next.device_id ?? null, + next.reason || null, + next.sent_at || null, + next.ack_at || null, + next.next_retry_at || null, + next.updated_at + ]); + return cloneNotification(mapNotification(rows[0])); +} +export async function getNotificationByTransactionId(transactionId) { + const { rows } = await getPool().query(`SELECT * FROM notifications + WHERE transaction_id = $1 + ORDER BY created_at DESC + LIMIT 1`, [transactionId]); + return rows[0] ? mapNotification(rows[0]) : null; +} +export async function getNotificationByTransactionAndEvent(transactionId, eventId) { + const { rows } = await getPool().query("SELECT * FROM notifications WHERE transaction_id = $1 AND event_id = $2", [transactionId, eventId]); + return rows[0] ? mapNotification(rows[0]) : null; +} +export async function listNotificationsByDevice(deviceId) { + const { rows } = await getPool().query("SELECT * FROM notifications WHERE device_id = $1 ORDER BY created_at DESC", [deviceId]); + return rows.map(mapNotification); +} +export async function listNotifications(filter) { + const filters = []; + const params = []; + if (filter?.transaction_id) { + params.push(filter.transaction_id); + filters.push(`transaction_id = $${params.length}`); + } + if (filter?.device_id) { + params.push(filter.device_id); + filters.push(`device_id = $${params.length}`); + } + if (filter?.delivery_status) { + params.push(filter.delivery_status); + filters.push(`delivery_status = $${params.length}`); + } + const where = filters.length ? `WHERE ${filters.join(" AND ")}` : ""; + const { rows } = await getPool().query(`SELECT * FROM notifications ${where} ORDER BY created_at DESC`, params); + return rows.map(mapNotification); +} +export function toNotificationPayload(notification) { + return cloneNotification(notification); +} diff --git a/dist/shared/store/transactionStore.js b/dist/shared/store/transactionStore.js new file mode 100644 index 0000000..45735ec --- /dev/null +++ b/dist/shared/store/transactionStore.js @@ -0,0 +1,199 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; +function nowIso() { + return new Date().toISOString(); +} +function makeCode(id) { + return `tx_${id.slice(0, 8)}`; +} +function mapTransaction(row) { + return { + id: row.id, + transaction_code: row.transaction_code, + merchant_id: row.merchant_id, + outlet_id: row.outlet_id, + terminal_id: row.terminal_id, + device_id: row.device_id || undefined, + qr_mode: row.qr_mode, + initiation_mode: row.initiation_mode, + partner_reference: row.partner_reference, + amount: Number(row.amount), + currency: row.currency, + status: row.status, + created_at: row.created_at, + paid_at: row.paid_at || undefined, + expired_at: row.expired_at || undefined, + updated_at: row.updated_at + }; +} +function mapEvent(row) { + return { + id: row.id, + transaction_id: row.transaction_id, + event_type: row.event_type, + source: row.source, + payload_json: row.payload_json || {}, + created_at: row.created_at + }; +} +const TRANSACTION_STATE_TRANSITIONS = { + initiated: ["initiated", "awaiting_payment", "paid", "failed", "expired", "reversed"], + awaiting_payment: ["awaiting_payment", "paid", "failed", "expired", "reversed"], + paid: ["paid", "reversed"], + failed: ["failed", "reversed"], + expired: ["expired", "reversed"], + reversed: ["reversed"] +}; +function isValidTransactionTransition(from, to) { + return TRANSACTION_STATE_TRANSITIONS[from]?.includes(to) ?? false; +} +export async function createTransaction(payload) { + const id = randomUUID(); + const now = nowIso(); + const entity = { + id, + transaction_code: makeCode(id), + merchant_id: payload.merchant_id, + outlet_id: payload.outlet_id, + terminal_id: payload.terminal_id, + device_id: payload.device_id, + qr_mode: payload.qr_mode || "static", + initiation_mode: payload.initiation_mode || "static", + partner_reference: payload.partner_reference, + amount: payload.amount, + currency: payload.currency || "IDR", + status: payload.status || "initiated", + created_at: now, + paid_at: payload.paid_at, + expired_at: payload.expired_at, + updated_at: now + }; + const txResult = await getPool().query(`INSERT INTO transactions ( + id, + transaction_code, + merchant_id, + outlet_id, + terminal_id, + device_id, + qr_mode, + initiation_mode, + partner_reference, + amount, + currency, + status, + created_at, + paid_at, + expired_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + RETURNING *`, [ + entity.id, + entity.transaction_code, + entity.merchant_id, + entity.outlet_id, + entity.terminal_id, + entity.device_id || null, + entity.qr_mode, + entity.initiation_mode, + entity.partner_reference, + entity.amount, + entity.currency, + entity.status, + entity.created_at, + entity.paid_at || null, + entity.expired_at || null, + entity.updated_at + ]); + await addTransactionEvent({ + transaction_id: txResult.rows[0].id, + event_type: "INITIATED", + source: "system", + payload_json: { status: txResult.rows[0].status, partner_reference: payload.partner_reference } + }); + return mapTransaction(txResult.rows[0]); +} +export async function addTransactionEvent(payload) { + const id = randomUUID(); + const { rows } = await getPool().query(`INSERT INTO transaction_events (id, transaction_id, event_type, source, payload_json, created_at) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING *`, [id, payload.transaction_id, payload.event_type, payload.source, payload.payload_json || {}, nowIso()]); + return mapEvent(rows[0]); +} +export async function updateTransactionStatus(id, to, options) { + const entity = await getTransactionById(id); + if (!entity) { + throw new Error("TRANSACTION_NOT_FOUND"); + } + if (!isValidTransactionTransition(entity.status, to)) { + throw new Error(`INVALID_TRANSACTION_STATE_TRANSITION:${entity.status}->${to}`); + } + if (entity.status === to) { + return entity; + } + const now = nowIso(); + const next = { + ...entity, + status: to, + paid_at: options.paid_at || entity.paid_at, + expired_at: options.expired_at || entity.expired_at, + updated_at: now + }; + if (to === "paid" && !next.paid_at) { + next.paid_at = now; + } + if (to === "expired" && !next.expired_at) { + next.expired_at = now; + } + const { rows } = await getPool().query(`UPDATE transactions + SET status = $2, + paid_at = $3, + expired_at = $4, + updated_at = $5 + WHERE id = $1 + RETURNING *`, [id, next.status, next.paid_at || null, next.expired_at || null, next.updated_at]); + await addTransactionEvent({ + transaction_id: id, + event_type: "STATE_CHANGED", + source: options.source, + payload_json: { + from: entity.status, + to, + ...options.eventContext + } + }); + return mapTransaction(rows[0]); +} +export async function getTransactionById(id) { + const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]); + return rows[0] ? mapTransaction(rows[0]) : null; +} +export async function findTransactionByPartnerReference(partnerReference) { + const { rows } = await getPool().query("SELECT * FROM transactions WHERE partner_reference = $1", [partnerReference]); + return rows[0] ? mapTransaction(rows[0]) : null; +} +export async function listTransactions(filter) { + if (filter?.status && filter?.merchant_id) { + const { rows } = await getPool().query("SELECT * FROM transactions WHERE status = $1 AND merchant_id = $2 ORDER BY created_at DESC", [filter.status, filter.merchant_id]); + return rows.map(mapTransaction); + } + if (filter?.status) { + const { rows } = await getPool().query("SELECT * FROM transactions WHERE status = $1 ORDER BY created_at DESC", [filter.status]); + return rows.map(mapTransaction); + } + if (filter?.merchant_id) { + const { rows } = await getPool().query("SELECT * FROM transactions WHERE merchant_id = $1 ORDER BY created_at DESC", [filter.merchant_id]); + return rows.map(mapTransaction); + } + const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC"); + return rows.map(mapTransaction); +} +export async function getTransactionEvents(transactionId) { + const { rows } = await getPool().query("SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", [transactionId]); + return rows.map(mapEvent); +} +export function toTransactionPayload(transaction) { + return { ...transaction }; +} +export function toTransactionEventPayload(event) { + return { ...event, payload_json: { ...event.payload_json } }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b24f957 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3415 @@ +{ + "name": "qris-soundbox-platform", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "qris-soundbox-platform", + "version": "0.1.0", + "dependencies": { + "express": "^4.19.2", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "pg": "^8.21.0", + "uuid": "^10.0.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", + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "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" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "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==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", + "dev": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "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==", + "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" + }, + "engines": { + "node": ">= 0.8.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==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cleanup": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/node-cleanup/-/node-cleanup-2.1.2.tgz", + "integrity": "sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "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", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "license": [ + "MIT", + "Apache2" + ], + "dependencies": { + "through": "~2.3" + } + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-stream": "=3.3.4" + }, + "bin": { + "ps-tree": "bin/ps-tree.js" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "~0.1.1" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsc-watch": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/tsc-watch/-/tsc-watch-6.3.1.tgz", + "integrity": "sha512-x1hNJ/m1Cln2OvNJJLPsnWI1pObm+Jq9jF6kyz0flEy/ym7Y9TvU9edg6lXLuGVkhDClwBWi8aWAFE0qgzEkzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "node-cleanup": "^2.1.2", + "ps-tree": "^1.2.0", + "string-argv": "^0.3.1" + }, + "bin": { + "tsc-watch": "dist/lib/tsc-watch.js" + }, + "engines": { + "node": ">=12.12.0" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/tsup/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "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": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..94b4a73 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "qris-soundbox-platform", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "start:dist": "node --experimental-specifier-resolution=node dist/index.js", + "smoke:cleanup": "node scripts/smoke-cleanup.mjs", + "typecheck": "tsc --noEmit", + "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'" + }, + "dependencies": { + "express": "^4.19.2", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "pg": "^8.21.0", + "uuid": "^10.0.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", + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } +} diff --git a/scripts/smoke-cleanup.mjs b/scripts/smoke-cleanup.mjs new file mode 100644 index 0000000..25215fa --- /dev/null +++ b/scripts/smoke-cleanup.mjs @@ -0,0 +1,48 @@ +import { env as processEnv } from "node:process"; +import { Pool } from "pg"; + +function required(value, fallback) { + return value || fallback; +} + +const pool = processEnv.DATABASE_URL + ? new Pool({ connectionString: processEnv.DATABASE_URL }) + : new Pool({ + host: required(processEnv.PGHOST, "127.0.0.1"), + port: Number(required(processEnv.PGPORT, "5432")), + user: required(processEnv.PGUSER, "postgres"), + password: processEnv.PGPASSWORD || "", + database: required(processEnv.PGDATABASE, "qris_soundbox_platform") + }); + +async function main() { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const txResult = await client.query("DELETE FROM transactions WHERE partner_reference LIKE 'PR-%' RETURNING id"); + const devResult = await client.query("DELETE FROM devices WHERE device_code LIKE 'DEV-%' RETURNING id"); + const merchantResult = await client.query("DELETE FROM merchants WHERE legal_name LIKE 'Smoke Merchant %' RETURNING id"); + + await client.query("COMMIT"); + + console.log(JSON.stringify({ + transactions_deleted: txResult.rowCount, + devices_deleted: devResult.rowCount, + merchants_deleted: merchantResult.rowCount, + note: "outlets/terminals are removed via merchant cascade" + })); + } catch (error) { + await client.query("ROLLBACK"); + console.error("cleanup failed", error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } finally { + client.release(); + await pool.end(); + } +} + +main().catch((error) => { + console.error("cleanup failed", error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..cb02a20 --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,167 @@ +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 DEVICE_TOKEN = process.env.DEVICE_TOKEN || "device-dev-token"; +const SECRET = process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret"; + +function short(data) { + const json = typeof data === 'string' ? data : JSON.stringify(data || {}); + return json.length > 180 ? `${json.slice(0, 180)}...` : json; +} + +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}`); + } + + console.log(`${options._label || `${options.method || 'GET'} ${path}`} => ${response.status} ${short(body)}`); + return body; +} + +async function reqAdmin(path, opts = {}) { + return req(path, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${ADMIN_TOKEN}` } }); +} + +async function reqDevice(path, opts = {}) { + return req(path, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${DEVICE_TOKEN}` } }); +} + +(async () => { + await req('/health', { _label: 'GET /health' }); + await req('/admin/login', { method: 'POST', body: { username: 'admin', password: 'admin' }, _label: 'POST /admin/login' }); + + await reqAdmin('/admin/seed/status', { _label: 'GET /admin/seed/status' }); + const ts = Date.now(); + + const merchant = await reqAdmin('/admin/merchants', { + method: 'POST', + body: { + legal_name: `Smoke Merchant ${ts}`, + brand_name: `SMK-${ts}`, + settlement_account_reference: `bank:${ts}`, + settlement_account_type: 'merchant_bank_account', + payout_mode: 'merchant_direct' + }, + _label: 'POST /admin/merchants' + }); + + const merchantId = merchant?.data?.id; + const outlet = await reqAdmin(`/admin/merchants/${merchantId}/outlets`, { + method: 'POST', + body: { name: `Outlet ${ts}` }, + _label: 'POST /admin/merchants/:id/outlets' + }); + + const outletId = outlet?.data?.id; + const terminal = await reqAdmin(`/admin/outlets/${outletId}/terminals`, { + method: 'POST', + body: { terminal_code: `TERM-${ts}`, qr_mode: 'static' }, + _label: 'POST /admin/outlets/:id/terminals' + }); + + const terminalId = terminal?.data?.id; + const device = await reqAdmin('/admin/devices', { + method: 'POST', + body: { + device_code: `DEV-${ts}`, + vendor: 'acme', + model: 'v1', + communication_mode: 'mqtt', + status: 'active' + }, + _label: 'POST /admin/devices' + }); + + const deviceId = device?.data?.id; + await reqAdmin(`/admin/devices/${deviceId}/bind`, { + method: 'POST', + body: { + merchant_id: merchantId, + outlet_id: outletId, + terminal_id: terminalId + }, + _label: 'POST /admin/devices/:id/bind' + }); + + await reqDevice('/device/heartbeat', { + method: 'POST', + body: { + device_id: deviceId, + timestamp: new Date().toISOString(), + firmware_version: '1.2.3', + network_strength: 88, + battery_level: 77, + state: 'idle' + }, + _label: 'POST /device/heartbeat' + }); + + const tx = await reqAdmin('/admin/transactions', { + method: 'POST', + body: { + partner_reference: `PR-${ts}`, + merchant_id: merchantId, + outlet_id: outletId, + terminal_id: terminalId, + device_id: deviceId, + amount: 19900, + currency: 'IDR', + qr_mode: 'static', + initiation_mode: 'static', + status: 'initiated' + }, + _label: 'POST /admin/transactions' + }); + + const txId = tx?.data?.id; + const callback = { + partner_reference: `PR-${ts}`, + partner_txn_id: `PTX-${ts}`, + amount: 19900, + 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' + }); + + 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/transactions/${txId}/heartbeats`, { _label: 'GET /admin/transactions/:id/heartbeats' }); + await reqAdmin(`/admin/devices/${deviceId}/heartbeats`, { _label: 'GET /admin/devices/:id/heartbeats' }); + await reqAdmin('/admin/notifications/failed', { _label: 'GET /admin/notifications/failed' }); + await reqAdmin(`/admin/transactions/${txId}/retry-notification`, { + method: 'POST', + body: {}, + _label: 'POST /admin/transactions/:id/retry-notification' + }); + await reqAdmin('/admin/dashboard/summary', { _label: 'GET /admin/dashboard/summary' }); + + console.log(`Smoke point 4 flow done. tx=${txId} device=${deviceId}`); +})(); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..4c8000f --- /dev/null +++ b/src/app.ts @@ -0,0 +1,84 @@ +import express from "express"; +import helmet from "helmet"; +import morgan from "morgan"; +import { env } from "./config/env"; +import { requestContext } from "./shared/middleware/requestContext"; +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 { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator"; +import path from "node:path"; +import fs from "node:fs"; + +const app = express(); +startNotificationOrchestrator(); + +app.use(helmet()); +app.use(express.json()); +app.use(morgan("dev")); +app.use(requestContext); + +app.get("/", (_req, res) => { + res.json(successResponse(_req, { status: "ok" })); +}); + +function resolveUiPageFile(slug: string) { + const workspaceRoot = process.cwd(); + const candidates = [ + path.resolve(workspaceRoot, "ui", slug, "index.html"), + path.resolve(workspaceRoot, "ui", slug.replace(/_/g, "-"), "index.html"), + path.resolve(workspaceRoot, "ui", slug.replace(/-/g, "_"), "index.html") + ]; + + return candidates.find((candidate) => fs.existsSync(candidate)) || null; +} + +app.get("/ui", (_req, res) => { + const filePath = path.resolve(process.cwd(), "ui/index.html"); + res.sendFile(filePath); +}); + +app.get("/ui/hub", (_req, res) => { + const filePath = path.resolve(process.cwd(), "ui/hub.html"); + res.sendFile(filePath); +}); + +app.use("/ui/shared", express.static(path.resolve(process.cwd(), "ui", "shared"))); + +app.get("/ui/:page", (req, res, next) => { + const filePath = resolveUiPageFile(req.params.page); + if (!filePath) { + return next(); + } + res.sendFile(filePath); +}); + +app.use("/admin", adminRoutes); +app.use("/integrations", integrationRoutes); +app.use("/device", deviceRoutes); + +app.use((err: Error, _req: Request, res: Response, next: NextFunction) => { + handleErrors(err, _req, res, next); +}); + +app.get("/health", (req, res) => { + res.status(200).json( + successResponse(req, { + status: "healthy", + time: new Date().toISOString() + }) + ); +}); + +app.use((req, res) => { + res.status(404).json({ + code: "NOT_FOUND", + message: `Route ${req.path} not found`, + request_id: req.requestId, + timestamp: new Date().toISOString() + }); +}); + +export default app; diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..647137a --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,19 @@ +export const env = { + PORT: Number(process.env.PORT || 3000), + ADMIN_TOKEN: process.env.ADMIN_TOKEN || "admin-dev-token", + DEVICE_TOKEN: process.env.DEVICE_TOKEN || "device-dev-token", + 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_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 + ), + DATABASE_URL: process.env.DATABASE_URL || "", + PGHOST: process.env.PGHOST || "127.0.0.1", + PGPORT: Number(process.env.PGPORT || 5432), + PGUSER: process.env.PGUSER || "postgres", + PGPASSWORD: process.env.PGPASSWORD || "postgres", + PGDATABASE: process.env.PGDATABASE || "qris_soundbox_platform" +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ff4d8f3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,18 @@ +import { createServer } from "node:http"; +import app from "./app"; +import { ensureSchema } from "./shared/db/pool"; +import { env } from "./config/env"; + +const port = env.PORT; + +const server = createServer(app); + +async function bootstrap() { + await ensureSchema(); + + server.listen(port, () => { + console.log(`QRIS Soundbox Platform bootstrap ready on :${port}`); + }); +} + +void bootstrap(); diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..6869f36 --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,1591 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { randomUUID } from "node:crypto"; +import { ApiError } from "../shared/errors"; +import { requireAdminToken } from "../shared/middleware/auth"; +import { successResponse } from "../shared/middleware/errorMiddleware"; +import { env } from "../config/env"; +import { idempotency } from "../shared/middleware/idempotency"; +import { createMerchant, getMerchantById, listMerchants, patchMerchant, toMerchantPayload, PayoutMode } from "../shared/store/merchantStore"; +import { + createOutlet, + createTerminal, + getOutletById, + getTerminalById, + listOutlets, + listTerminals, + patchOutlet, + patchTerminal, + toOutletPayload, + toTerminalPayload +} from "../shared/store/locationStore"; +import { bindDevice, getActiveBindingByDevice, getActiveBindingByTerminal, toBindingPayload, unbindDevice } from "../shared/store/bindingStore"; +import { + createDevice, + getDeviceById, + listDevices, + patchDevice, + DeviceEntity, + toDevicePayload +} from "../shared/store/deviceStore"; +import { + deriveDeviceStatus, + getHeartbeatCountForDeviceLastHours, + getLatestHeartbeatByDeviceId, + listHeartbeats, + createDeviceHeartbeat +} from "../shared/store/heartbeatStore"; +import { + createDeviceCommand, + getDeviceCommandById, + listDeviceCommands, + toDeviceCommandPayload, + toDeviceCommandPayloadBrief +} from "../shared/store/deviceCommandStore"; +import { + createTransaction, + getTransactionById, + listTransactions, + toTransactionEventPayload, + toTransactionPayload, + getTransactionEvents +} from "../shared/store/transactionStore"; +import { + getNotificationByTransactionId, + listNotifications, + listNotificationsByDevice, + toNotificationPayload +} from "../shared/store/notificationStore"; +import { retryNotificationByTransactionId } from "../shared/orchestrators/notificationOrchestrator"; + +const router = Router(); + +type LoginInput = { username?: string; password?: string }; + +type MerchantCreateInput = { + legal_name?: string; + brand_name?: string; + settlement_account_reference?: string; + settlement_account_type?: string; + payout_mode?: PayoutMode; + fee_profile_id?: string; + status?: "active" | "inactive"; + onboarding_status?: "pending" | "approved" | "rejected"; +}; + +type OutletCreateInput = { + name?: string; + address?: string; + outlet_code?: string; + status?: "active" | "inactive"; +}; + +type TerminalCreateInput = { + terminal_code?: string; + qr_mode?: "static" | "dynamic_mqtt" | "dynamic_api"; + partner_reference?: string; + status?: "active" | "inactive"; +}; + +type DeviceCreateInput = { + device_code?: string; + serial_number?: string; + vendor?: string; + model?: string; + communication_mode?: "static" | "mqtt" | "api"; + capability_profile_json?: Record; + auth_method?: string; + status?: "active" | "inactive"; + firmware_version?: string; + last_seen_at?: string; +}; + +type DeviceCommandInput = { + command?: string; + payload?: Record; +}; + +type BindingInput = { + merchant_id?: string; + outlet_id?: string; + terminal_id?: string; +}; + +type MerchantRejectInput = { + reason?: string; +}; + +type SeedInput = { + include_demo_heartbeat?: boolean; + include_demo_transactions?: boolean; +}; + +type TransactionCreateInput = { + partner_reference?: string; + merchant_id?: string; + outlet_id?: string; + terminal_id?: string; + device_id?: string; + amount?: number; + currency?: string; + qr_mode?: "static" | "dynamic"; + initiation_mode?: "manual" | "dynamic_api" | "dynamic_mqtt" | "static"; + status?: "initiated" | "awaiting_payment"; +}; + +function parseIdempotentReplay(req: Request) { + return (req.body as { __idempotentReplay?: boolean; __idempotentResponse?: unknown }).__idempotentReplay; +} + +function getReplayResponse(req: Request) { + return (req.body as { __idempotentResponse?: unknown }).__idempotentResponse; +} + +function isIsoDate(value: string | undefined): value is string { + if (!value) { + return false; + } + return Number.isFinite(Date.parse(value)); +} + +function isTxInDateRange(tx: { created_at: string }, from?: string, to?: string): boolean { + const createdAt = Date.parse(tx.created_at); + if (Number.isNaN(createdAt)) { + return false; + } + + if (from && createdAt < Date.parse(from)) { + return false; + } + + if (to && createdAt > Date.parse(to)) { + return false; + } + + return true; +} + +function parseDeviceStatusFilter(value: string | undefined) { + if (value === "online" || value === "offline" || value === "degraded" || value === "stale") { + return value; + } + return undefined; +} + +function parseCommunicationModeFilter(value: string | undefined) { + if (value === "static" || value === "mqtt" || value === "api") { + return value; + } + return undefined; +} + +function parseDeviceCommunicationMode(value: string | undefined): "static" | "mqtt" | "api" | undefined { + if (value === "static" || value === "mqtt" || value === "api") { + return value; + } + return undefined; +} + +function parseDeviceStatusValue(value: string | undefined): "active" | "inactive" | undefined { + if (value === "active" || value === "inactive") { + return value; + } + return undefined; +} + +function parsePayoutMode(value: string | undefined): "merchant_direct" | "manual" | undefined { + if (value === "merchant_direct" || value === "manual") { + return value; + } + return undefined; +} + +function parseOutletStatusFilter(value: string | undefined): "active" | "inactive" | undefined { + if (value === "active" || value === "inactive") { + return value; + } + return undefined; +} + +function parseTerminalStatusFilter(value: string | undefined): "active" | "inactive" | undefined { + if (value === "active" || value === "inactive") { + return value; + } + return undefined; +} + +function parseTerminalModeFilter(value: string | undefined): "static" | "dynamic_mqtt" | "dynamic_api" | undefined { + if (value === "static" || value === "dynamic_mqtt" || value === "dynamic_api") { + return value; + } + return undefined; +} + +function parseTransactionStatusFilter( + value: string | undefined +): "initiated" | "awaiting_payment" | "paid" | "failed" | "expired" | "reversed" | undefined { + if ( + value === "initiated" || + value === "awaiting_payment" || + value === "paid" || + value === "failed" || + value === "expired" || + value === "reversed" + ) { + return value; + } + return undefined; +} + +function parseCommandStatusFilter(value: string | undefined) { + if (value === "accepted" || value === "delivered" || value === "failed" || value === "timeout") { + return value; + } + return undefined; +} + +function buildBindingSummary( + binding: Awaited> | null +) { + if (!binding) { + return null; + } + + return { + id: binding.id, + merchant_id: binding.merchant_id, + outlet_id: binding.outlet_id, + terminal_id: binding.terminal_id + }; +} + +async function buildDeviceAdminPayload(device: DeviceEntity) { + const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); + return { + ...toDevicePayload(device), + derived_status: deriveDeviceStatus({ + last_seen_at: device.last_seen_at, + network_strength: latestHeartbeat?.network_strength ?? null, + battery_level: latestHeartbeat?.battery_level ?? null + }), + heartbeat_count_24h: await getHeartbeatCountForDeviceLastHours(device.id), + binding_summary: buildBindingSummary(await getActiveBindingByDevice(device.id)), + latest_heartbeat: latestHeartbeat + ? { + id: latestHeartbeat.id, + timestamp: latestHeartbeat.timestamp, + received_at: latestHeartbeat.received_at, + state: latestHeartbeat.state, + network_strength: latestHeartbeat.network_strength, + battery_level: latestHeartbeat.battery_level, + firmware_version: latestHeartbeat.firmware_version + } + : null + }; +} + +async function deriveDeviceStatusesForDashboard() { + const devices = await listDevices(); + + return Promise.all( + devices.map(async (device) => { + const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); + return { + device, + status: deriveDeviceStatus({ + last_seen_at: device.last_seen_at, + network_strength: latestHeartbeat?.network_strength ?? null, + battery_level: latestHeartbeat?.battery_level ?? null + }) + }; + }) + ); +} + +function buildDashboardRange() { + const start = new Date(); + start.setUTCHours(0, 0, 0, 0); + const end = new Date(start); + end.setUTCDate(start.getUTCDate() + 1); + return { start, end }; +} + +function toStartEndDateFilter(from?: string, to?: string) { + if (from && Number.isNaN(Date.parse(from))) { + return null; + } + if (to && Number.isNaN(Date.parse(to))) { + return null; + } + + return { + fromTs: from ? Date.parse(from) : null, + toTs: to ? Date.parse(to) : null + }; +} + +function normalizeMerchantMode(payloadMode: MerchantCreateInput["payout_mode"]): NonNullable { + return payloadMode || "merchant_direct"; +} + +function validatePayoutConfig(payload: MerchantCreateInput) { + const mode = normalizeMerchantMode(payload.payout_mode); + if (mode === "merchant_direct") { + if (!payload.settlement_account_reference || !payload.settlement_account_type) { + throw new ApiError( + "BAD_REQUEST", + "settlement_account_reference and settlement_account_type required when payout_mode=merchant_direct", + 400 + ); + } + } +} + +async function ensureMerchant(req: Request, next: NextFunction) { + const merchantId = req.params.merchantId; + const merchant = await getMerchantById(merchantId); + if (!merchant) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + return merchant; +} + +router.post("/login", async (req: Request, res: Response, next: NextFunction) => { + const { username, password } = req.body as LoginInput; + if (username !== "admin" || password !== "admin") { + return next(new ApiError("UNAUTHORIZED", "Invalid credentials", 401)); + } + + const token = env.ADMIN_TOKEN; + res.json( + successResponse(req, { + token + }) + ); +}); + +router.use(async (req: Request, res: Response, next: NextFunction) => { + if (req.path === "/login") { + return next(); + } + + return requireAdminToken(req, res, next); +}); + +router.get("/health", requireAdminToken, async (_req: Request, res: Response) => { + res.json( + successResponse(_req, { + ok: true, + now: new Date().toISOString() + }) + ); +}); + +router.post( + "/sample-idempotent", + requireAdminToken, + idempotency({ scope: "admin.sample", required: false }), + async (req: Request, res: Response) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + + const id = randomUUID(); + res.json(successResponse(req, { id, generated_at: new Date().toISOString() })); + } +); + +router.post( + "/merchants", + requireAdminToken, + idempotency({ scope: "merchant.create", required: false }), + async (req: Request, res: Response, next: NextFunction) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + + const payload = req.body as MerchantCreateInput; + if (!payload?.legal_name) { + return next(new ApiError("BAD_REQUEST", "legal_name is required", 400)); + } + + const normalizedPayoutMode = parsePayoutMode(payload.payout_mode); + if (payload.payout_mode && !normalizedPayoutMode) { + return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400)); + } + + try { + validatePayoutConfig(payload); + } catch (err) { + return next(err as Error); + } + + const created = await createMerchant({ + legal_name: payload.legal_name, + brand_name: payload.brand_name, + settlement_account_reference: payload.settlement_account_reference, + settlement_account_type: payload.settlement_account_type, + payout_mode: normalizeMerchantMode(payload.payout_mode), + fee_profile_id: payload.fee_profile_id, + status: payload.status, + onboarding_status: payload.onboarding_status + }); + + res.status(201).json(successResponse(req, toMerchantPayload(created))); + } +); + +router.get("/merchants", requireAdminToken, async (_req: Request, res: Response) => { + res.json(successResponse(_req, (await listMerchants()).map(toMerchantPayload))); +}); + +router.get("/merchants/:merchantId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const merchant = await getMerchantById(req.params.merchantId); + if (!merchant) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + + res.json(successResponse(req, toMerchantPayload(merchant))); +}); + +router.patch("/merchants/:merchantId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as MerchantCreateInput; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + + const existing = await getMerchantById(req.params.merchantId); + if (!existing) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + + const normalized: MerchantCreateInput = { + ...payload, + payout_mode: payload.payout_mode ? payload.payout_mode : existing.payout_mode + }; + + if (normalized.payout_mode && !parsePayoutMode(normalized.payout_mode)) { + return next(new ApiError("BAD_REQUEST", "payout_mode must be merchant_direct or manual", 400)); + } + + if (normalized.payout_mode === "merchant_direct") { + normalized.settlement_account_reference = + normalized.settlement_account_reference || existing.settlement_account_reference; + normalized.settlement_account_type = + normalized.settlement_account_type || existing.settlement_account_type; + } + + try { + validatePayoutConfig(normalized); + } catch (err) { + return next(err as Error); + } + + const updated = await patchMerchant(req.params.merchantId, normalized); + res.json(successResponse(req, toMerchantPayload(updated))); +}); + +router.post("/merchants/:merchantId/approve", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const existing = await getMerchantById(req.params.merchantId); + if (!existing) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + + if (existing.onboarding_status === "approved") { + return res.json(successResponse(req, toMerchantPayload(existing))); + } + + const updated = await patchMerchant(req.params.merchantId, { + onboarding_status: "approved" + }); + + res.json(successResponse(req, toMerchantPayload(updated))); +}); + +router.post( + "/merchants/:merchantId/reject", + requireAdminToken, + async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as MerchantRejectInput; + if (!payload?.reason || payload.reason.trim() === "") { + return next(new ApiError("BAD_REQUEST", "reason is required", 400)); + } + + const existing = await getMerchantById(req.params.merchantId); + if (!existing) { + return next(new ApiError("NOT_FOUND", "merchant not found", 404)); + } + + const updated = await patchMerchant(req.params.merchantId, { + onboarding_status: "rejected", + status: "inactive" + }); + + res.json( + successResponse(req, { + ...toMerchantPayload(updated), + rejection_reason: payload.reason + }) + ); + } +); + +router.post( + "/seed", + requireAdminToken, + idempotency({ scope: "seed.demo", required: false }), + async (req: Request, res: Response, next: NextFunction) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + + const payload = (req.body as SeedInput | undefined) || {}; + const includeHeartbeat = payload.include_demo_heartbeat !== false; + const includeTransactions = payload.include_demo_transactions !== false; + + if ( (await listMerchants()).length > 0 || (await listDevices()).length > 0 || (await listOutlets()).length > 0 || (await listTerminals()).length > 0) { + return next(new ApiError("BAD_REQUEST", "seed requires empty demo environment", 400)); + } + + const merchantA = await createMerchant({ + legal_name: "Seed Merchant A", + brand_name: "Seed A", + settlement_account_reference: "seed-bank:111111111", + settlement_account_type: "merchant_bank_account", + payout_mode: "merchant_direct", + status: "active", + onboarding_status: "approved" + }); + const merchantB = await createMerchant({ + legal_name: "Seed Merchant B", + brand_name: "Seed B", + settlement_account_reference: "seed-bank:222222222", + settlement_account_type: "merchant_bank_account", + payout_mode: "manual", + status: "active", + onboarding_status: "pending" + }); + + const outletA = await createOutlet({ + merchant_id: merchantA.id, + name: "Outlet Seed A", + address: "Jl. Contoh Nomor 1" + }); + const outletB = await createOutlet({ + merchant_id: merchantB.id, + name: "Outlet Seed B", + address: "Jl. Contoh Nomor 2" + }); + + const terminalA = await createTerminal({ + outlet_id: outletA.id, + terminal_code: "TERM_SEED_A", + qr_mode: "static" + }); + const terminalB = await createTerminal({ + outlet_id: outletB.id, + terminal_code: "TERM_SEED_B", + qr_mode: "static" + }); + + const deviceA = await createDevice({ + device_code: "DEV_SEED_A", + vendor: "seed-maker", + model: "v1", + communication_mode: "mqtt", + status: "active" + }); + const deviceB = await createDevice({ + device_code: "DEV_SEED_B", + vendor: "seed-maker", + model: "v1", + communication_mode: "mqtt", + status: "active" + }); + const deviceC = await createDevice({ + device_code: "DEV_SEED_C", + vendor: "seed-maker", + model: "v1", + communication_mode: "mqtt", + status: "inactive" + }); + + await bindDevice({ + device_id: deviceA.id, + merchant_id: merchantA.id, + outlet_id: outletA.id, + terminal_id: terminalA.id + }); + await bindDevice({ + device_id: deviceB.id, + merchant_id: merchantB.id, + outlet_id: outletB.id, + terminal_id: terminalB.id + }); + + if (includeHeartbeat) { + await createDeviceHeartbeat({ + device_id: deviceA.id, + timestamp: new Date().toISOString(), + firmware_version: "1.0.0", + network_strength: 92, + battery_level: 89, + state: "idle" + }); + await createDeviceHeartbeat({ + device_id: deviceB.id, + timestamp: new Date().toISOString(), + firmware_version: "1.0.0", + network_strength: 83, + battery_level: 76, + state: "idle" + }); + } + + const transactions = includeTransactions + ? [ + await createTransaction({ + merchant_id: merchantA.id, + outlet_id: outletA.id, + terminal_id: terminalA.id, + device_id: deviceA.id, + partner_reference: "seed-pr-001", + amount: 25000, + currency: "IDR", + qr_mode: "static", + initiation_mode: "static", + status: "initiated" + }), + await createTransaction({ + merchant_id: merchantB.id, + outlet_id: outletB.id, + terminal_id: terminalB.id, + device_id: deviceB.id, + partner_reference: "seed-pr-002", + amount: 50000, + currency: "IDR", + qr_mode: "static", + initiation_mode: "static", + status: "awaiting_payment" + }) + ] + : []; + + const seeded = { + merchants: [toMerchantPayload(merchantA), toMerchantPayload(merchantB)], + outlets: [outletA, outletB], + terminals: [terminalA, terminalB], + devices: [deviceA, deviceB, deviceC], + transactions: transactions.map((tx) => toTransactionPayload(tx)), + include_demo_heartbeat: includeHeartbeat, + include_demo_transactions: includeTransactions + }; + + res.status(201).json(successResponse(req, seeded)); + } +); + +router.get("/seed/status", requireAdminToken, async (_req: Request, res: Response) => { + res.json( + successResponse(_req, { + merchants: (await listMerchants()).length, + outlets: (await listOutlets()).length, + terminals: (await listTerminals()).length, + devices: (await listDevices()).length, + transactions: (await listTransactions()).length, + heartbeats: (await listHeartbeats()).length, + notifications: (await listNotifications()).length, + seed_eligible: (await listMerchants()).length === 0 && (await listDevices()).length === 0 && (await listOutlets()).length === 0 && (await listTerminals()).length === 0 + }) + ); +}); + +router.post( + "/merchants/:merchantId/outlets", + requireAdminToken, + idempotency({ scope: "outlet.create", required: false }), + async (req: Request, res: Response, next: NextFunction) => { + if (parseIdempotentReplay(req)) { + return res.status(200).json(getReplayResponse(req)); + } + + const merchant = await ensureMerchant(req, next); + if (!merchant) { + return; + } + + const payload = req.body as OutletCreateInput; + if (!payload?.name) { + return next(new ApiError("BAD_REQUEST", "name is required", 400)); + } + + if (payload.status) { + if (!parseOutletStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + } + + const outlet = await createOutlet({ + merchant_id: merchant.id, + name: payload.name, + address: payload.address, + outlet_code: payload.outlet_code, + status: payload.status + }); + + res.status(201).json(successResponse(req, outlet)); + } +); + +router.get("/outlets", requireAdminToken, 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)?.trim(); + const status = parseOutletStatusFilter(statusRaw); + if (statusRaw && !status) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + const q = (req.query.q as string | undefined)?.trim(); + res.json( + successResponse( + req, + (await listOutlets({ + merchant_id: merchantId, + status, + q: q || undefined + })).map(toOutletPayload) + ) + ); +}); + +router.get("/outlets/:outletId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const outlet = await getOutletById(req.params.outletId); + if (!outlet) { + return next(new ApiError("NOT_FOUND", "outlet not found", 404)); + } + + res.json(successResponse(req, toOutletPayload(outlet))); +}); + +router.patch("/outlets/:outletId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as OutletCreateInput; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + if (payload.status && !parseOutletStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + + try { + const updated = await patchOutlet(req.params.outletId, payload); + res.json(successResponse(req, toOutletPayload(updated))); + } catch (err) { + if (err instanceof Error && err.message === "OUTLET_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "outlet not found", 404)); + } + return next(err as Error); + } +}); + +router.post( + "/outlets/:outletId/terminals", + requireAdminToken, + idempotency({ scope: "terminal.create", required: false }), + async (req: Request, res: Response, next: NextFunction) => { + if (parseIdempotentReplay(req)) { + return res.status(201).json(getReplayResponse(req)); + } + + const outlet = await getOutletById(req.params.outletId); + if (!outlet) { + return next(new ApiError("NOT_FOUND", "outlet not found", 404)); + } + + const payload = req.body as TerminalCreateInput; + if (!payload || typeof payload !== "object") { + return next(new ApiError("BAD_REQUEST", "terminal payload required", 400)); + } + + if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) { + return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400)); + } + if (payload.status && !parseTerminalStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + + const terminal = await createTerminal({ + outlet_id: outlet.id, + terminal_code: payload.terminal_code, + qr_mode: payload.qr_mode, + partner_reference: payload.partner_reference, + status: payload.status + }); + + res.status(201).json(successResponse(req, toTerminalPayload(terminal))); + } +); + +router.get("/terminals", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const outletId = (req.query.outlet_id as string | undefined)?.trim(); + const statusRaw = (req.query.status as string | undefined)?.trim(); + const status = parseTerminalStatusFilter(statusRaw); + if (statusRaw && !status) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + const q = (req.query.q as string | undefined)?.trim(); + res.json( + successResponse( + req, + (await listTerminals({ + outlet_id: outletId, + status, + q: q || undefined + })).map(toTerminalPayload) + ) + ); +}); + +router.get("/terminals/:terminalId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const terminal = await getTerminalById(req.params.terminalId); + if (!terminal) { + return next(new ApiError("NOT_FOUND", "terminal not found", 404)); + } + + res.json(successResponse(req, toTerminalPayload(terminal))); +}); + +router.patch("/terminals/:terminalId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as TerminalCreateInput; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) { + return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400)); + } + if (payload.status && !parseTerminalStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + + try { + const updated = await patchTerminal(req.params.terminalId, payload); + res.json(successResponse(req, toTerminalPayload(updated))); + } catch (err) { + if (err instanceof Error && err.message === "TERMINAL_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "terminal not found", 404)); + } + return next(err as Error); + } +}); + +router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", required: false }), async (req: Request, res: Response, next: NextFunction) => { + if (parseIdempotentReplay(req)) { + return res.status(201).json(getReplayResponse(req)); + } + + const payload = req.body as DeviceCreateInput; + if (!payload) { + return next(new ApiError("BAD_REQUEST", "device payload required", 400)); + } + + if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) { + return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400)); + } + + if (payload.status && !parseDeviceStatusValue(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + + const created = await createDevice(payload); + res.status(201).json(successResponse(req, toDevicePayload(created))); +}); + +router.get("/devices", requireAdminToken, async (req: Request, res: Response) => { + const status = parseDeviceStatusFilter(req.query.status as string | undefined); + const vendor = (req.query.vendor as string | undefined)?.trim(); + const communicationMode = parseCommunicationModeFilter(req.query.communication_mode as string | undefined); + const merchantId = (req.query.merchant_id as string | undefined)?.trim(); + const q = (req.query.q as string | undefined)?.trim(); + + const rawDevices = await listDevices(); + const evaluated = await Promise.all( + rawDevices.map(async (device) => { + const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); + const binding = merchantId ? await getActiveBindingByDevice(device.id) : null; + return { + device, + latestHeartbeat, + binding, + derivedStatus: deriveDeviceStatus({ + last_seen_at: device.last_seen_at, + network_strength: latestHeartbeat?.network_strength ?? null, + battery_level: latestHeartbeat?.battery_level ?? null + }) + }; + }) + ); + + const data = evaluated + .filter((entry) => { + const { device, derivedStatus, binding } = entry; + + if (status && derivedStatus !== status) { + return false; + } + + if (vendor && device.vendor !== vendor) { + return false; + } + + if (communicationMode && device.communication_mode !== communicationMode) { + return false; + } + + if (merchantId && (!binding || binding.merchant_id !== merchantId)) { + return false; + } + + if (q) { + const text = q.toLowerCase(); + const codeMatch = device.device_code.toLowerCase().includes(text); + const serialMatch = device.serial_number?.toLowerCase().includes(text); + if (!codeMatch && !serialMatch) { + return false; + } + } + + return true; + }) + .map((entry) => entry.device); + + const payloads = await Promise.all( + data + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((device) => buildDeviceAdminPayload(device)) + ); + res.json(successResponse(req, payloads)); +}); + +router.get("/devices/:deviceId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const activeBinding = await getActiveBindingByDevice(device.id); + const latestHeartbeat = await getLatestHeartbeatByDeviceId(device.id); + const heartbeatCount24h = await getHeartbeatCountForDeviceLastHours(device.id); + const notifications = (await listNotificationsByDevice(device.id)) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .slice(0, 10) + .map(toNotificationPayload); + + res.json( + successResponse(req, { + ...toDevicePayload(device), + derived_status: deriveDeviceStatus({ + last_seen_at: device.last_seen_at, + network_strength: latestHeartbeat?.network_strength ?? null, + battery_level: latestHeartbeat?.battery_level ?? null + }), + active_binding: activeBinding ? toBindingPayload(activeBinding) : null, + latest_heartbeat: latestHeartbeat + ? { + id: latestHeartbeat.id, + timestamp: latestHeartbeat.timestamp, + received_at: latestHeartbeat.received_at, + state: latestHeartbeat.state, + network_strength: latestHeartbeat.network_strength, + battery_level: latestHeartbeat.battery_level, + firmware_version: latestHeartbeat.firmware_version + } + : null, + heartbeat_count_24h: heartbeatCount24h, + notifications_latest: notifications + }) + ); +}); + +router.get("/devices/:deviceId/heartbeats", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const from = req.query.from as string | undefined; + const to = req.query.to as string | undefined; + const state = req.query.state as string | undefined; + + if (from && Number.isNaN(Date.parse(from))) { + return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); + } + + if (to && Number.isNaN(Date.parse(to))) { + return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); + } + + res.json( + successResponse(req, { + device_id: device.id, + heartbeats: await listHeartbeats({ + device_id: device.id, + from, + to, + state + }) + }) + ); +}); + +router.patch("/devices/:deviceId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as DeviceCreateInput; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) { + return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400)); + } + if (payload.status && !parseDeviceStatusValue(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + + try { + const updated = await patchDevice(req.params.deviceId, payload); + res.json(successResponse(req, toDevicePayload(updated))); + } 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)); + } + + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const payload = req.body as BindingInput; + if (!payload?.merchant_id || !payload.outlet_id || !payload.terminal_id) { + return next(new ApiError("BAD_REQUEST", "merchant_id, outlet_id, terminal_id required", 400)); + } + + const merchant = await getMerchantById(payload.merchant_id); + const outlet = await getOutletById(payload.outlet_id); + const terminal = await getTerminalById(payload.terminal_id); + if (!merchant || !outlet || !terminal) { + return next(new ApiError("BAD_REQUEST", "merchant/outlet/terminal reference invalid", 400)); + } + + if (outlet.merchant_id !== merchant.id) { + return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400)); + } + + if (terminal.outlet_id !== outlet.id) { + return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400)); + } + + const binding = await bindDevice({ + device_id: device.id, + merchant_id: merchant.id, + outlet_id: outlet.id, + terminal_id: terminal.id + }); + + res.json(successResponse(req, toBindingPayload(binding))); +}); + +router.post("/devices/:deviceId/unbind", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const binding = await unbindDevice(device.id); + if (!binding) { + return next(new ApiError("BAD_REQUEST", "device has no active binding", 400)); + } + + res.json(successResponse(req, toBindingPayload(binding))); +}); + +router.post( + "/devices/:deviceId/commands", + requireAdminToken, + async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const payload = req.body as DeviceCommandInput; + if (!payload || typeof payload.command !== "string" || payload.command.trim() === "") { + return next(new ApiError("BAD_REQUEST", "command is required", 400)); + } + + const command = await createDeviceCommand({ + device_id: device.id, + command: payload.command.trim(), + payload: payload.payload || {} + }); + + res.status(201).json(successResponse(req, toDeviceCommandPayload(command))); + } +); + +router.get( + "/devices/:deviceId/commands", + requireAdminToken, + async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const statusFilter = parseCommandStatusFilter(req.query.status as string | undefined); + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 50; + + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + + const commands = (await listDeviceCommands(device.id)) + .filter((command) => !statusFilter || command.status === statusFilter) + .slice(0, Math.min(limit, 200)) + .map(toDeviceCommandPayloadBrief); + + res.json(successResponse(req, { device_id: device.id, commands })); + } +); + +router.get( + "/devices/:deviceId/commands/:commandId", + requireAdminToken, + async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const command = await getDeviceCommandById(device.id, req.params.commandId); + if (!command) { + return next(new ApiError("NOT_FOUND", "command not found", 404)); + } + + res.json(successResponse(req, toDeviceCommandPayload(command))); + } +); + +router.get("/devices/:deviceId/notifications", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const device = await getDeviceById(req.params.deviceId); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 50; + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + + const notifications = (await listNotificationsByDevice(device.id)) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .slice(0, Math.min(limit, 200)) + .map(toNotificationPayload); + + res.json(successResponse(req, { device_id: device.id, notifications })); +}); + +router.post( + "/transactions", + requireAdminToken, + idempotency({ scope: "transaction.create", required: false }), + async (req: Request, res: Response, next: NextFunction) => { + if (parseIdempotentReplay(req)) { + return res.status(201).json(getReplayResponse(req)); + } + + const payload = req.body as TransactionCreateInput; + if ( + !payload || + !payload.partner_reference || + !payload.merchant_id || + !payload.outlet_id || + !payload.terminal_id + ) { + return next(new ApiError("BAD_REQUEST", "partner_reference, merchant_id, outlet_id, terminal_id required", 400)); + } + + const merchant = await getMerchantById(payload.merchant_id); + if (!merchant) { + return next(new ApiError("BAD_REQUEST", "merchant not found", 400)); + } + + const outlet = await getOutletById(payload.outlet_id); + if (!outlet) { + return next(new ApiError("BAD_REQUEST", "outlet not found", 400)); + } + + if (outlet.merchant_id !== merchant.id) { + return next(new ApiError("BAD_REQUEST", "outlet does not belong to merchant", 400)); + } + + const terminal = await getTerminalById(payload.terminal_id); + if (!terminal) { + return next(new ApiError("BAD_REQUEST", "terminal not found", 400)); + } + + if (terminal.outlet_id !== outlet.id) { + return next(new ApiError("BAD_REQUEST", "terminal does not belong to outlet", 400)); + } + + if (payload.device_id && !await getDeviceById(payload.device_id)) { + return next(new ApiError("BAD_REQUEST", "device not found", 400)); + } + + const amount = Number(payload.amount); + if (!Number.isFinite(amount) || amount <= 0) { + return next(new ApiError("BAD_REQUEST", "amount must be a positive number", 400)); + } + + if (payload.status && !parseTransactionStatusFilter(payload.status)) { + return next(new ApiError("BAD_REQUEST", "invalid status", 400)); + } + + const created = await createTransaction({ + merchant_id: merchant.id, + outlet_id: outlet.id, + terminal_id: terminal.id, + device_id: payload.device_id, + partner_reference: payload.partner_reference, + amount, + currency: payload.currency, + qr_mode: payload.qr_mode || "static", + initiation_mode: payload.initiation_mode || "static", + status: payload.status || "initiated" + }); + + res.status(201).json(successResponse(req, toTransactionPayload(created))); + } +); + +router.get("/transactions", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const statusRaw = (req.query.status as string | undefined)?.trim(); + const status = parseTransactionStatusFilter(statusRaw); + if (statusRaw && !status) { + return next(new ApiError("BAD_REQUEST", "invalid status", 400)); + } + + const merchantId = (req.query.merchant_id as string | undefined)?.trim(); + const from = req.query.from as string | undefined; + const to = req.query.to as string | undefined; + const partnerReference = req.query.partner_reference as string | undefined; + const q = (req.query.q as string | undefined)?.trim(); + + 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)); + } + + const normalizedPartnerRef = partnerReference?.trim(); + const normalizedQ = q || ""; + + res.json( + successResponse( + req, + (await listTransactions({ + status, + merchant_id: merchantId + })) + .filter((tx) => isTxInDateRange(tx, from, to)) + .filter((tx) => !normalizedPartnerRef || tx.partner_reference === normalizedPartnerRef) + .filter((tx) => { + if (!normalizedQ) { + return true; + } + + const lower = normalizedQ.toLowerCase(); + return ( + tx.partner_reference.toLowerCase().includes(lower) || + tx.transaction_code.toLowerCase().includes(lower) + ); + }) + .map(toTransactionPayload) + ) + ); +}); + +router.get( + "/transactions/:transactionId", + requireAdminToken, + async (req: Request, res: Response, next: NextFunction) => { + const tx = await getTransactionById(req.params.transactionId); + if (!tx) { + return next(new ApiError("NOT_FOUND", "transaction not found", 404)); + } + + const events = (await getTransactionEvents(tx.id)).map(toTransactionEventPayload); + const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null; + const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id; + const heartbeatHistory = heartbeatDeviceId + ? (await listHeartbeats({ device_id: heartbeatDeviceId })).map((heartbeat) => ({ + id: heartbeat.id, + device_id: heartbeat.device_id, + timestamp: heartbeat.timestamp, + state: heartbeat.state, + network_strength: heartbeat.network_strength, + battery_level: heartbeat.battery_level, + firmware_version: heartbeat.firmware_version, + received_at: heartbeat.received_at + })) + : []; + + res.json( + successResponse(req, { + transaction: toTransactionPayload(tx), + events, + heartbeat_device_id: heartbeatDeviceId, + heartbeat_history: heartbeatHistory + }) + ); + } +); + +router.get( + "/transactions/:transactionId/events", + requireAdminToken, + async (req: Request, res: Response, next: NextFunction) => { + const tx = await getTransactionById(req.params.transactionId); + if (!tx) { + return next(new ApiError("NOT_FOUND", "transaction not found", 404)); + } + + const events = (await getTransactionEvents(tx.id)) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .map(toTransactionEventPayload); + + res.json(successResponse(req, { transaction_id: tx.id, events })); + } +); + +router.get( + "/transactions/:transactionId/heartbeats", + requireAdminToken, + async (req: Request, res: Response, next: NextFunction) => { + const tx = await getTransactionById(req.params.transactionId); + if (!tx) { + return next(new ApiError("NOT_FOUND", "transaction not found", 404)); + } + + const from = req.query.from as string | undefined; + const to = req.query.to as string | undefined; + const state = req.query.state as string | undefined; + const limitRaw = req.query.limit as string | undefined; + const limit = limitRaw ? Number(limitRaw) : 100; + + if (from && Number.isNaN(Date.parse(from))) { + return next(new ApiError("BAD_REQUEST", "from must be valid ISO datetime", 400)); + } + + if (to && Number.isNaN(Date.parse(to))) { + return next(new ApiError("BAD_REQUEST", "to must be valid ISO datetime", 400)); + } + + if (!Number.isFinite(limit) || limit <= 0) { + return next(new ApiError("BAD_REQUEST", "limit must be a positive number", 400)); + } + + const bindingByTerminal = tx.terminal_id ? await getActiveBindingByTerminal(tx.terminal_id) : null; + const heartbeatDeviceId = tx.device_id || bindingByTerminal?.device_id; + if (!heartbeatDeviceId) { + return res.json( + successResponse(req, { + transaction_id: tx.id, + heartbeat_device_id: null, + heartbeats: [] + }) + ); + } + + const heartbeats = (await listHeartbeats({ + device_id: heartbeatDeviceId, + from, + to, + state + })) + .slice(0, Math.min(limit, 500)) + .map((heartbeat) => ({ + id: heartbeat.id, + device_id: heartbeat.device_id, + timestamp: heartbeat.timestamp, + received_at: heartbeat.received_at, + state: heartbeat.state, + network_strength: heartbeat.network_strength, + battery_level: heartbeat.battery_level, + firmware_version: heartbeat.firmware_version + })); + + res.json( + successResponse(req, { + transaction_id: tx.id, + heartbeat_device_id: heartbeatDeviceId, + heartbeats + }) + ); + } +); + +router.post( + "/transactions/:transactionId/retry-notification", + requireAdminToken, + async (req: Request, res: Response, next: NextFunction) => { + const transactionId = req.params.transactionId; + try { + const tx = await getTransactionById(transactionId); + if (!tx) { + return next(new ApiError("NOT_FOUND", "transaction not found", 404)); + } + + if (tx.status !== "paid") { + return next( + new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400) + ); + } + + const before = await getNotificationByTransactionId(transactionId); + if (!before) { + return next( + new ApiError("NOT_FOUND", "notification not found for this transaction", 404) + ); + } + + if (before.delivery_status === "acknowledged") { + return res.status(200).json( + successResponse(req, { + transaction_id: tx.id, + notification_id: before.id, + delivery_status: before.delivery_status, + next_retry_at: before.next_retry_at || null + }) + ); + } + + const updated = await retryNotificationByTransactionId(transactionId); + if (!updated) { + return next(new ApiError("NOTIFICATION_PUBLISH_FAILED", "notification retry could not be executed", 500)); + } + + res.json( + successResponse(req, { + transaction_id: tx.id, + notification_id: updated.id, + delivery_status: updated.delivery_status, + next_retry_at: updated.next_retry_at || null + }) + ); + } catch (error) { + if (error instanceof Error && error.message === "NOTIFICATION_PUBLISH_CONDITION") { + return next(new ApiError("BAD_REQUEST", "notification retry only allowed for paid transactions", 400)); + } + + return next(error as Error); + } + } +); + +router.get("/dashboard/summary", requireAdminToken, async (req: Request, res: Response) => { + let dashboard = { + transactions_today: 0, + success_rate_today: 0, + active_devices: 0, + pending_notifications: 0, + devices_stale: 0, + devices_offline: 0 + }; + + try { + const { start, end } = buildDashboardRange(); + const startTs = start.getTime(); + const endTs = end.getTime(); + + const todayTransactions = (await listTransactions()).filter((tx) => { + const createdTs = Date.parse(tx.created_at); + return createdTs >= startTs && createdTs < endTs; + }); + const paidToday = todayTransactions.filter((tx) => tx.status === "paid").length; + const transactionsToday = todayTransactions.length; + const successRateToday = transactionsToday > 0 ? (paidToday / transactionsToday) * 100 : 0; + + const statuses = await deriveDeviceStatusesForDashboard(); + 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; + + const pendingNotifications = (await listNotifications()).filter((notification) => { + return notification.delivery_status === "queued" || notification.delivery_status === "retrying"; + }).length; + + dashboard = { + transactions_today: transactionsToday, + success_rate_today: Number(successRateToday.toFixed(2)), + active_devices: activeDevices, + pending_notifications: pendingNotifications, + devices_stale: devicesStale, + devices_offline: devicesOffline + }; + } catch (error) { + console.error("[dashboard/summary] fallback due calculation error", error instanceof Error ? error.message : error); + } + + res.json(successResponse(req, dashboard)); +}); + +router.get("/notifications/failed", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const deviceId = req.query.device_id as string | undefined; + const from = req.query.from as string | undefined; + const to = req.query.to as string | undefined; + + const range = toStartEndDateFilter(from, to); + if ((from || to) && range === null) { + return next(new ApiError("BAD_REQUEST", "from/to must be valid ISO datetime", 400)); + } + + const filtered = (await listNotifications()) + .filter((notification) => notification.delivery_status === "failed") + .filter((notification) => !deviceId || notification.device_id === deviceId) + .filter((notification) => { + if (!range) { + return true; + } + + const createdTs = Date.parse(notification.created_at); + if (range.fromTs && createdTs < range.fromTs) { + return false; + } + + if (range.toTs && createdTs > range.toTs) { + return false; + } + + return true; + }) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) + .map((notification) => ({ + notification_id: notification.id, + transaction_id: notification.transaction_id, + device_id: notification.device_id, + delivery_status: notification.delivery_status, + retry_count: notification.retry_count, + reason: notification.reason + })); + + res.json(successResponse(req, filtered)); +}); + +export default router; diff --git a/src/routes/device.ts b/src/routes/device.ts new file mode 100644 index 0000000..a2aa369 --- /dev/null +++ b/src/routes/device.ts @@ -0,0 +1,168 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { ApiError } from "../shared/errors"; +import { requireDeviceToken } from "../shared/middleware/auth"; +import { successResponse } from "../shared/middleware/errorMiddleware"; +import { getDeviceById, patchDevice } from "../shared/store/deviceStore"; +import { createDeviceHeartbeat } from "../shared/store/heartbeatStore"; +import { acknowledgeDeviceCommand } from "../shared/store/deviceCommandStore"; + +const router = Router(); + +type HeartbeatInput = { + device_id?: string; + timestamp?: string; + firmware_version?: string; + network_strength?: unknown; + battery_level?: unknown; + state?: string; +}; + +type CommandAckInput = { + device_id?: string; + command_id?: string; + status?: "delivered" | "failed" | "timeout"; + reason?: string; + result_payload?: Record; +}; + +function normalizeNumberOrNull(value: unknown): number | null { + if (typeof value === "string") { + const parsed = Number(value); + if (!Number.isNaN(parsed) && Number.isFinite(parsed)) { + return parsed; + } + return null; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + return null; +} + +function normalizeSignalStrength(value: unknown): number | null { + const normalized = normalizeNumberOrNull(value); + if (normalized === null) { + return null; + } + + if (normalized < 0 || normalized > 100) { + throw new Error("NETWORK_STRENGTH_OUT_OF_RANGE"); + } + + return normalized; +} + +function normalizeBatteryLevel(value: unknown): number | null { + const normalized = normalizeNumberOrNull(value); + if (normalized === null) { + return null; + } + + if (normalized < 0 || normalized > 100) { + throw new Error("BATTERY_LEVEL_OUT_OF_RANGE"); + } + + return normalized; +} + +router.post("/heartbeat", requireDeviceToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as HeartbeatInput; + if (!payload || !payload.device_id) { + return next(new ApiError("BAD_REQUEST", "device_id is required", 400)); + } + + const device = await getDeviceById(payload.device_id); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const eventTs = payload.timestamp ? new Date(payload.timestamp) : new Date(); + if (Number.isNaN(eventTs.getTime())) { + return next(new ApiError("BAD_REQUEST", "timestamp must be valid ISO datetime", 400)); + } + + let payloadNetworkStrength: number | null; + let payloadBattery: number | null; + try { + payloadNetworkStrength = normalizeSignalStrength(payload.network_strength); + payloadBattery = normalizeBatteryLevel(payload.battery_level); + } catch (error) { + if (error instanceof Error && error.message === "NETWORK_STRENGTH_OUT_OF_RANGE") { + return next(new ApiError("BAD_REQUEST", "network_strength must be between 0 and 100", 400)); + } + if (error instanceof Error && error.message === "BATTERY_LEVEL_OUT_OF_RANGE") { + return next(new ApiError("BAD_REQUEST", "battery_level must be between 0 and 100", 400)); + } + return next(error as Error); + } + + const heartbeat = await createDeviceHeartbeat({ + device_id: payload.device_id, + timestamp: eventTs.toISOString(), + firmware_version: payload.firmware_version, + network_strength: payloadNetworkStrength, + battery_level: payloadBattery, + state: payload.state, + payload_json: { + network_strength_raw: payload.network_strength, + battery_level_raw: payload.battery_level, + state: payload.state, + firmware_version: payload.firmware_version, + timestamp: payload.timestamp, + request_id: (req as unknown as { requestId: string }).requestId + } + }); + + await patchDevice(payload.device_id, { + last_seen_at: heartbeat.timestamp, + firmware_version: payload.firmware_version || device.firmware_version + }); + + res.json( + successResponse(req, { + heartbeat_id: heartbeat.id, + device_id: heartbeat.device_id, + request_id: req.requestId, + server_time: heartbeat.received_at + }) + ); +}); + +router.post("/commands/ack", requireDeviceToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as CommandAckInput; + if (!payload || !payload.command_id || !payload.device_id || !payload.status) { + return next(new ApiError("BAD_REQUEST", "command_id, device_id, status are required", 400)); + } + + if (!["delivered", "failed", "timeout"].includes(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be delivered, failed, or timeout", 400)); + } + + const device = await getDeviceById(payload.device_id); + if (!device) { + return next(new ApiError("NOT_FOUND", "device not found", 404)); + } + + const updated = await acknowledgeDeviceCommand({ + device_id: device.id, + command_id: payload.command_id, + status: payload.status, + reason: payload.reason, + result_payload: payload.result_payload + }); + if (!updated) { + return next(new ApiError("NOT_FOUND", "command not found", 404)); + } + + res.json( + successResponse(req, { + command_id: updated.id, + device_id: updated.device_id, + status: updated.status, + acknowledged_at: updated.acknowledged_at + }) + ); +}); + +export default router; diff --git a/src/routes/integrations.ts b/src/routes/integrations.ts new file mode 100644 index 0000000..1fc4e38 --- /dev/null +++ b/src/routes/integrations.ts @@ -0,0 +1,374 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import { ApiError } from "../shared/errors"; +import { successResponse } from "../shared/middleware/errorMiddleware"; +import { readIdempotency, writeIdempotency } from "../shared/idempotency/idempotencyStore"; +import { + addTransactionEvent, + findTransactionByPartnerReference, + getTransactionEvents, + updateTransactionStatus +} from "../shared/store/transactionStore"; +import { emitTransactionPaid } from "../shared/events/transactionEvents"; +import { env } from "../config/env"; + +const router = Router(); + +type CallbackPayload = { + partner_reference?: string; + partner_txn_id?: string; + amount?: number; + currency?: string; + status?: string; + payment_status?: string; + paid_at?: string; + merchant_id?: string; + terminal_id?: string; + signature?: string; +}; + +type PaymentStatus = "paid" | "failed" | "expired"; +type CallbackStatus = { + status: PaymentStatus; + reason?: "UNKNOWN_STATUS"; +}; + +type CallbackParsed = { + partnerReference: string; + partnerTxnId?: string; + status: CallbackStatus; + amount: number; + paidAt?: string; + currency: string; +}; + +type CallbackResponse = { + status: "accepted"; + event_id: string; + transaction_id: string | null; + request_id: string; + timestamp: string; + note?: string; + reason?: "AMOUNT_MISMATCH" | "TRANSACTION_NOT_FOUND" | "UNKNOWN_STATUS" | "STATE_TRANSITION_REJECTED"; +}; + +type CallbackStoredEntry = { + response: CallbackResponse; + transaction_id?: string; +}; + +function parsePaymentStatus(rawStatus: string | undefined): CallbackStatus { + const normalized = String(rawStatus || "").toLowerCase(); + + if (["paid", "success", "successful", "settled", "completed"].includes(normalized)) { + return { status: "paid" }; + } + + if (["failed", "declined", "rejected", "error", "cancelled"].includes(normalized)) { + return { status: "failed" }; + } + + if (["expired", "timeout", "stale"].includes(normalized)) { + return { status: "expired" }; + } + + return { status: "failed", reason: "UNKNOWN_STATUS" }; +} + +function verifySignature(payload: CallbackPayload, signature: string | undefined) { + if (!signature) { + throw new ApiError("WEBHOOK_SIGNATURE_INVALID", "missing X-Partner-Signature", 401); + } + + const { signature: _ignoredSignature, ...signaturePayload } = payload; + const secret = env.INTEGRATION_WEBHOOK_SECRET; + const expected = createHmac("sha256", secret) + .update(JSON.stringify(signaturePayload)) + .digest("hex"); + + const a = Buffer.from(signature.toLowerCase(), "utf8"); + const b = Buffer.from(expected.toLowerCase(), "utf8"); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new ApiError("WEBHOOK_SIGNATURE_INVALID", "invalid X-Partner-Signature", 401); + } +} + +function parseCallback(payload: CallbackPayload): CallbackParsed { + const partnerReference = payload.partner_reference; + if (!partnerReference) { + throw new ApiError("CALLBACK_PARTNER_DATA_INVALID", "partner_reference is required", 400); + } + + const amount = Number(payload.amount || 0); + if (!Number.isFinite(amount) || amount <= 0) { + throw new ApiError("CALLBACK_PARTNER_DATA_INVALID", "amount must be > 0", 400); + } + + const parsed: CallbackParsed = { + partnerReference, + partnerTxnId: payload.partner_txn_id, + status: parsePaymentStatus(payload.payment_status || payload.status), + amount, + paidAt: payload.paid_at, + currency: typeof payload.currency === "string" && payload.currency.length > 0 ? payload.currency.toUpperCase() : "IDR" + }; + + return parsed; +} + +function makeIdempotencyKey(payload: CallbackParsed, headerValue: string | undefined) { + if (headerValue) { + return headerValue; + } + + return `${payload.partnerReference}:${payload.partnerTxnId || ""}:${payload.status.status}`; +} + +function buildCallbackResponse( + req: Request, + transactionId: string | null, + eventId: string, + note?: CallbackResponse["note"], + reason?: CallbackResponse["reason"] +): CallbackResponse { + const response: CallbackResponse = { + status: "accepted", + event_id: eventId, + transaction_id: transactionId, + request_id: req.requestId, + timestamp: new Date().toISOString() + }; + + if (note) { + response.note = note; + } + + if (reason) { + response.reason = reason; + } + + return response; +} + +function writeCallbackResult(idempotencyKey: string, response: CallbackResponse, transactionId: string | null) { + writeIdempotency( + "callback.processing", + idempotencyKey, + { response, transaction_id: transactionId }, + env.IDEMPOTENCY_TTL_MS + ); +} + +async function makeResponseEventId(txId: string, fallbackTag: string) { + const events = await getTransactionEvents(txId); + return events.at(-1)?.id || `${fallbackTag}_${Date.now()}`; +} + +router.post("/qris/callback", async (req: Request, res: Response, next: NextFunction) => { + const incoming = req.body as CallbackPayload; + const signature = req.header("X-Partner-Signature"); + let parsed: CallbackParsed; + + try { + verifySignature(incoming, signature); + parsed = parseCallback(incoming); + } catch (error) { + return next(error); + } + + const idempotencyKey = makeIdempotencyKey(parsed, req.header("Idempotency-Key") || undefined); + const cache = readIdempotency("callback.processing", idempotencyKey); + + if (cache && typeof cache === "object" && "response" in cache) { + const cached = cache as CallbackStoredEntry; + const transactionId = typeof cached.transaction_id === "string" ? cached.transaction_id : null; + + if (transactionId) { + await addTransactionEvent({ + transaction_id: transactionId, + event_type: "CALLBACK_DUPLICATE", + source: "webhook", + payload_json: { + idempotency_key: idempotencyKey, + source_payload: incoming + } + }); + } + + return res.json(successResponse(req, cached.response)); + } + + const tx = await findTransactionByPartnerReference(parsed.partnerReference); + + if (!tx) { + const response = buildCallbackResponse( + req, + null, + `callback_no_tx_${Date.now()}`, + "TRANSACTION_NOT_FOUND" + ); + writeCallbackResult(idempotencyKey, response, null); + return res.json(successResponse(req, response)); + } + + if (parsed.amount !== tx.amount) { + const response = buildCallbackResponse( + req, + tx.id, + `callback_amount_mismatch_${Date.now()}`, + "AMOUNT_MISMATCH" + ); + + await addTransactionEvent({ + transaction_id: tx.id, + event_type: "CALLBACK_REJECTED", + source: "webhook", + payload_json: { + idempotency_key: idempotencyKey, + received_amount: parsed.amount, + expected_amount: tx.amount + } + }); + + writeCallbackResult(idempotencyKey, response, tx.id); + return res.status(409).json(successResponse(req, response)); + } + + await addTransactionEvent({ + transaction_id: tx.id, + event_type: "CALLBACK_RECEIVED", + source: "webhook", + payload_json: { + idempotency_key: idempotencyKey, + partner_reference: parsed.partnerReference, + partner_txn_id: parsed.partnerTxnId, + candidate_status: parsed.status.status, + status_reason: parsed.status.reason, + partner_payload: incoming + } + }); + + const eventContext = { + partner_reference: parsed.partnerReference, + partner_txn_id: parsed.partnerTxnId, + idempotency_key: idempotencyKey, + candidate_currency: parsed.currency, + reason: parsed.status.reason + }; + + if (parsed.status.status === "paid") { + const wasPaid = tx.status === "paid"; + let updated; + try { + updated = await updateTransactionStatus(tx.id, "paid", { + source: "webhook", + eventContext, + paid_at: parsed.paidAt + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { + const response = buildCallbackResponse( + req, + tx.id, + `callback_transition_${Date.now()}`, + "transaction state transition rejected" + ); + writeCallbackResult(idempotencyKey, response, tx.id); + return res.status(409).json(successResponse(req, response)); + } + + throw error; + } + + if (!wasPaid) { + emitTransactionPaid({ + transaction_id: updated.id, + merchant_id: updated.merchant_id, + outlet_id: updated.outlet_id, + terminal_id: updated.terminal_id, + device_id: updated.device_id, + amount: updated.amount, + currency: updated.currency, + partner_reference: updated.partner_reference, + paid_at: updated.paid_at + }); + await addTransactionEvent({ + transaction_id: updated.id, + event_type: "PUSH_QUEUED", + source: "system", + payload_json: { + event_type: "transaction.paid", + transaction_id: updated.id, + partner_reference: updated.partner_reference, + device_id: updated.device_id + } + }); + } + + const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event")); + writeCallbackResult(idempotencyKey, response, updated.id); + return res.json(successResponse(req, response)); + } + + if (parsed.status.status === "expired") { + let updated; + try { + updated = await updateTransactionStatus(tx.id, "expired", { + source: "webhook", + eventContext, + expired_at: new Date().toISOString() + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { + const response = buildCallbackResponse( + req, + tx.id, + `callback_transition_${Date.now()}`, + "transaction state transition rejected" + ); + writeCallbackResult(idempotencyKey, response, tx.id); + return res.status(409).json(successResponse(req, response)); + } + + throw error; + } + + const response = buildCallbackResponse(req, updated.id, await makeResponseEventId(updated.id, "tx_event")); + writeCallbackResult(idempotencyKey, response, updated.id); + return res.json(successResponse(req, response)); + } + + let updated; + try { + updated = await updateTransactionStatus(tx.id, "failed", { + source: "webhook", + eventContext + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("INVALID_TRANSACTION_STATE_TRANSITION")) { + const response = buildCallbackResponse( + req, + tx.id, + `callback_transition_${Date.now()}`, + "transaction state transition rejected" + ); + writeCallbackResult(idempotencyKey, response, tx.id); + return res.status(409).json(successResponse(req, response)); + } + + throw error; + } + + const response = buildCallbackResponse( + req, + updated.id, + await makeResponseEventId(updated.id, "tx_event"), + undefined, + parsed.status.reason + ); + + writeCallbackResult(idempotencyKey, response, updated.id); + return res.json(successResponse(req, response)); +}); + +export default router; diff --git a/src/shared/db/pool.ts b/src/shared/db/pool.ts new file mode 100644 index 0000000..de33f1a --- /dev/null +++ b/src/shared/db/pool.ts @@ -0,0 +1,215 @@ +import { Pool, type PoolClient } from "pg"; +import { env } from "../../config/env"; + +type PoolConfigWithConnectionString = { + connectionString: string; +}; + +type PoolConfigWithCredentials = { + host: string; + port: number; + user: string; + password: string; + database: string; +}; + +let pool: Pool | null = null; + +function buildPoolConfig(): PoolConfigWithConnectionString | PoolConfigWithCredentials { + if (env.DATABASE_URL) { + return { + connectionString: env.DATABASE_URL + }; + } + + return { + host: env.PGHOST, + port: env.PGPORT, + user: env.PGUSER, + password: env.PGPASSWORD, + database: env.PGDATABASE + }; +} + +export function getPool(): Pool { + if (!pool) { + const config = buildPoolConfig(); + + pool = new Pool(config); + } + + return pool; +} + +export async function withClient(work: (client: PoolClient) => Promise): Promise { + const client = await getPool().connect(); + try { + return await work(client); + } finally { + client.release(); + } +} + +export async function ensureSchema() { + const pool = getPool(); + await pool.query(MIGRATIONS_SQL); +} + +const MIGRATIONS_SQL = ` +BEGIN; + +CREATE TABLE IF NOT EXISTS merchants ( + id TEXT PRIMARY KEY, + merchant_code TEXT NOT NULL UNIQUE, + legal_name TEXT NOT NULL, + brand_name TEXT, + settlement_account_reference TEXT, + settlement_account_type TEXT, + payout_mode TEXT NOT NULL DEFAULT 'merchant_direct' CHECK (payout_mode IN ('merchant_direct', 'manual')), + fee_profile_id TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + onboarding_status TEXT NOT NULL DEFAULT 'pending' CHECK (onboarding_status IN ('pending', 'approved', 'rejected')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS outlets ( + id TEXT PRIMARY KEY, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + outlet_code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + address TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS terminals ( + id TEXT PRIMARY KEY, + outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, + terminal_code TEXT NOT NULL UNIQUE, + qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')), + partner_reference TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + device_code TEXT NOT NULL UNIQUE, + serial_number TEXT, + vendor TEXT, + model TEXT, + 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, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + last_seen_at TIMESTAMPTZ, + firmware_version TEXT, + created_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 TABLE IF NOT EXISTS device_bindings ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, + terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE, + active_flag BOOLEAN NOT NULL DEFAULT false, + bound_at TIMESTAMPTZ NOT NULL, + unbound_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_device_active_binding ON device_bindings (device_id) WHERE active_flag = TRUE; +CREATE INDEX IF NOT EXISTS idx_device_bindings_terminal_active ON device_bindings (terminal_id, active_flag); + +CREATE TABLE IF NOT EXISTS device_heartbeats ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + timestamp TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL, + firmware_version TEXT, + network_strength INTEGER, + battery_level INTEGER, + state TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_device_heartbeats_device ON device_heartbeats (device_id, received_at DESC); + +CREATE TABLE IF NOT EXISTS device_commands ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, + command TEXT NOT NULL, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'accepted' CHECK (status IN ('accepted', 'delivered', 'failed', 'timeout')), + requested_at TIMESTAMPTZ NOT NULL, + acknowledged_at TIMESTAMPTZ, + result_payload_json JSONB, + reason TEXT +); + +CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC); + +CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + transaction_code TEXT NOT NULL UNIQUE, + merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, + outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, + terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE, + device_id TEXT REFERENCES devices (id) ON DELETE SET NULL, + qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic')), + initiation_mode TEXT NOT NULL DEFAULT 'static' CHECK (initiation_mode IN ('static', 'manual', 'dynamic_api', 'dynamic_mqtt')), + partner_reference TEXT NOT NULL UNIQUE, + amount NUMERIC(20,2) NOT NULL, + currency TEXT NOT NULL DEFAULT 'IDR', + status TEXT NOT NULL DEFAULT 'initiated' CHECK (status IN ('initiated', 'awaiting_payment', 'paid', 'failed', 'expired', 'reversed')), + created_at TIMESTAMPTZ NOT NULL, + paid_at TIMESTAMPTZ, + expired_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_transactions_partner_ref ON transactions (partner_reference); +CREATE INDEX IF NOT EXISTS idx_transactions_merchant_created ON transactions (merchant_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_transactions_status_created ON transactions (status, created_at DESC); + +CREATE TABLE IF NOT EXISTS transaction_events ( + id TEXT PRIMARY KEY, + transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + source TEXT NOT NULL, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_transaction_events_tx ON transaction_events (transaction_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, + device_id TEXT REFERENCES devices (id) ON DELETE SET NULL, + delivery_channel TEXT NOT NULL DEFAULT 'mqtt' CHECK (delivery_channel IN ('mqtt')), + payload_type TEXT NOT NULL DEFAULT 'payment_success' CHECK (payload_type IN ('payment_success')), + delivery_status TEXT NOT NULL CHECK (delivery_status IN ('queued', 'sent', 'acknowledged', 'failed', 'retrying')), + retry_count INT NOT NULL DEFAULT 0, + ack_status TEXT NOT NULL DEFAULT 'not_needed' CHECK (ack_status IN ('pending', 'received', 'not_supported', 'not_needed')), + event_id TEXT NOT NULL, + reason TEXT, + payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + sent_at TIMESTAMPTZ, + ack_at TIMESTAMPTZ, + next_retry_at TIMESTAMPTZ, + CONSTRAINT notifications_unique_tx_event UNIQUE (transaction_id, event_id) +); + +CREATE INDEX IF NOT EXISTS idx_notifications_device_status ON notifications (device_id, delivery_status); +CREATE INDEX IF NOT EXISTS idx_notifications_status_created ON notifications (delivery_status, created_at DESC); + +COMMIT; +`; diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts new file mode 100644 index 0000000..7a243ed --- /dev/null +++ b/src/shared/errors/index.ts @@ -0,0 +1,52 @@ +export type ErrorCode = + | "BAD_REQUEST" + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "CONFLICT" + | "INTERNAL_ERROR" + | "DEVICE_UNAUTHORIZED" + | "DUPLICATE_REQUEST" + | "TRACE_MISSING" + | "WEBHOOK_SIGNATURE_INVALID" + | "TRANSACTION_NOT_FOUND" + | "PAYMENT_STATUS_INVALID" + | "DUPLICATE_WEBHOOK" + | "CALLBACK_PARTNER_DATA_INVALID" + | "IDEMPOTENCY_MISSING_KEY" + | "NOTIFICATION_DEVICE_UNAVAILABLE" + | "NOTIFICATION_NO_ACTIVE_BINDING" + | "NOTIFICATION_PUBLISH_FAILED" + | "NOTIFICATION_RETRY_EXHAUSTED"; + +export interface ApiErrorShape { + code: ErrorCode; + message: string; + details?: Record; + request_id: string; + timestamp: string; +} + +export class ApiError extends Error { + statusCode: number; + code: ErrorCode; + details?: Record; + + constructor(code: ErrorCode, message: string, statusCode = 400, details?: Record) { + super(message); + this.code = code; + this.statusCode = statusCode; + this.details = details; + Error.captureStackTrace(this, this.constructor); + } +} + +export function errorEnvelope(error: ApiError, requestId: string): ApiErrorShape { + return { + code: error.code, + message: error.message, + details: error.details, + request_id: requestId, + timestamp: new Date().toISOString() + }; +} diff --git a/src/shared/events/transactionEvents.ts b/src/shared/events/transactionEvents.ts new file mode 100644 index 0000000..e3567d3 --- /dev/null +++ b/src/shared/events/transactionEvents.ts @@ -0,0 +1,87 @@ +export type TransactionPaidPayload = { + transaction_id: string; + merchant_id: string; + outlet_id: string; + terminal_id: string; + device_id: string | null | undefined; + amount: number; + currency: string; + partner_reference: string; + paid_at?: string; +}; + +type TransactionPaidInternalEvent = { + id: string; + event_type: "transaction.paid"; + transaction_id: string; + payload_json: TransactionPaidPayload; + created_at: string; +}; + +export type TransactionPaidEvent = TransactionPaidInternalEvent; + +import { randomUUID } from "node:crypto"; + +const transactionPaidEvents = new Map(); +const transactionPaidIndex = new Map(); +const transactionPaidSubscribers = new Set<(event: TransactionPaidEvent) => void>(); + +function nowIso() { + return new Date().toISOString(); +} + +function cloneInternalEvent(event: TransactionPaidInternalEvent): TransactionPaidInternalEvent { + return { + ...event, + payload_json: { ...event.payload_json } + }; +} + +export function subscribeTransactionPaid(handler: (event: TransactionPaidEvent) => void) { + transactionPaidSubscribers.add(handler); + return () => transactionPaidSubscribers.delete(handler); +} + +export function emitTransactionPaid(payload: TransactionPaidPayload): TransactionPaidEvent { + const existingId = transactionPaidIndex.get(payload.transaction_id); + if (existingId) { + const events = transactionPaidEvents.get(payload.transaction_id) || []; + const existing = events.find((event) => event.id === existingId); + if (existing) { + return cloneInternalEvent(existing); + } + } + + const event: TransactionPaidInternalEvent = { + id: randomUUID(), + event_type: "transaction.paid", + transaction_id: payload.transaction_id, + payload_json: payload, + created_at: nowIso() + }; + + const bucket = transactionPaidEvents.get(payload.transaction_id) || []; + bucket.push(event); + transactionPaidEvents.set(payload.transaction_id, bucket); + transactionPaidIndex.set(payload.transaction_id, event.id); + + const frozen = cloneInternalEvent(event); + for (const listener of Array.from(transactionPaidSubscribers)) { + listener(frozen); + } + + return frozen; +} + +export function getTransactionPaidEvents(transactionId?: string): TransactionPaidEvent[] { + if (transactionId) { + return (transactionPaidEvents.get(transactionId) || []).map(cloneInternalEvent); + } + + return Array.from(transactionPaidEvents.values()).flatMap((bucket) => bucket.map(cloneInternalEvent)); +} + +export function getTransactionPaidEventByTransactionId(transactionId: string): TransactionPaidEvent | null { + const events = transactionPaidEvents.get(transactionId) || []; + return events.length > 0 ? cloneInternalEvent(events[events.length - 1]) : null; +} diff --git a/src/shared/idempotency/idempotencyStore.ts b/src/shared/idempotency/idempotencyStore.ts new file mode 100644 index 0000000..724af3b --- /dev/null +++ b/src/shared/idempotency/idempotencyStore.ts @@ -0,0 +1,39 @@ +interface IdempotentEntry { + key: string; + scope: string; + value: unknown; + expiresAt: number; +} + +const store = new Map(); + +export function makeIdempotencyKey(scope: string, key: string): string { + return `${scope}:${key}`; +} + +export function readIdempotency(scope: string, key: string): unknown | null { + const entry = store.get(makeIdempotencyKey(scope, key)); + if (!entry) { + return null; + } + + if (entry.expiresAt < Date.now()) { + store.delete(makeIdempotencyKey(scope, key)); + return null; + } + + return entry.value; +} + +export function writeIdempotency(scope: string, key: string, value: unknown, ttlMs: number): void { + store.set(makeIdempotencyKey(scope, key), { + key, + scope, + value, + expiresAt: Date.now() + ttlMs + }); +} + +export function clearIdempotency(scope: string, key: string): void { + store.delete(makeIdempotencyKey(scope, key)); +} diff --git a/src/shared/middleware/auth.ts b/src/shared/middleware/auth.ts new file mode 100644 index 0000000..d200801 --- /dev/null +++ b/src/shared/middleware/auth.ts @@ -0,0 +1,40 @@ +import { NextFunction, Request, Response } from "express"; +import { ApiError } from "../errors"; +import { env } from "../../config/env"; + +function extractAdminToken(req: Request) { + const raw = req.header("authorization") || ""; + if (raw.startsWith("Bearer ")) { + return raw.slice(7); + } + + return raw || req.header("x-admin-token") || ""; +} + +export function requireAdminToken(req: Request, _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) { + return next(new ApiError("UNAUTHORIZED", "Invalid admin token", 401)); + } + + return next(); +} + +export function requireDeviceToken(req: Request, _res: Response, next: NextFunction) { + const raw = req.header("authorization") || ""; + const token = raw.startsWith("Bearer ") ? raw.slice(7) : raw; + + if (!token) { + return next(new ApiError("UNAUTHORIZED", "Missing device bearer token", 401)); + } + + if (token !== env.DEVICE_TOKEN) { + return next(new ApiError("UNAUTHORIZED", "Invalid device token", 401)); + } + + return next(); +} diff --git a/src/shared/middleware/errorMiddleware.ts b/src/shared/middleware/errorMiddleware.ts new file mode 100644 index 0000000..65e7a55 --- /dev/null +++ b/src/shared/middleware/errorMiddleware.ts @@ -0,0 +1,37 @@ +import { NextFunction, Request, Response } from "express"; +import { ApiError, errorEnvelope } from "../errors"; + +export interface EnvelopeSuccess { + data: T; + request_id: string; + timestamp: string; +} + +export function successResponse(req: Request, data: T): EnvelopeSuccess { + return { + data, + request_id: req.requestId, + timestamp: new Date().toISOString() + }; +} + +export function handleErrors(err: Error, req: Request, res: Response, _next: NextFunction) { + if (err instanceof ApiError) { + res.status(err.statusCode).json( + errorEnvelope( + { + ...err + } as ApiError, + req.requestId + ) + ); + return; + } + + res.status(500).json({ + code: "INTERNAL_ERROR", + message: err.message || "Unexpected server error", + request_id: req.requestId, + timestamp: new Date().toISOString() + }); +} diff --git a/src/shared/middleware/idempotency.ts b/src/shared/middleware/idempotency.ts new file mode 100644 index 0000000..da37e48 --- /dev/null +++ b/src/shared/middleware/idempotency.ts @@ -0,0 +1,78 @@ +import { NextFunction, Request, Response } from "express"; +import { ApiError } from "../errors"; +import { readIdempotency, writeIdempotency } from "../idempotency/idempotencyStore"; +import { env } from "../../config/env"; +import { successResponse } from "./errorMiddleware"; + +interface IdempotencyOptions { + scope: string; + ttlMs?: number; + required?: boolean; +} + +export function idempotency(options: IdempotencyOptions) { + return function idempotencyMiddleware(req: Request, _res: Response, next: NextFunction) { + const idempotencyKey = req.header("idempotency-key"); + if (!idempotencyKey) { + if (options.required === false) { + return next(); + } + + return next(new ApiError("DUPLICATE_REQUEST", "Missing Idempotency-Key", 400)); + } + + const cached = readIdempotency(options.scope, idempotencyKey); + if (cached) { + const cachedPayload = (cached as { response?: unknown; statusCode?: number; payload?: unknown }).response ?? cached; + const cachedStatus = (cached as { statusCode?: number }).statusCode || 200; + const payload = (() => { + if ( + cachedPayload && + typeof cachedPayload === "object" && + "data" in cachedPayload && + "request_id" in cachedPayload && + "timestamp" in cachedPayload + ) { + const typed = cachedPayload as Record & { request_id?: string; timestamp?: string }; + typed.request_id = req.requestId; + typed.timestamp = new Date().toISOString(); + return typed; + } + return cachedPayload; + })(); + return _res.status(cachedStatus).json(payload); + } + + req.body = { ...(req.body || {}), __idempotencyKey: idempotencyKey } as Request["body"]; + + const originalJson = _res.json.bind(_res); + const originalStatus = _res.status.bind(_res); + let statusCode = 200; + _res.status = function statusWithStore(code: number) { + statusCode = code; + return originalStatus(code); + }; + _res.json = function jsonWithStore(payload: unknown) { + const responsePayload = payload && + typeof payload === "object" && + "data" in payload && + "request_id" in payload && + "timestamp" in payload + ? successResponse(req, (payload as { data: unknown }).data) + : payload; + writeIdempotency( + options.scope, + idempotencyKey, + { + response: responsePayload, + statusCode, + at: Date.now() + }, + options.ttlMs || env.IDEMPOTENCY_TTL_MS + ); + return originalJson(responsePayload); + }; + + next(); + }; +} diff --git a/src/shared/middleware/requestContext.ts b/src/shared/middleware/requestContext.ts new file mode 100644 index 0000000..56198fa --- /dev/null +++ b/src/shared/middleware/requestContext.ts @@ -0,0 +1,25 @@ +import { randomUUID } from "node:crypto"; +import { Request, Response, NextFunction } from "express"; +import { env } from "../../config/env"; + +declare module "express-serve-static-core" { + interface Request { + requestId: string; + traceId?: string; + } +} + +export function requestContext(req: Request, _res: Response, next: NextFunction) { + const requestId = + (req.header(env.TRACE_HEADER) as string | undefined) || + req.header("x-trace-id") || + randomUUID(); + + const traceId = + (req.header("x-trace-id") as string | undefined) || requestId; + + req.requestId = requestId; + req.traceId = traceId; + + next(); +} diff --git a/src/shared/orchestrators/notificationOrchestrator.ts b/src/shared/orchestrators/notificationOrchestrator.ts new file mode 100644 index 0000000..06ec6a9 --- /dev/null +++ b/src/shared/orchestrators/notificationOrchestrator.ts @@ -0,0 +1,340 @@ +import { getActiveBindingByDevice, getActiveBindingByTerminal } from "../store/bindingStore"; +import { + NotificationDeliveryStatus, + NotificationEntity, + createNotification, + getNotificationByTransactionAndEvent, + getNotificationByTransactionId, + listNotifications, + toNotificationPayload, + updateNotification +} from "../store/notificationStore"; +import { getMerchantById } from "../store/merchantStore"; +import { getTransactionById, listTransactions, toTransactionPayload, TransactionEntity } from "../store/transactionStore"; +import { buildPaymentSuccessPayload, publishPaymentSuccess, MqttPublishResult } from "../services/mqttPublisher"; +import type { TransactionPaidEvent, TransactionPaidPayload } from "../events/transactionEvents"; +import { subscribeTransactionPaid } from "../events/transactionEvents"; +import { env } from "../../config/env"; + +type ResolveDeviceResult = { + deviceId: string; + source: "tx_device" | "binding_device"; +} | null; + +const RETRY_INTERVAL_MS = [ + env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000, + (env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000) * 2, + (env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000) * 4 +]; +const RETRY_POLL_INTERVAL_MS = 5000; +const MAX_RETRY = 3; + +async function resolveNotificationDevice(payload: TransactionPaidPayload): Promise { + if (payload.device_id) { + const binding = await getActiveBindingByDevice(payload.device_id); + if (binding && binding.terminal_id === payload.terminal_id) { + return { + deviceId: payload.device_id, + source: "tx_device" + }; + } + } + + const terminalBinding = await getActiveBindingByTerminal(payload.terminal_id); + if (!terminalBinding) { + return null; + } + + return { + deviceId: terminalBinding.device_id, + source: "binding_device" + }; +} + +function buildNotificationPayload(txPayload: TransactionPaidPayload, deviceId: string) { + return { + message_type: "payment_success", + transaction_id: txPayload.transaction_id, + merchant_id: txPayload.merchant_id, + terminal_id: txPayload.terminal_id, + amount: txPayload.amount, + currency: txPayload.currency, + paid_at: txPayload.paid_at, + partner_reference: txPayload.partner_reference, + target_device_id: deviceId + }; +} + +function resolveDeliveryStatus(bindingResolved: ResolveDeviceResult | null): NotificationDeliveryStatus { + return bindingResolved ? "queued" : "failed"; +} + +function resolveFailureReason(bindingResolved: ResolveDeviceResult | null) { + if (!bindingResolved) { + return "NOTIFICATION_NO_ACTIVE_BINDING"; + } + return undefined; +} + +function makeNextRetryDate(retryCount: number) { + const intervalMs = RETRY_INTERVAL_MS[Math.min(retryCount, RETRY_INTERVAL_MS.length - 1)] || 60000; + return new Date(Date.now() + intervalMs).toISOString(); +} + +async function getNotificationMerchantName(merchantId: string): Promise { + const merchant = await getMerchantById(merchantId); + return merchant?.brand_name || merchant?.legal_name || merchantId; +} + +function mapMqttFailureState( + retryCount: number, + reason?: string +): { status: NotificationDeliveryStatus; retry_count: number; next_retry_at?: string; reason?: string } { + const nextRetryCount = retryCount + 1; + if (nextRetryCount >= MAX_RETRY) { + return { + status: "failed", + retry_count: nextRetryCount, + reason: reason || "NOTIFICATION_RETRY_EXHAUSTED" + }; + } + + return { + status: "retrying", + retry_count: nextRetryCount, + next_retry_at: makeNextRetryDate(retryCount), + reason + }; +} + +async function markNotificationSent(notification: NotificationEntity, publishResult: MqttPublishResult) { + await updateNotification(notification.id, { + delivery_status: "sent", + retry_count: notification.retry_count, + ack_status: "not_supported", + sent_at: publishResult.publishedAt + }); +} + +async function markNotificationFailed(notification: NotificationEntity, publishResult: MqttPublishResult) { + const next = mapMqttFailureState(notification.retry_count, publishResult.reason); + await updateNotification(notification.id, { + delivery_status: next.status, + retry_count: next.retry_count, + reason: next.reason, + ack_status: "not_supported", + next_retry_at: next.next_retry_at + }); +} + +async function markNoDeviceFailure(notification: NotificationEntity) { + await updateNotification(notification.id, { + delivery_status: "failed", + retry_count: 0, + reason: "NOTIFICATION_NO_ACTIVE_BINDING", + ack_status: "not_needed" + }); +} + +async function resolveNotificationFromTransaction(notification: NotificationEntity): Promise { + const tx = await getTransactionById(notification.transaction_id); + if (!tx) { + return null; + } + + return { + transaction_id: tx.id, + merchant_id: tx.merchant_id, + outlet_id: tx.outlet_id, + terminal_id: tx.terminal_id, + device_id: tx.device_id, + amount: tx.amount, + currency: tx.currency, + paid_at: tx.paid_at, + partner_reference: tx.partner_reference + }; +} + +async function getMqttPayloadFromNotification(notification: NotificationEntity, paidAt?: string) { + return buildPaymentSuccessPayload({ + transaction_id: String(notification.payload_json.transaction_id || ""), + merchant_id: String(notification.payload_json.merchant_id || ""), + merchant_name: await getNotificationMerchantName(String(notification.payload_json.merchant_id || "")), + device_id: String(notification.device_id || ""), + amount: Number(notification.payload_json.amount || 0), + currency: String(notification.payload_json.currency || "IDR"), + paid_at: paidAt, + partner_reference: String(notification.payload_json.partner_reference || ""), + event_id: notification.event_id + }); +} + +async function publishNotificationNow(notification: NotificationEntity, eventPayload: TransactionPaidPayload | null) { + if (!notification.device_id) { + const resolvedFromTransaction = await resolveNotificationFromTransaction(notification); + if (!resolvedFromTransaction) { + await markNoDeviceFailure(notification); + return; + } + + const resolved = await resolveNotificationDevice(resolvedFromTransaction); + if (!resolved) { + await markNoDeviceFailure(notification); + return; + } + + notification = await updateNotification(notification.id, { + device_id: resolved.deviceId, + delivery_status: "queued", + reason: undefined + }); + } + + const effectivePayload = eventPayload ?? (await resolveNotificationFromTransaction(notification)); + + if (!effectivePayload) { + await markNoDeviceFailure(notification); + return; + } + + const mqttPayload = await buildPaymentSuccessPayload({ + transaction_id: effectivePayload.transaction_id, + merchant_id: effectivePayload.merchant_id, + merchant_name: await getNotificationMerchantName(effectivePayload.merchant_id), + device_id: notification.device_id || String(effectivePayload.device_id || ""), + amount: effectivePayload.amount, + currency: effectivePayload.currency, + paid_at: effectivePayload.paid_at, + partner_reference: effectivePayload.partner_reference, + event_id: notification.event_id + }); + + const result = await publishPaymentSuccess(mqttPayload); + if (!result.ok) { + await markNotificationFailed(notification, result); + return; + } + + await markNotificationSent(notification, result); +} + +async function onTransactionPaid(event: TransactionPaidEvent) { + const payload: TransactionPaidPayload = event.payload_json; + + const existing = await getNotificationByTransactionAndEvent(payload.transaction_id, event.id); + if (existing) { + return; + } + + const resolved = await resolveNotificationDevice(payload); + const deliveryStatus = resolveDeliveryStatus(resolved); + + const created = await createNotification({ + transaction_id: payload.transaction_id, + device_id: resolved?.deviceId || null, + event_id: event.id, + delivery_status: deliveryStatus, + reason: resolveFailureReason(resolved), + payload_json: buildNotificationPayload(payload, resolved?.deviceId || ""), + ack_status: resolved ? "not_supported" : "not_needed" + }); + + await publishNotificationNow(created, payload); +} + +async function bootstrapNotificationForPaidTransaction(transaction: TransactionEntity) { + const existing = await getNotificationByTransactionId(transaction.id); + if (existing) { + return; + } + + const payload: TransactionPaidPayload = { + transaction_id: transaction.id, + merchant_id: transaction.merchant_id, + outlet_id: transaction.outlet_id, + terminal_id: transaction.terminal_id, + device_id: transaction.device_id, + amount: transaction.amount, + currency: transaction.currency, + partner_reference: transaction.partner_reference, + paid_at: transaction.paid_at + }; + + const eventId = `bootstrap_${transaction.id}`; + const resolved = await resolveNotificationDevice(payload); + const deliveryStatus = resolveDeliveryStatus(resolved); + + const created = await createNotification({ + transaction_id: transaction.id, + device_id: resolved?.deviceId || null, + event_id: eventId, + delivery_status: deliveryStatus, + reason: resolveFailureReason(resolved), + payload_json: buildNotificationPayload(payload, resolved?.deviceId || ""), + ack_status: resolved ? "not_supported" : "not_needed" + }); + + await publishNotificationNow(created, payload); +} + +async function seedPaidTransactions() { + const paidTransactions = await listTransactions({ status: "paid" }); + for (const tx of paidTransactions) { + await bootstrapNotificationForPaidTransaction(toTransactionPayload(tx)); + } +} + +async function processRetryCycle() { + const now = new Date().toISOString(); + const retrying = await listNotifications({ + delivery_status: "retrying" + }); + + const due = retrying.filter((notification) => { + if (!notification.next_retry_at) { + return true; + } + + return notification.next_retry_at <= now; + }); + + for (const notification of due) { + await publishNotificationNow(notification, null); + } +} + +export async function retryNotificationByTransactionId(transactionId: string): Promise { + const tx = await getTransactionById(transactionId); + if (!tx) { + return null; + } + + if (tx.status !== "paid") { + throw new Error("NOTIFICATION_PUBLISH_CONDITION"); + } + + const notification = await getNotificationByTransactionId(transactionId); + if (!notification) { + return null; + } + + if (notification.delivery_status === "acknowledged") { + return toNotificationPayload(notification); + } + + await publishNotificationNow(notification, null); + const updated = await getNotificationByTransactionId(transactionId); + return updated ? toNotificationPayload(updated) : null; +} + +export function startNotificationOrchestrator() { + void seedPaidTransactions(); + + void subscribeTransactionPaid((event) => { + void onTransactionPaid(event); + }); + + setInterval(() => { + void processRetryCycle(); + }, RETRY_POLL_INTERVAL_MS); +} diff --git a/src/shared/services/mqttPublisher.ts b/src/shared/services/mqttPublisher.ts new file mode 100644 index 0000000..4cd733a --- /dev/null +++ b/src/shared/services/mqttPublisher.ts @@ -0,0 +1,99 @@ +import { env } from "../../config/env"; + +type PaymentSuccessPayload = { + message_type: "payment_success"; + device_id: string; + event_id: string; + transaction_id: string; + merchant_id: string; + merchant_name: string; + amount: number; + currency: string; + paid_at?: string; + partner_reference?: string; + audio_text: string; + display_text: string; +}; + +export type MqttPublishResult = { + ok: boolean; + topic: string; + qos: 1; + retained: false; + publishedAt: string; + reason?: string; + payload: PaymentSuccessPayload; +}; + +const forcedFailAll = String(env.MQTT_PUBLISH_FORCE_FAIL_ALL).toLowerCase() === "true"; +const forcedFailDevices = new Set( + String(env.MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS) + .split(",") + .map((item) => item.trim()) + .filter(Boolean) +); + +function shouldForceFail(deviceId: string): boolean { + return forcedFailAll || forcedFailDevices.has(deviceId); +} + +export function buildPaymentSuccessPayload( + input: { + transaction_id: string; + merchant_id: string; + merchant_name: string; + device_id: string; + amount: number; + currency: string; + paid_at?: string; + partner_reference?: string; + event_id: string; + } +): PaymentSuccessPayload { + const displayAmount = `${input.amount.toLocaleString("id-ID")}`; + + return { + message_type: "payment_success", + device_id: input.device_id, + event_id: input.event_id, + transaction_id: input.transaction_id, + merchant_id: input.merchant_id, + merchant_name: input.merchant_name, + amount: input.amount, + currency: input.currency, + paid_at: input.paid_at, + partner_reference: input.partner_reference, + audio_text: `Pembayaran diterima ${input.currency} ${displayAmount}`, + display_text: `Pembayaran diterima Rp${displayAmount}` + }; +} + +export function makePaymentSuccessTopic(deviceId: string) { + return `devices/${deviceId}/downlink/payment/success`; +} + +export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Promise { + const publishedAt = new Date().toISOString(); + const topic = makePaymentSuccessTopic(payload.device_id); + + if (shouldForceFail(payload.device_id)) { + return { + ok: false, + topic, + qos: 1, + retained: false, + publishedAt, + reason: "MQTT_PUBLISH_SIMULATED_FAILURE", + payload + }; + } + + return { + ok: true, + topic, + qos: 1, + retained: false, + publishedAt, + payload + }; +} diff --git a/src/shared/store/bindingStore.ts b/src/shared/store/bindingStore.ts new file mode 100644 index 0000000..4199035 --- /dev/null +++ b/src/shared/store/bindingStore.ts @@ -0,0 +1,153 @@ +import { randomUUID } from "node:crypto"; +import { getPool, withClient } from "../db/pool"; + +export interface DeviceBindingEntity { + id: string; + device_id: string; + merchant_id: string; + outlet_id: string; + terminal_id: string; + active_flag: boolean; + bound_at: string; + unbound_at?: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function mapBinding(row: any): DeviceBindingEntity { + return { + id: row.id, + device_id: row.device_id, + merchant_id: row.merchant_id, + outlet_id: row.outlet_id, + terminal_id: row.terminal_id, + active_flag: row.active_flag, + bound_at: row.bound_at, + unbound_at: row.unbound_at || undefined + }; +} + +export async function getActiveBindingByDevice(deviceId: string): Promise { + const { rows } = await getPool().query( + `SELECT * FROM device_bindings + WHERE device_id = $1 AND active_flag = TRUE + ORDER BY bound_at DESC + LIMIT 1`, + [deviceId] + ); + + return rows[0] ? mapBinding(rows[0]) : null; +} + +export async function getActiveBindingByTerminal(terminalId: string): Promise { + const { rows } = await getPool().query( + `SELECT * FROM device_bindings + WHERE terminal_id = $1 AND active_flag = TRUE + ORDER BY bound_at DESC + LIMIT 1`, + [terminalId] + ); + + return rows[0] ? mapBinding(rows[0]) : null; +} + +export async function getBindingsByDeviceId(deviceId: string): Promise { + const { rows } = await getPool().query( + `SELECT * FROM device_bindings + WHERE device_id = $1 + ORDER BY bound_at DESC`, + [deviceId] + ); + return rows.map(mapBinding); +} + +export async function bindDevice(payload: { + device_id: string; + merchant_id: string; + outlet_id: string; + terminal_id: string; +}): Promise { + const now = nowIso(); + + const result = await withClient(async (client) => { + const existing = await client.query( + `SELECT * FROM device_bindings + WHERE device_id = $1 AND active_flag = TRUE + ORDER BY bound_at DESC + LIMIT 1`, + [payload.device_id] + ); + + const same = existing.rows[0] + ? mapBinding(existing.rows[0]) + : null; + if ( + same && + same.merchant_id === payload.merchant_id && + same.outlet_id === payload.outlet_id && + same.terminal_id === payload.terminal_id + ) { + return same; + } + + await client.query("BEGIN"); + try { + if (existing.rows[0]) { + await client.query( + `UPDATE device_bindings + SET active_flag = FALSE, unbound_at = $2 + WHERE id = $1`, + [same!.id, now] + ); + } + + const id = randomUUID(); + const inserted = await client.query( + `INSERT INTO device_bindings ( + id, + device_id, + merchant_id, + outlet_id, + terminal_id, + active_flag, + bound_at + ) VALUES ($1,$2,$3,$4,$5,TRUE,$6) + RETURNING *`, + [id, payload.device_id, payload.merchant_id, payload.outlet_id, payload.terminal_id, now] + ); + + await client.query("COMMIT"); + return mapBinding(inserted.rows[0]); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); + + return result; +} + +export async function unbindDevice(deviceId: string): Promise { + const now = nowIso(); + const { rows } = await getPool().query( + `UPDATE device_bindings + SET active_flag = FALSE, + unbound_at = $2 + WHERE device_id = $1 AND active_flag = TRUE + RETURNING *`, + [deviceId, now] + ); + + return rows[0] ? mapBinding(rows[0]) : null; +} + +export async function getBindingById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM device_bindings WHERE id = $1", [id]); + return rows[0] ? mapBinding(rows[0]) : null; +} + +export function toBindingPayload(binding: DeviceBindingEntity) { + return { ...binding }; +} diff --git a/src/shared/store/deviceCommandStore.ts b/src/shared/store/deviceCommandStore.ts new file mode 100644 index 0000000..dd3defb --- /dev/null +++ b/src/shared/store/deviceCommandStore.ts @@ -0,0 +1,147 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export type DeviceCommandStatus = "accepted" | "delivered" | "failed" | "timeout"; + +export interface DeviceCommandEntity { + id: string; + device_id: string; + command: string; + payload: Record; + status: DeviceCommandStatus; + requested_at: string; + acknowledged_at: string | null; + result_payload: Record | null; + reason: string | null; +} + +function nowIso() { + return new Date().toISOString(); +} + +function normalizeStatus(status: DeviceCommandStatus): DeviceCommandStatus { + if (status === "accepted" || status === "delivered" || status === "failed" || status === "timeout") { + return status; + } + return "accepted"; +} + +function mapCommand(row: any): DeviceCommandEntity { + return { + id: row.id, + device_id: row.device_id, + command: row.command, + payload: row.payload_json || {}, + status: row.status, + requested_at: row.requested_at, + acknowledged_at: row.acknowledged_at || null, + result_payload: row.result_payload_json || null, + reason: row.reason || null + }; +} + +export async function createDeviceCommand(payload: { + device_id: string; + command: string; + payload?: Record; + status?: DeviceCommandStatus; +}) { + const entity: DeviceCommandEntity = { + id: `cmd_${randomUUID()}`, + device_id: payload.device_id, + command: payload.command, + payload: payload.payload || {}, + status: normalizeStatus(payload.status || "accepted"), + requested_at: nowIso(), + acknowledged_at: null, + result_payload: null, + reason: null + }; + + const { rows } = await getPool().query( + `INSERT INTO device_commands ( + id, + device_id, + command, + payload_json, + status, + requested_at, + acknowledged_at, + result_payload_json, + reason + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, + [ + entity.id, + entity.device_id, + entity.command, + entity.payload, + entity.status, + entity.requested_at, + entity.acknowledged_at, + entity.result_payload, + entity.reason + ] + ); + + return mapCommand(rows[0]); +} + +export async function listDeviceCommands(deviceId: string): Promise { + const { rows } = await getPool().query( + "SELECT * FROM device_commands WHERE device_id = $1 ORDER BY requested_at DESC", + [deviceId] + ); + + return rows.map(mapCommand); +} + +export async function getDeviceCommandById( + deviceId: string, + commandId: string +): Promise { + const { rows } = await getPool().query( + "SELECT * FROM device_commands WHERE device_id = $1 AND id = $2", + [deviceId, commandId] + ); + + return rows[0] ? mapCommand(rows[0]) : null; +} + +export function toDeviceCommandPayload(command: DeviceCommandEntity) { + return mapCommand(command); +} + +export function toDeviceCommandPayloadBrief(command: DeviceCommandEntity) { + return { + command_id: command.id, + device_id: command.device_id, + command: command.command, + status: command.status, + requested_at: command.requested_at, + acknowledged_at: command.acknowledged_at + }; +} + +export async function acknowledgeDeviceCommand(payload: { + device_id: string; + command_id: string; + status: "delivered" | "failed" | "timeout"; + result_payload?: Record; + reason?: string; +}) { + const now = nowIso(); + + const { rows } = await getPool().query( + `UPDATE device_commands + SET status = $3, + acknowledged_at = $4, + result_payload_json = $5, + reason = $6 + WHERE device_id = $1 AND id = $2 + RETURNING *`, + [payload.device_id, payload.command_id, payload.status, now, payload.result_payload || null, payload.reason || null] + ); + + return rows[0] ? mapCommand(rows[0]) : null; +} diff --git a/src/shared/store/deviceStore.ts b/src/shared/store/deviceStore.ts new file mode 100644 index 0000000..ec56f93 --- /dev/null +++ b/src/shared/store/deviceStore.ts @@ -0,0 +1,152 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export interface DeviceEntity { + id: string; + device_code: string; + serial_number?: string; + vendor?: string; + model?: string; + communication_mode?: "static" | "mqtt" | "api"; + capability_profile_json?: Record; + auth_method?: string; + status: "active" | "inactive"; + last_seen_at?: string; + firmware_version?: string; + created_at: string; + updated_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function makeCode(id: string) { + return `d_${id.slice(0, 6)}`; +} + +function mapDevice(row: any): DeviceEntity { + return { + id: row.id, + device_code: row.device_code, + serial_number: row.serial_number || undefined, + vendor: row.vendor || undefined, + model: row.model || undefined, + communication_mode: row.communication_mode, + capability_profile_json: row.capability_profile_json || {}, + auth_method: row.auth_method || undefined, + status: row.status, + last_seen_at: row.last_seen_at || undefined, + firmware_version: row.firmware_version || undefined, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +export async function createDevice(payload: { + device_code?: string; + serial_number?: string; + vendor?: string; + model?: string; + communication_mode?: DeviceEntity["communication_mode"]; + capability_profile_json?: Record; + auth_method?: string; + status?: DeviceEntity["status"]; + firmware_version?: string; + last_seen_at?: string; +}): Promise { + const id = randomUUID(); + const now = nowIso(); + + const { rows } = await getPool().query( + `INSERT INTO devices ( + id, + device_code, + serial_number, + vendor, + model, + communication_mode, + capability_profile_json, + auth_method, + status, + last_seen_at, + firmware_version, + created_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING *`, + [ + id, + payload.device_code || makeCode(id), + payload.serial_number, + payload.vendor, + payload.model, + payload.communication_mode || "static", + payload.capability_profile_json || {}, + payload.auth_method || "token", + payload.status || "active", + payload.last_seen_at || null, + payload.firmware_version, + now, + now + ] + ); + + return mapDevice(rows[0]); +} + +export async function listDevices(): Promise { + const { rows } = await getPool().query("SELECT * FROM devices ORDER BY created_at DESC"); + return rows.map(mapDevice); +} + +export async function getDeviceById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM devices WHERE id = $1", [id]); + return rows[0] ? mapDevice(rows[0]) : null; +} + +export async function patchDevice(id: string, patch: Partial): Promise { + const existing = await getDeviceById(id); + if (!existing) { + throw new Error("DEVICE_NOT_FOUND"); + } + + const merged = { ...existing, ...patch, updated_at: nowIso() }; + + const { rows } = await getPool().query( + `UPDATE devices + SET device_code = $2, + serial_number = $3, + vendor = $4, + model = $5, + communication_mode = $6, + capability_profile_json = $7, + auth_method = $8, + status = $9, + firmware_version = $10, + last_seen_at = $11, + updated_at = $12 + WHERE id = $1 + RETURNING *`, + [ + id, + merged.device_code, + merged.serial_number, + merged.vendor, + merged.model, + merged.communication_mode || "static", + merged.capability_profile_json || {}, + merged.auth_method, + merged.status, + merged.firmware_version, + merged.last_seen_at || null, + merged.updated_at + ] + ); + + return mapDevice(rows[0]); +} + +export function toDevicePayload(device: DeviceEntity) { + return { ...device }; +} diff --git a/src/shared/store/heartbeatStore.ts b/src/shared/store/heartbeatStore.ts new file mode 100644 index 0000000..6164fe1 --- /dev/null +++ b/src/shared/store/heartbeatStore.ts @@ -0,0 +1,173 @@ +export type DeviceHealthStatus = "online" | "offline" | "degraded" | "stale"; + +export interface DeviceHeartbeatEntity { + id: string; + device_id: string; + timestamp: string; + received_at: string; + firmware_version?: string; + network_strength?: number | null; + battery_level?: number | null; + state?: string; +} + +type CreateDeviceHeartbeatPayload = { + device_id: string; + timestamp: string; + firmware_version?: string; + network_strength?: number | null; + battery_level?: number | null; + state?: string; + payload_json?: Record; +}; + +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +function nowIso() { + return new Date().toISOString(); +} + +function mapHeartbeat(row: any): DeviceHeartbeatEntity { + return { + id: row.id, + device_id: row.device_id, + timestamp: row.timestamp, + received_at: row.received_at, + firmware_version: row.firmware_version || undefined, + network_strength: row.network_strength, + battery_level: row.battery_level, + state: row.state || undefined + }; +} + +export async function createDeviceHeartbeat(payload: CreateDeviceHeartbeatPayload): Promise { + const now = nowIso(); + const id = randomUUID(); + + const { rows } = await getPool().query( + `INSERT INTO device_heartbeats ( + id, + device_id, + timestamp, + received_at, + firmware_version, + network_strength, + battery_level, + state, + payload_json + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + RETURNING *`, + [ + id, + payload.device_id, + payload.timestamp, + now, + payload.firmware_version, + payload.network_strength, + payload.battery_level, + payload.state, + payload.payload_json || {} + ] + ); + + return mapHeartbeat(rows[0]); +} + +export async function getLatestHeartbeatByDeviceId(deviceId: string): Promise { + const { rows } = await getPool().query( + `SELECT * FROM device_heartbeats + WHERE device_id = $1 + ORDER BY received_at DESC + LIMIT 1`, + [deviceId] + ); + + return rows[0] ? mapHeartbeat(rows[0]) : null; +} + +export async function getHeartbeatCountForDeviceLastHours(deviceId: string, hours = 24): Promise { + const { rows } = await getPool().query( + `SELECT COUNT(*)::INT AS cnt + FROM device_heartbeats + WHERE device_id = $1 + AND (NOW() AT TIME ZONE 'utc' - timestamp) <= ($2 || ' hours')::interval`, + [deviceId, hours] + ); + + return Number(rows[0]?.cnt || 0); +} + +export async function listHeartbeats(filter?: { + device_id?: string; + state?: string; + from?: string; + to?: string; + limit?: number; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + + if (filter?.device_id) { + clauses.push(`device_id = $${i++}`); + params.push(filter.device_id); + } + + if (filter?.state) { + clauses.push(`state = $${i++}`); + params.push(filter.state); + } + + if (filter?.from) { + clauses.push(`timestamp >= $${i++}`); + params.push(filter.from); + } + + if (filter?.to) { + clauses.push(`timestamp <= $${i++}`); + params.push(filter.to); + } + + const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const limitSql = filter?.limit ? `LIMIT ${Number(filter.limit)}` : ""; + + const { rows } = await getPool().query( + `SELECT * FROM device_heartbeats ${whereSql} ORDER BY received_at DESC ${limitSql}`, + params + ); + + return rows.map(mapHeartbeat); +} + +export function deriveDeviceStatus( + input?: { + last_seen_at?: string | null; + network_strength?: number | null; + battery_level?: number | null; + } +): DeviceHealthStatus { + const now = Date.now(); + const lastSeen = Date.parse(input?.last_seen_at || ""); + if (!Number.isFinite(lastSeen)) { + return "offline"; + } + + const ageSeconds = (now - lastSeen) / 1000; + if (ageSeconds > 900) { + return "offline"; + } + + if (ageSeconds > 90) { + return "stale"; + } + + if ( + (typeof input?.network_strength === "number" && input.network_strength < 40) || + (typeof input?.battery_level === "number" && input.battery_level < 20) + ) { + return "degraded"; + } + + return "online"; +} diff --git a/src/shared/store/locationStore.ts b/src/shared/store/locationStore.ts new file mode 100644 index 0000000..899eb6c --- /dev/null +++ b/src/shared/store/locationStore.ts @@ -0,0 +1,264 @@ +import { randomUUID } from "node:crypto"; +import { getPool, withClient } from "../db/pool"; + +export interface OutletEntity { + id: string; + merchant_id: string; + outlet_code: string; + name: string; + address?: string; + status: "active" | "inactive"; + created_at: string; + updated_at: string; +} + +export interface TerminalEntity { + id: string; + outlet_id: string; + terminal_code: string; + qr_mode: "static" | "dynamic_mqtt" | "dynamic_api"; + partner_reference?: string; + status: "active" | "inactive"; + created_at: string; + updated_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function makeCode(prefix: string, id: string) { + return `${prefix}_${id.slice(0, 6)}`; +} + +function mapOutlet(row: any): OutletEntity { + return { + id: row.id, + merchant_id: row.merchant_id, + outlet_code: row.outlet_code, + name: row.name, + address: row.address || undefined, + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +function mapTerminal(row: any): TerminalEntity { + return { + id: row.id, + outlet_id: row.outlet_id, + terminal_code: row.terminal_code, + qr_mode: row.qr_mode, + partner_reference: row.partner_reference || undefined, + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +export async function createOutlet(payload: { + merchant_id: string; + name: string; + address?: string; + outlet_code?: string; + status?: OutletEntity["status"]; +}): Promise { + const id = randomUUID(); + const now = nowIso(); + + const { rows } = await getPool().query( + `INSERT INTO outlets (id, merchant_id, outlet_code, name, address, status, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING *`, + [ + id, + payload.merchant_id, + payload.outlet_code || makeCode("out", id), + payload.name, + payload.address, + payload.status || "active", + now, + now + ] + ); + + return mapOutlet(rows[0]); +} + +export async function createTerminal(payload: { + outlet_id: string; + terminal_code?: string; + qr_mode?: TerminalEntity["qr_mode"]; + partner_reference?: string; + status?: TerminalEntity["status"]; +}): Promise { + const id = randomUUID(); + const now = nowIso(); + + const { rows } = await getPool().query( + `INSERT INTO terminals (id, outlet_id, terminal_code, qr_mode, partner_reference, status, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + RETURNING *`, + [ + id, + payload.outlet_id, + payload.terminal_code || makeCode("term", id), + payload.qr_mode || "static", + payload.partner_reference || null, + payload.status || "active", + now, + now + ] + ); + + return mapTerminal(rows[0]); +} + +export async function listOutlets(filter?: { + merchant_id?: string; + status?: OutletEntity["status"]; + q?: string; +}): 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); + } + + if (filter?.q) { + const value = `%${filter.q.toLowerCase()}%`; + clauses.push(`(LOWER(name) LIKE $${i++} OR LOWER(outlet_code) LIKE $${i++})`); + params.push(value, value); + } + + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query( + `SELECT * FROM outlets ${where} ORDER BY created_at DESC`, + params + ); + return rows.map(mapOutlet); +} + +export async function listTerminals(filter?: { + outlet_id?: string; + status?: TerminalEntity["status"]; + q?: string; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + + if (filter?.outlet_id) { + clauses.push(`outlet_id = $${i++}`); + params.push(filter.outlet_id); + } + + if (filter?.status) { + clauses.push(`status = $${i++}`); + params.push(filter.status); + } + + if (filter?.q) { + const value = `%${filter.q.toLowerCase()}%`; + clauses.push(`(LOWER(terminal_code) LIKE $${i++} OR LOWER(COALESCE(partner_reference, '')) LIKE $${i++})`); + params.push(value, value); + } + + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query( + `SELECT * FROM terminals ${where} ORDER BY created_at DESC`, + params + ); + return rows.map(mapTerminal); +} + +export async function getOutletById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM outlets WHERE id = $1", [id]); + return rows[0] ? mapOutlet(rows[0]) : null; +} + +export async function getTerminalById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM terminals WHERE id = $1", [id]); + return rows[0] ? mapTerminal(rows[0]) : null; +} + +export async function patchOutlet(id: string, patch: Partial): Promise { + const existing = await getOutletById(id); + if (!existing) { + throw new Error("OUTLET_NOT_FOUND"); + } + + const merged = { ...existing, ...patch, updated_at: nowIso() }; + + const { rows } = await getPool().query( + `UPDATE outlets + SET merchant_id = $2, + outlet_code = $3, + name = $4, + address = $5, + status = $6, + updated_at = $7 + WHERE id = $1 + RETURNING *`, + [ + id, + merged.merchant_id, + merged.outlet_code, + merged.name, + merged.address || null, + merged.status, + merged.updated_at + ] + ); + + return mapOutlet(rows[0]); +} + +export async function patchTerminal(id: string, patch: Partial): Promise { + const existing = await getTerminalById(id); + if (!existing) { + throw new Error("TERMINAL_NOT_FOUND"); + } + + const merged = { ...existing, ...patch, updated_at: nowIso() }; + + const { rows } = await getPool().query( + `UPDATE terminals + SET outlet_id = $2, + terminal_code = $3, + qr_mode = $4, + partner_reference = $5, + status = $6, + updated_at = $7 + WHERE id = $1 + RETURNING *`, + [ + id, + merged.outlet_id, + merged.terminal_code, + merged.qr_mode, + merged.partner_reference || null, + merged.status, + merged.updated_at + ] + ); + + return mapTerminal(rows[0]); +} + +export function toOutletPayload(outlet: OutletEntity) { + return { ...outlet }; +} + +export function toTerminalPayload(terminal: TerminalEntity) { + return { ...terminal }; +} diff --git a/src/shared/store/merchantStore.ts b/src/shared/store/merchantStore.ts new file mode 100644 index 0000000..7d8ade5 --- /dev/null +++ b/src/shared/store/merchantStore.ts @@ -0,0 +1,177 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export type PayoutMode = "merchant_direct" | "manual"; + +export interface MerchantEntity { + id: string; + merchant_code: string; + legal_name: string; + brand_name?: string; + settlement_account_reference?: string; + settlement_account_type?: string; + payout_mode: PayoutMode; + fee_profile_id?: string; + status: "active" | "inactive"; + onboarding_status: "pending" | "approved" | "rejected"; + created_at: string; + updated_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function makeCode(id: string) { + return `m_${id.slice(0, 6)}`; +} + +function toPublic(entity: MerchantEntity) { + return entity; +} + +function mapRowToMerchant(row: any): MerchantEntity { + return { + id: row.id, + merchant_code: row.merchant_code, + legal_name: row.legal_name, + brand_name: row.brand_name || undefined, + settlement_account_reference: row.settlement_account_reference || undefined, + settlement_account_type: row.settlement_account_type || undefined, + payout_mode: row.payout_mode as PayoutMode, + fee_profile_id: row.fee_profile_id || undefined, + status: row.status, + onboarding_status: row.onboarding_status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +export async function createMerchant(payload: { + legal_name: string; + brand_name?: string; + settlement_account_reference?: string; + settlement_account_type?: string; + payout_mode?: PayoutMode; + fee_profile_id?: string; + status?: MerchantEntity["status"]; + onboarding_status?: MerchantEntity["onboarding_status"]; +}): Promise { + const id = randomUUID(); + const now = nowIso(); + const payoutMode: PayoutMode = payload.payout_mode || "merchant_direct"; + + const { rows } = await getPool().query( + `INSERT INTO merchants ( + id, + merchant_code, + legal_name, + brand_name, + settlement_account_reference, + settlement_account_type, + payout_mode, + fee_profile_id, + status, + onboarding_status, + created_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, + [ + id, + makeCode(id), + payload.legal_name, + payload.brand_name, + payload.settlement_account_reference, + payload.settlement_account_type, + payoutMode, + payload.fee_profile_id, + payload.status || "active", + payload.onboarding_status || "pending", + now, + now + ] + ); + + return toPublic(mapRowToMerchant(rows[0])); +} + +export async function getMerchantById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM merchants WHERE id = $1", [id]); + 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); +} + +export async function patchMerchant( + id: string, + patch: { + legal_name?: string; + brand_name?: string; + settlement_account_reference?: string; + settlement_account_type?: string; + payout_mode?: PayoutMode; + fee_profile_id?: string; + status?: MerchantEntity["status"]; + onboarding_status?: MerchantEntity["onboarding_status"]; + } +): Promise { + const existing = await getMerchantById(id); + if (!existing) { + throw new Error("MERCHANT_NOT_FOUND"); + } + + const merged = { ...existing, ...patch, updated_at: nowIso() }; + + const { rows } = await getPool().query( + `UPDATE merchants + SET legal_name = $2, + brand_name = $3, + settlement_account_reference = $4, + settlement_account_type = $5, + payout_mode = $6, + fee_profile_id = $7, + status = $8, + onboarding_status = $9, + updated_at = $10 + WHERE id = $1 + RETURNING *`, + [ + id, + merged.legal_name, + merged.brand_name, + merged.settlement_account_reference, + merged.settlement_account_type, + merged.payout_mode, + merged.fee_profile_id, + merged.status, + merged.onboarding_status, + merged.updated_at + ] + ); + + return mapRowToMerchant(rows[0]); +} + +export function toMerchantPayload(m: MerchantEntity) { + return { + id: m.id, + merchant_code: m.merchant_code, + legal_name: m.legal_name, + brand_name: m.brand_name, + settlement_account_reference: m.settlement_account_reference, + settlement_account_type: m.settlement_account_type, + payout_mode: m.payout_mode, + fee_profile_id: m.fee_profile_id, + status: m.status, + onboarding_status: m.onboarding_status, + created_at: m.created_at, + updated_at: m.updated_at + }; +} diff --git a/src/shared/store/notificationStore.ts b/src/shared/store/notificationStore.ts new file mode 100644 index 0000000..aad9e54 --- /dev/null +++ b/src/shared/store/notificationStore.ts @@ -0,0 +1,247 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export type NotificationDeliveryStatus = "queued" | "sent" | "acknowledged" | "failed" | "retrying"; +export type NotificationAckStatus = "pending" | "received" | "not_supported" | "not_needed"; + +export interface NotificationEntity { + id: string; + transaction_id: string; + device_id: string | null; + delivery_channel: "mqtt"; + payload_type: "payment_success"; + delivery_status: NotificationDeliveryStatus; + retry_count: number; + ack_status: NotificationAckStatus; + event_id: string; + reason?: string; + payload_json: Record; + created_at: string; + updated_at: string; + sent_at?: string; + ack_at?: string; + next_retry_at?: string; +} + +type CreateNotificationPayload = { + transaction_id: string; + device_id: string | null; + event_id: string; + delivery_status: NotificationDeliveryStatus; + reason?: string; + payload_json?: Record; + ack_status?: NotificationAckStatus; +}; + +type UpdateNotificationPayload = { + delivery_status?: NotificationDeliveryStatus; + retry_count?: number; + ack_status?: NotificationAckStatus; + device_id?: string | null; + reason?: string; + sent_at?: string; + ack_at?: string; + next_retry_at?: string; +}; + +function nowIso() { + return new Date().toISOString(); +} + +function cloneNotification(notification: NotificationEntity): NotificationEntity { + return { + ...notification, + payload_json: { ...notification.payload_json } + }; +} + +function mapNotification(row: any): NotificationEntity { + return { + id: row.id, + transaction_id: row.transaction_id, + device_id: row.device_id || null, + delivery_channel: "mqtt", + payload_type: "payment_success", + delivery_status: row.delivery_status, + retry_count: row.retry_count, + ack_status: row.ack_status, + event_id: row.event_id, + reason: row.reason || undefined, + payload_json: row.payload_json || {}, + created_at: row.created_at, + updated_at: row.updated_at, + sent_at: row.sent_at || undefined, + ack_at: row.ack_at || undefined, + next_retry_at: row.next_retry_at || undefined + }; +} + +export async function createNotification(payload: CreateNotificationPayload): Promise { + const now = nowIso(); + + const insert = await getPool().query( + `INSERT INTO notifications ( + id, + transaction_id, + device_id, + delivery_status, + retry_count, + ack_status, + event_id, + reason, + payload_json, + created_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ON CONFLICT (transaction_id, event_id) DO UPDATE + SET updated_at = EXCLUDED.updated_at + RETURNING *`, + [ + randomUUID(), + payload.transaction_id, + payload.device_id, + payload.delivery_status, + 0, + payload.ack_status || "not_needed", + payload.event_id, + payload.reason || null, + payload.payload_json || {}, + now, + now + ] + ); + + if (insert.rowCount && insert.rowCount > 0) { + return mapNotification(insert.rows[0]); + } + + const { rows } = await getPool().query( + "SELECT * FROM notifications WHERE transaction_id = $1 AND event_id = $2", + [payload.transaction_id, payload.event_id] + ); + + return mapNotification(rows[0]); +} + +export async function getNotificationById(notificationId: string): Promise { + const { rows } = await getPool().query("SELECT * FROM notifications WHERE id = $1", [notificationId]); + return rows[0] ? cloneNotification(mapNotification(rows[0])) : null; +} + +export async function updateNotification( + notificationId: string, + patch: UpdateNotificationPayload +): Promise { + const existing = await getNotificationById(notificationId); + if (!existing) { + throw new Error("NOTIFICATION_NOT_FOUND"); + } + + const next: NotificationEntity = { + ...existing, + ...patch, + id: existing.id, + transaction_id: existing.transaction_id, + device_id: existing.device_id, + delivery_channel: existing.delivery_channel, + payload_type: existing.payload_type, + event_id: existing.event_id, + payload_json: existing.payload_json, + created_at: existing.created_at, + updated_at: nowIso() + }; + + const { rows } = await getPool().query( + `UPDATE notifications + SET delivery_status = $2, + retry_count = $3, + ack_status = $4, + device_id = COALESCE($5, device_id), + reason = $6, + sent_at = $7, + ack_at = $8, + next_retry_at = $9, + updated_at = $10 + WHERE id = $1 + RETURNING *`, + [ + notificationId, + next.delivery_status, + next.retry_count, + next.ack_status, + next.device_id ?? null, + next.reason || null, + next.sent_at || null, + next.ack_at || null, + next.next_retry_at || null, + next.updated_at + ] + ); + + return cloneNotification(mapNotification(rows[0])); +} + +export async function getNotificationByTransactionId(transactionId: string): Promise { + const { rows } = await getPool().query( + `SELECT * FROM notifications + WHERE transaction_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + [transactionId] + ); + return rows[0] ? mapNotification(rows[0]) : null; +} + +export async function getNotificationByTransactionAndEvent( + transactionId: string, + eventId: string +): Promise { + const { rows } = await getPool().query( + "SELECT * FROM notifications WHERE transaction_id = $1 AND event_id = $2", + [transactionId, eventId] + ); + return rows[0] ? mapNotification(rows[0]) : null; +} + +export async function listNotificationsByDevice(deviceId: string): Promise { + const { rows } = await getPool().query( + "SELECT * FROM notifications WHERE device_id = $1 ORDER BY created_at DESC", + [deviceId] + ); + return rows.map(mapNotification); +} + +export async function listNotifications(filter?: { + transaction_id?: string; + device_id?: string; + delivery_status?: NotificationDeliveryStatus; +}): Promise { + const filters: string[] = []; + const params: unknown[] = []; + + if (filter?.transaction_id) { + params.push(filter.transaction_id); + filters.push(`transaction_id = $${params.length}`); + } + + if (filter?.device_id) { + params.push(filter.device_id); + filters.push(`device_id = $${params.length}`); + } + + if (filter?.delivery_status) { + params.push(filter.delivery_status); + filters.push(`delivery_status = $${params.length}`); + } + + const where = filters.length ? `WHERE ${filters.join(" AND ")}` : ""; + const { rows } = await getPool().query( + `SELECT * FROM notifications ${where} ORDER BY created_at DESC`, + params + ); + return rows.map(mapNotification); +} + +export function toNotificationPayload(notification: NotificationEntity) { + return cloneNotification(notification); +} diff --git a/src/shared/store/transactionStore.ts b/src/shared/store/transactionStore.ts new file mode 100644 index 0000000..b1fd41b --- /dev/null +++ b/src/shared/store/transactionStore.ts @@ -0,0 +1,330 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export type TransactionStatus = + | "initiated" + | "awaiting_payment" + | "paid" + | "failed" + | "expired" + | "reversed"; + +export type TransactionEventType = + | "INITIATED" + | "STATE_CHANGED" + | "CALLBACK_RECEIVED" + | "CALLBACK_REJECTED" + | "CALLBACK_DUPLICATE" + | "PUSH_QUEUED"; + +export type TransactionEventSource = "webhook" | "system" | "admin"; + +export interface TransactionEntity { + id: string; + transaction_code: string; + merchant_id: string; + outlet_id: string; + terminal_id: string; + device_id?: string; + qr_mode: "static" | "dynamic"; + initiation_mode: "static" | "manual" | "dynamic_api" | "dynamic_mqtt"; + partner_reference: string; + amount: number; + currency: string; + status: TransactionStatus; + created_at: string; + paid_at?: string; + expired_at?: string; + updated_at: string; +} + +export interface TransactionEventEntity { + id: string; + transaction_id: string; + event_type: TransactionEventType; + source: TransactionEventSource; + payload_json: Record; + created_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function makeCode(id: string) { + return `tx_${id.slice(0, 8)}`; +} + +function mapTransaction(row: any): TransactionEntity { + return { + id: row.id, + transaction_code: row.transaction_code, + merchant_id: row.merchant_id, + outlet_id: row.outlet_id, + terminal_id: row.terminal_id, + device_id: row.device_id || undefined, + qr_mode: row.qr_mode, + initiation_mode: row.initiation_mode, + partner_reference: row.partner_reference, + amount: Number(row.amount), + currency: row.currency, + status: row.status, + created_at: row.created_at, + paid_at: row.paid_at || undefined, + expired_at: row.expired_at || undefined, + updated_at: row.updated_at + }; +} + +function mapEvent(row: any): TransactionEventEntity { + return { + id: row.id, + transaction_id: row.transaction_id, + event_type: row.event_type, + source: row.source, + payload_json: row.payload_json || {}, + created_at: row.created_at + }; +} + +const TRANSACTION_STATE_TRANSITIONS: Record = { + initiated: ["initiated", "awaiting_payment", "paid", "failed", "expired", "reversed"], + awaiting_payment: ["awaiting_payment", "paid", "failed", "expired", "reversed"], + paid: ["paid", "reversed"], + failed: ["failed", "reversed"], + expired: ["expired", "reversed"], + reversed: ["reversed"] +}; + +function isValidTransactionTransition(from: TransactionStatus, to: TransactionStatus) { + return TRANSACTION_STATE_TRANSITIONS[from]?.includes(to) ?? false; +} + +export async function createTransaction(payload: { + merchant_id: string; + outlet_id: string; + terminal_id: string; + device_id?: string; + qr_mode?: "static" | "dynamic"; + initiation_mode?: "static" | "manual" | "dynamic_api" | "dynamic_mqtt"; + partner_reference: string; + amount: number; + currency?: string; + status?: TransactionStatus; + paid_at?: string; + expired_at?: string; +}): Promise { + const id = randomUUID(); + const now = nowIso(); + + const entity: TransactionEntity = { + id, + transaction_code: makeCode(id), + merchant_id: payload.merchant_id, + outlet_id: payload.outlet_id, + terminal_id: payload.terminal_id, + device_id: payload.device_id, + qr_mode: payload.qr_mode || "static", + initiation_mode: payload.initiation_mode || "static", + partner_reference: payload.partner_reference, + amount: payload.amount, + currency: payload.currency || "IDR", + status: payload.status || "initiated", + created_at: now, + paid_at: payload.paid_at, + expired_at: payload.expired_at, + updated_at: now + }; + + const txResult = await getPool().query( + `INSERT INTO transactions ( + id, + transaction_code, + merchant_id, + outlet_id, + terminal_id, + device_id, + qr_mode, + initiation_mode, + partner_reference, + amount, + currency, + status, + created_at, + paid_at, + expired_at, + updated_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + RETURNING *`, + [ + entity.id, + entity.transaction_code, + entity.merchant_id, + entity.outlet_id, + entity.terminal_id, + entity.device_id || null, + entity.qr_mode, + entity.initiation_mode, + entity.partner_reference, + entity.amount, + entity.currency, + entity.status, + entity.created_at, + entity.paid_at || null, + entity.expired_at || null, + entity.updated_at + ] + ); + + await addTransactionEvent({ + transaction_id: txResult.rows[0].id, + event_type: "INITIATED", + source: "system", + payload_json: { status: txResult.rows[0].status, partner_reference: payload.partner_reference } + }); + + return mapTransaction(txResult.rows[0]); +} + +export async function addTransactionEvent(payload: { + transaction_id: string; + event_type: TransactionEventType; + source: TransactionEventSource; + payload_json?: Record; +}): Promise { + const id = randomUUID(); + const { rows } = await getPool().query( + `INSERT INTO transaction_events (id, transaction_id, event_type, source, payload_json, created_at) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING *`, + [id, payload.transaction_id, payload.event_type, payload.source, payload.payload_json || {}, nowIso()] + ); + + return mapEvent(rows[0]); +} + +export async function updateTransactionStatus( + id: string, + to: TransactionStatus, + options: { + source: TransactionEventSource; + eventContext?: Record; + paid_at?: string; + expired_at?: string; + } +): Promise { + const entity = await getTransactionById(id); + if (!entity) { + throw new Error("TRANSACTION_NOT_FOUND"); + } + + if (!isValidTransactionTransition(entity.status, to)) { + throw new Error(`INVALID_TRANSACTION_STATE_TRANSITION:${entity.status}->${to}`); + } + + if (entity.status === to) { + return entity; + } + + const now = nowIso(); + const next: TransactionEntity = { + ...entity, + status: to, + paid_at: options.paid_at || entity.paid_at, + expired_at: options.expired_at || entity.expired_at, + updated_at: now + }; + + if (to === "paid" && !next.paid_at) { + next.paid_at = now; + } + + if (to === "expired" && !next.expired_at) { + next.expired_at = now; + } + + const { rows } = await getPool().query( + `UPDATE transactions + SET status = $2, + paid_at = $3, + expired_at = $4, + updated_at = $5 + WHERE id = $1 + RETURNING *`, + [id, next.status, next.paid_at || null, next.expired_at || null, next.updated_at] + ); + + await addTransactionEvent({ + transaction_id: id, + event_type: "STATE_CHANGED", + source: options.source, + payload_json: { + from: entity.status, + to, + ...options.eventContext + } + }); + + return mapTransaction(rows[0]); +} + +export async function getTransactionById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM transactions WHERE id = $1", [id]); + return rows[0] ? mapTransaction(rows[0]) : null; +} + +export async function findTransactionByPartnerReference(partnerReference: string): Promise { + const { rows } = await getPool().query( + "SELECT * FROM transactions WHERE partner_reference = $1", + [partnerReference] + ); + return rows[0] ? mapTransaction(rows[0]) : null; +} + +export async function listTransactions(filter?: { + status?: TransactionStatus; + merchant_id?: string; +}): Promise { + if (filter?.status && filter?.merchant_id) { + const { rows } = await getPool().query( + "SELECT * FROM transactions WHERE status = $1 AND merchant_id = $2 ORDER BY created_at DESC", + [filter.status, filter.merchant_id] + ); + return rows.map(mapTransaction); + } + + if (filter?.status) { + const { rows } = await getPool().query( + "SELECT * FROM transactions WHERE status = $1 ORDER BY created_at DESC", + [filter.status] + ); + return rows.map(mapTransaction); + } + + if (filter?.merchant_id) { + const { rows } = await getPool().query( + "SELECT * FROM transactions WHERE merchant_id = $1 ORDER BY created_at DESC", + [filter.merchant_id] + ); + return rows.map(mapTransaction); + } + + const { rows } = await getPool().query("SELECT * FROM transactions ORDER BY created_at DESC"); + return rows.map(mapTransaction); +} + +export async function getTransactionEvents(transactionId: string): Promise { + const { rows } = await getPool().query( + "SELECT * FROM transaction_events WHERE transaction_id = $1 ORDER BY created_at ASC", + [transactionId] + ); + return rows.map(mapEvent); +} + +export function toTransactionPayload(transaction: TransactionEntity) { + return { ...transaction }; +} + +export function toTransactionEventPayload(event: TransactionEventEntity) { + return { ...event, payload_json: { ...event.payload_json } }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e6ccc5c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.mts", "src/**/*.cts"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/admin-application-review-detail/index.html b/ui/admin-application-review-detail/index.html new file mode 100644 index 0000000..c930f83 --- /dev/null +++ b/ui/admin-application-review-detail/index.html @@ -0,0 +1,596 @@ + + + + + + + + + + + + + + + + +
+ +
+
+ +

Review: Coffee & Co. Jakarta

+
+
+
+timer +Pending Review +
+
+ + + +
+
+ +
+ +
+
+ +
+
+business +

Business Details

+
+
+
+ + +
+
+ +

Coffee & Co. Jakarta

+
+
+ +

Food & Beverage (Café)

+
+
+ +

Limited Liability (PT)

+
+
+ +

Jl. Senopati No. 12, Kebayoran Baru, Jakarta Selatan, 12190

+
+
+
+ +
+
+person +

Person in Charge (PIC)

+
+
+
+ +

Bambang Wijaya

+
+
+ +

3174092803850001

+
+
+ +

bambang.w@coffeeandco.id

+
+
+ +

+62 812 3456 7890

+
+
+
+ +
+
+account_balance +

Bank Settlement Info

+
+
+
+ +

Bank Central Asia (BCA)

+
+
+ +

8820 123 456

+
+
+ +

PT KOPI NUSANTARA ABADI

+
+
+
+ +
+
+info +

Application Audit

+
+
+
+RAW PAYLOAD v2.4 + +
+
{
+  "application_id": "APP-2023-9912",
+  "submission_date": "2023-11-24T10:22:31Z",
+  "device_provisioning": true,
+  "mcc_code": "5812",
+  "risk_score": 0.12,
+  "onboarding_channel": "Web Portal Direct"
+}
+
+
+
+
+ +
+
+

+description + Document Verification +

+ +
+
+IDENTITY CARD (KTP) + +
+
+ +
+ +
+
+
+ +
+
+TAX ID (NPWP) + +
+
+ +
+ +
+
+
+ +
+
+STORE FRONT PHOTO + +
+
+ +
+ +
+
+
+
+
+
+ +
+
+
+Assigned To +Self (Admin #042) +
+
+
+ + +
+
+ + +
+ + + + +' + diff --git a/ui/admin-dashboard-overview/index.html b/ui/admin-dashboard-overview/index.html new file mode 100644 index 0000000..a562d10 --- /dev/null +++ b/ui/admin-dashboard-overview/index.html @@ -0,0 +1,737 @@ + + + + + +Soundbox Ops - Admin Console + + + + + + + + + + + +
+
+
+search + +
+ +
+
+ + +
+
+
+

Admin User

+

Super Administrator

+
+Administrator Profile +
+
+
+ +
+ +
+
+

Operational Overview

+

Real-time status of your QRIS soundbox ecosystem.

+
+
+ + +
+
+ +
+ +
+
+
+store +
+ + +4.2% trending_up + +
+

Total Merchants

+

1,240

+
+ +
+
+
+sensors +
+94.4% Active +
+

Devices Online

+

850 / 900

+
+ +
+
+
+payments +
+ + +12% trending_up + +
+

Today's Transactions

+

Rp450M

+
+ +
+
+
+verified +
+ + Stable check_circle + +
+

Success Rate

+

99.2%

+
+ +
+
+
+hourglass_empty +
+ +
+

Pending Settlements

+

24

+
+
+ +
+ +
+ +
+
+
+

Transaction Volume Trend

+

Last 7 days performance metrics

+
+
+
+ +Current Period +
+
+ +Previous Period +
+
+
+ +
+
+
+
+
+Mon +
+
+
+
+
+Tue +
+
+
+
+
+Wed +
+
+
+
+
+Thu +
+
+
+
+
+Fri +
+
+
+
+
+Sat +
+
+
+
+
+Sun +
+
+
+ +
+
+
+

Pending Merchant Onboarding

+

New applications requiring review

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Merchant NameCategorySubmission DateStatusActions
+
+
KB
+
+

Kopi Bahagia

+

ID: MERCH-9021

+
+
+
F&B - CafeOct 24, 2023 + + Pending Review + + + +
+
+
JM
+
+

Jaya Mart

+

ID: MERCH-8843

+
+
+
Retail - GroceryOct 23, 2023 + + Pending Review + + + +
+
+
AL
+
+

Apotek Lestari

+

ID: MERCH-8712

+
+
+
Healthcare - PharmaOct 23, 2023 + + Pending Review + + + +
+
+
+
+ +
+ +
+

Device Health

+
+ + + + + + +
+

94%

+

Healthy

+
+
+
+
+
+ +Online / Ready +
+850 +
+
+
+ +Degraded / Slow +
+35 +
+
+
+ +Offline / Error +
+15 +
+
+
+ +
+
+

Recent Alerts

+2 Critical +
+
+ +
+
+error +
+

Terminal X-009 Offline

+

Location: Outlet Y (Sudirman Mall)

+
+2 mins ago + +
+
+
+
+ +
+
+warning +
+

Network Latency Spike

+

Impact: Cluster Jakarta Selatan

+
+14 mins ago + +
+
+
+
+ +
+
+error +
+

Repeated Auth Failure

+

Merchant: IndoFresh Mart #44

+
+45 mins ago + +
+
+
+
+ +
+
+info +
+

New FW Update Available

+

Version 2.4.1 (Stable Build)

+
+2 hours ago + +
+
+
+
+
+ +
+
+
+ +
+
+

Audit Activity Stream

+
+Live Stream +Operational +
+
+
+

[14:32:11] SUCCESS: Settlement triggered for Cluster-B (Rp12.4M handled)

+

[14:31:05] INFO: Device X-292 ping response received (latency 42ms)

+

[14:29:44] WARN: Merchant ID 9921 failed KYC validation step 3

+

[14:28:12] SUCCESS: New Admin 'DevOps_Main' logged in via MFA

+
+ +
+terminal +
+
+
+ + + + +' + diff --git a/ui/admin-fee-pricing-management/index.html b/ui/admin-fee-pricing-management/index.html new file mode 100644 index 0000000..3298e4a --- /dev/null +++ b/ui/admin-fee-pricing-management/index.html @@ -0,0 +1,576 @@ + + + + + +Fee & Pricing Management | Soundbox Ops + + + + + + + + + + +
+ +
+
+
Soundbox Ops
+ +
+
+
+ +search +
+ + +Administrator Avatar +
+
+
+ +
+
+

Fee & Pricing Management

+

Configure transaction MDR, platform fees, and subscription tiers.

+
+
+ + +
+
+ +
+ +
+

Active Pricing Tiers

+
+ +
+
+DEFAULT +more_vert +
+

SME Basic

+

Optimized for small-volume merchants.

+
+
+
Base MDR
+
0.75%
+
+
+
Subscription
+
$12/mo
+
+
+
+
+
+PREMIUM +more_vert +
+

Enterprise Plus

+

High-volume corporate accounts.

+
+
+
Base MDR
+
0.45%
+
+
+
Subscription
+
$150/mo
+
+
+
+
+
+CUSTOM +more_vert +
+

Government Special

+

Public sector specific regulations.

+
+
+
Base MDR
+
0.10%
+
+
+
Subscription
+
$0/mo
+
+
+
+
+
+ +
+ +
+
+
+

Configure Tier: SME Basic

+

Managing global transaction rules for all assigned merchants.

+
+
+ + +
+
+
+ +
+
+percent +

Merchant Discount Rate (MDR)

+
+
+
+ +
+ +% +
+
+
+ +
+ +% +
+
+
+ +
+ +% +
+
+
+
+ +
+
+payments +

Fixed Fees & Subscription

+
+
+
+ +
+$ + +
+
+
+ +
+$ + +
+
+
+ +
+$ + +
+
+
+
+ +
+
+

Tier Logic Restrictions

+ +
+
+
+
+terminal +
+
+

Cap Maximum Fee

+

Limit total MDR to $25.00 per TRX

+
+ +
+
+
+event_repeat +
+
+

Prorated First Month

+

Calculate sub-fee based on join date

+
+ +
+
+
+
+
+ +
+ +
+ +
+update +
+
+
+published_with_changes +

Bulk Fee Update

+
+

Apply a flat-rate adjustment or percentage shift to multiple tiers or specific merchant clusters instantly.

+
+ + +
+
+
+ +
+
+

History of Fee Changes

+Full Log +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampChangeAdmin
+
2023-11-24 14:02
+
+
SME Basic QRIS 0.65% → 0.75%
+
+
+ +J. DOE +
+
+
2023-11-23 09:15
+
+
New Tier Created: Gov Special
+
+
+ +A. CHEN +
+
+
2023-11-22 17:44
+
+
Enterprise Subs $125 → $150
+
+
+
SYS
+SYSTEM +
+
+
+
+
+ +
+
+Active Configuration JSON + +
+
+
{
+  "tier_id": "tier_sme_001",
+  "tier_name": "SME Basic",
+  "pricing_logic": {
+    "mdr": {
+      "qris": 0.0075,
+      "credit_card": 0.0210,
+      "debit_card": 0.0100
+    },
+    "fixed_fees": {
+      "processing": 0.25,
+      "settlement": 0.50
+    },
+    "subscription": {
+      "monthly": 12.00,
+      "currency": "USD"
+    }
+  },
+  "constraints": {
+    "max_fee_cap": 25.00,
+    "prorated_subscription": false
+  },
+  "last_updated": "2023-11-24T14:02:11Z"
+}
+
+
+
+
+
+
+ +
+
+
+

Change Details

+ +
+
+
+ +
+
+
+

Approved by Finance Dir

+

2023-11-24 15:30

+
+
+
+

Requested by J. Doe

+

2023-11-24 14:02

+
+
+
+

Previous Value Verified

+

2023-11-24 13:45

+
+
+
+
+ +

"Adjusting QRIS MDR to reflect new interbank processing costs and maintain margin for small-volume SME segment."

+
+
+
+ +
+
+
+ + + +' + \ No newline at end of file diff --git a/ui/admin-login-portal/index.html b/ui/admin-login-portal/index.html new file mode 100644 index 0000000..4610dc4 --- /dev/null +++ b/ui/admin-login-portal/index.html @@ -0,0 +1,269 @@ + + + + + +Login Admin | Soundbox Ops + + + + + + + + + +
+ + + + +
+ +
+
+
+
+ + + +' + \ No newline at end of file diff --git a/ui/admin-login/index.html b/ui/admin-login/index.html new file mode 100644 index 0000000..d1ffa13 --- /dev/null +++ b/ui/admin-login/index.html @@ -0,0 +1,282 @@ + + + + + +Login Admin | Soundbox Ops + + + + + + + + + +
+ + + + +
+ +
+
+
+
+ + + + +' + diff --git a/ui/admin-onboarding-review-queue/index.html b/ui/admin-onboarding-review-queue/index.html new file mode 100644 index 0000000..272669a --- /dev/null +++ b/ui/admin-onboarding-review-queue/index.html @@ -0,0 +1,554 @@ + + + + + +Merchant Onboarding Review Queue + + + + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+
+ + + +
+
+
+ +
+ +
+

Onboarding Review Queue

+

Verify and authorize new merchant accounts for the soundbox ecosystem.

+
+ +
+ +
+
+
+Pending Reviews +pending +
+
0
+
+
+trending_up ++8% from yesterday +
+
+ +
+
+
+Approved Today +check_circle +
+
0
+
+
+check +Daily quota met +
+
+ +
+
+
+Avg. Review Time +timer +
+
4.2h
+
+
+trending_down +-12m improvement +
+
+
+ +
+
+
+

Review Applications

+
+All: 0 +Urgent: 12 +
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + +
Merchant DetailsSubmission DateCategoryStatusActions
Loading pending applications...
+
+
+

Showing 0 pending applications

+
+ + +
+
+
+
+ + +
+ +
+
+

Application Review

+ +
+
+ +
+Merchant Branding +
+

Bistro Kopi Central

+High Priority Review +
+
+ +
+
+verified_user + Entity Verification +
+
+
+

Business Registration

+

REG-2023-99120

+
+
+

Tax ID / PAN

+

BKC991204X

+
+
+
+
+description +

Incorporation_Doc.pdf

+
+ +
+
+ +
+
+code + Risk Score Payload +
+
+ +
{
+  "risk_assessment": {
+    "score": 14,
+    "rating": "LOW",
+    "signals": [
+      "geofence_match: true",
+      "kyc_verification: pass",
+      "velocity_check: pass"
+    ],
+    "last_check": "2023-10-24T09:12:04Z"
+  }
+}
+
+
+ +
+
+history + Timeline +
+
+
+ +check + +

Application Submitted

+

Oct 24, 09:12 AM by Merchant

+
+
+ +robot + +

Automated KYC Check

+

Oct 24, 09:13 AM - System Approved

+
+
+ + + +

Pending Manual Review

+

Assigning to Alex Rivera...

+
+
+
+
+
+ + +
+
+ + + + + + +' + diff --git a/ui/admin-reconciliation-management/index.html b/ui/admin-reconciliation-management/index.html new file mode 100644 index 0000000..b4cd76c --- /dev/null +++ b/ui/admin-reconciliation-management/index.html @@ -0,0 +1,566 @@ + + + + + +Reconciliation | Soundbox Ops + + + + + + + + + + + +
+ +
+
+

Reconciliation

+ +
+
+
+ +search +
+ + +
+
+Administrator Avatar + +
+
+
+
+ +
+
+
+Total Matched +
+check_circle +
+
+
42,892
+
+trending_up ++12.4% +vs last period +
+
+
+
+Discrepancies +
+error_outline +
+
+
148
+
+trending_down +-2.1% +Requires attention +
+
+
+
+Pending Verification +
+hourglass_empty +
+
+
1,024
+
+history +Avg 4h processing +
+
+
+
+Bank Statements +
+account_balance +
+
+
842
+
+cloud_done +12 API Connections +
+
+
+ +
+
+calendar_today +Oct 01, 2023 - Oct 31, 2023 +
+ + +
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Transaction DetailsSystem Record (Internal)Bank Record (External)VarianceStatusActions
+

TXN-90283471

+

Oct 24, 2023 • 14:22:10

+
₹ 14,500.00₹ 14,500.000.00 +MATCHED + + +
+

TXN-88273412

+

Oct 24, 2023 • 11:05:45

+
₹ 8,240.50₹ 8,245.50- 5.00 +EXCEPTION + + +
+

TXN-90112456

+

Oct 23, 2023 • 23:18:02

+
₹ 1,20,000.00Not FoundPending +PENDING + + +
+

TXN-90283472

+

Oct 23, 2023 • 18:45:30

+
₹ 450.00₹ 450.000.00 +MATCHED + + +
+

TXN-90283478

+

Oct 23, 2023 • 16:12:11

+
₹ 22,000.00₹ 21,560.00- 440.00 +EXCEPTION + + +
+
+ +
+

Showing 1 to 5 of 42,892 entries

+
+ + + + + +
+
+
+ +
+ +
+
+

+terminal + Raw Reconciliation Payload (Last Match) +

+ +
+
+
{
+  "reconciliation_id": "RECON-00492-AX",
+  "timestamp": "2023-10-24T14:22:10.452Z",
+  "system_ledger": {
+    "entry_id": "SL-90283471",
+    "amount": 14500.00,
+    "currency": "INR",
+    "hash": "8f3e22...a1"
+  },
+  "bank_statement": {
+    "ref_num": "BANK-TX-5512",
+    "posted_date": "2023-10-24",
+    "amount": 14500.00,
+    "match_confidence": 0.998
+  },
+  "result": "AUTO_MATCH_SUCCESS",
+  "strategy": "EXACT_AMOUNT_REF_MATCH"
+}
+            
+
+
+ +
+

Recent Resolution Activity

+
+
+
+
+

TXN-88273412 Resolved

+

Manual match confirmed by Admin A12

+

12 minutes ago

+
+
+
+
+

Report Exported

+

Full Oct report generated (PDF)

+

45 minutes ago

+
+
+
+

HSBC Sync Completed

+

2,400 statements fetched via API

+

1 hour ago

+
+
+
+
+
+
+ + + + + +' + \ No newline at end of file diff --git a/ui/admin-system-audit-logs/index.html b/ui/admin-system-audit-logs/index.html new file mode 100644 index 0000000..0b42a9c --- /dev/null +++ b/ui/admin-system-audit-logs/index.html @@ -0,0 +1,526 @@ + + + + + +Audit Logs | Soundbox Ops + + + + + + + + + + + +
+ +
+
+

Audit Logs

+/ +System Security +
+
+ +
+search + +
+
+ + +
+Administrator Avatar +
+
+
+ +
+ +
+
+Action Type: + +
+
+User Role: + +
+
+calendar_today +Date Range: + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimestampUser & RoleActionResource IDIP AddressStatusPayload
+
+Oct 24, 2023 +14:22:45.002 +
+
+
+User +
+Felix Chen +Super Admin +
+
+
+Updated Merchant Fee + +MID-88219-X + + 192.168.1.104 + + + Success + + + +
+
+Oct 24, 2023 +13:58:12.881 +
+
+
+User +
+Sarah Miller +Operator +
+
+
+Failed Login Attempt + + 103.24.11.92 + + + Failed + + + +
+
+Oct 24, 2023 +12:10:04.230 +
+
+
+User +
+Jordan Blake +Super Admin +
+
+
+Deleted API Key + +KEY-TEST-992 + + 45.12.88.21 + + + Success + + + +
+
+Oct 24, 2023 +11:45:30.121 +
+
+
+User +
+System Core +Automated +
+
+
+Initiated Daily Settlement + +SETTLE-20231024 + + Internal + + + Pending + + + +
+
+ +
+Showing 1 to 20 of 1,248 entries +
+ + + + + +
+
+
+
+
+ + + + + +' + \ No newline at end of file diff --git a/ui/device-registry-monitoring/index.html b/ui/device-registry-monitoring/index.html new file mode 100644 index 0000000..b244629 --- /dev/null +++ b/ui/device-registry-monitoring/index.html @@ -0,0 +1,803 @@ + + + + + +Device Registry | Soundbox Ops + + + + + + + + + + + +
+
+
+search + +
+ +
+
+ + +
+Administrator Profile +
+
+ +
+ +
+
+

Device Registry

+

Manage and monitor all IoT soundbox units across the network.

+
+ +
+ +
+
+
+
+devices +
+ +trending_up + +24 + +
+

Total Registered

+

1,200

+
+
+
+
+
+
+
+router +
+ +check_circle + 75% Rate + +
+

Active Units

+

900

+
+
+
+
+
+
+
+inventory_2 +
+Unassigned +
+

Stock Available

+

300

+
+
+
+
+
+ +
+ +
+
+filter_list +Filters +
+
+ + + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + +
Device IDModelMerchant BindingConnectionStatusHealthLast Seen
Loading device registry...
+
+ +
+Showing 1-4 of 1,200 devices +
+ + + + +... + + +
+
+
+ +
+
+build +
+
+

Scheduled Maintenance

+

Device Heartbeat service will be offline for 15 minutes today at 02:00 UTC for firmware indexing updates.

+
+ +
+
+ +
+
+
+
+

Device Detail

+ +
+
+
+Soundbox V2 Product +
+

-

+Device +
+
+
+
+
+ + +
+
+
+ + + + + diff --git a/ui/device-technical-detail/index.html b/ui/device-technical-detail/index.html new file mode 100644 index 0000000..757fcc5 --- /dev/null +++ b/ui/device-technical-detail/index.html @@ -0,0 +1,756 @@ + + + + + +Device Detail | Soundbox Ops + + + + + + + + + + + +
+
+search + +
+
+
+ + +
+
+
+
+

Admin_User

+

Super Admin

+
+Admin Avatar +
+
+
+ +
+
+ + + +
+
+
+speaker_group +
+
+
+

SND-10293

+ + + Online + +
+
+

+settings_input_component + Soundbox V2 Pro +

+

+schedule + Last seen 2 mins ago +

+

+location_on + Mumbai, Central Region +

+
+
+
+
+ + +
+
+ +
+ + + + +
+ +
+ +
+ +
+
+

Signal Strength (4G)

+
+

-78 dBm

+signal_cellular_4_bar +
+

+check_circle + Excellent +

+
+
+

Battery Health

+
+

92%

+battery_5_bar +
+

+bolt + Discharging (External OFF) +

+
+
+

Firmware Version

+
+

v2.4.1

+verified +
+

+info + Latest version available +

+
+
+ +
+
+

Current Merchant Binding

+ +
+
+
+Bakery A Logo +
+
+

Bakery A - Mumbai Outlet

+

Merchant ID: MID-99201-B02

+
+
+

Bound Since

+

12 Oct 2023, 11:45 AM

+
+
+
+ +
+
+
+ +Live Payload Stream +
+
+ + +
+
+
+

[14:02:11] INITIALIZING WEBSOCKET CONNECTION...

+

[14:02:12] CONNECTED TO SND-10293_GATEWAY_V4

+

[14:02:15] RECV: {"event": "heartbeat", "status": "online", "v_batt": 4.12, "rssi": -78, "ts": 1715421255}

+

[14:03:01] SEND: {"cmd": "ack_config", "token": "fx-2291"}

+

[14:03:02] RECV: {"event": "tx_confirm", "tx_id": "QR-90112", "amount": 12.50, "currency": "INR"}

+

[14:04:15] IDLE: STANDBY MODE ACTIVE

+
+
+
+ +
+ +
+
+

Remote Actions

+
+
+ + + +
+ +
+
+
+ +
+

Device Events

+
+
+ +
+
+
+
+
+ + + + + + +' + diff --git a/ui/device-ui-payment-success/index.html b/ui/device-ui-payment-success/index.html new file mode 100644 index 0000000..072264b --- /dev/null +++ b/ui/device-ui-payment-success/index.html @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+
+ +
+ +
+ +
+
+
+check +
+
+ +
+

Payment Successful

+

Merchant: Brew & Bean Cafe

+
+ +
+

Amount Paid

+
+Rp +50,000 +
+
+ +
+
+Reference No. +FTX-8829-0012 +
+
+Date & Time +Oct 24, 2023 · 14:32 +
+
+Method +
+QRIS QuickPay +
+
+
+
+
+ +
+
+wifi +Connected +
+
+88% +battery_5_bar +
+
+ +
+
+ +
+

IOT SOUNDBOX GEN-3

+
+ + + +' + \ No newline at end of file diff --git a/ui/device-ui-qr-payment-display/index.html b/ui/device-ui-qr-payment-display/index.html new file mode 100644 index 0000000..18057f6 --- /dev/null +++ b/ui/device-ui-qr-payment-display/index.html @@ -0,0 +1,478 @@ + + + + + +FinOps Admin - Device QR State + + + + + + + + + + + +
+ +
+
+
+search + +
+
+
+

Merchant Control Center

+
+ + + +
+
+
+ +
+ +
+
+
+Device Fleet +chevron_right +Soundbox SB-9021 +
+

Live Device Simulation

+
+
+ + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+Merchant +wifi +
+

Central Coffee Roasters

+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+qr_code_2 +
+
+
+ +
+
+
+
+ +
+
+ +
+

Total Payment

+

Rp 50.000

+
+
+ +
+pending +Waiting for payment... +
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+ +
+

+ Device Diagnostics + Active +

+
+
+Signal Strength +
+
+
+
+
+
+
+-74 dBm +
+
+
+Battery Level +
+94% +battery_5_bar +
+
+
+Firmware +v2.4.1-stable +
+
+Uptime +14d 06h 22m +
+
+
+ +
+
+

Recent Logs

+ +
+
+
+ +
+
+

[14:22:01] CMD_REQ_QR_GEN: amount=50000

+

[14:22:02] QR_API_RES: 200 OK - data_hash=7f2a1...

+

[14:22:02] UI_STATE_UPD: display_qr_waiting

+

[14:22:05] HEARTBEAT: latency=42ms

+

[14:22:15] HEARTBEAT: latency=38ms

+

[14:22:18] SOCKET_EVT: client_polling_started

+
+
+
+ +
+
+
+block +
+
+

Cancel Trans.

+

Void current QR

+
+
+
+
+volume_up +
+
+

Test Speaker

+

Play chime

+
+
+
+
+
+ +
+
+

Recent Merchant Transactions

+
+Daily Target: 82% +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Transaction IDTimestampMethodAmountStatus
TXN-9842105514:18:42 +
+qr_code +QRIS Static +
+
Rp 24,500 + + Settled + +
TXN-9842104214:05:11 +
+qr_code +QRIS Dynamic +
+
Rp 120,000 + + Settled + +
TXN-9842102113:58:30 +
+qr_code +QRIS Dynamic +
+
Rp 50,000 + + Failed + +
+
+
+
+
+ + + + + +' + \ No newline at end of file diff --git a/ui/hub.html b/ui/hub.html new file mode 100644 index 0000000..0ffe5b7 --- /dev/null +++ b/ui/hub.html @@ -0,0 +1,92 @@ + + + + + + QRIS Soundbox UI Hub + + + +
+

QRIS Soundbox UI Hub

+ Kembali ke Katalog + +
+
+ + +
+ + + diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..812bd11 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,55 @@ + + + + + + QRIS Soundbox UI Catalogue + + + +

QRIS Soundbox UI Catalogue

+

Klik satu halaman untuk preview, atau buka /ui/hub untuk navigasi terpadu.

+ + + + diff --git a/ui/merchant-dashboard-portal/index.html b/ui/merchant-dashboard-portal/index.html new file mode 100644 index 0000000..6f12f3c --- /dev/null +++ b/ui/merchant-dashboard-portal/index.html @@ -0,0 +1,485 @@ + + + + + +Soundbox Ops | Merchant Portal + + + + + + + + + + +
+
+
+search + +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+

Merchant Portal

+

Good morning, John's Coffee

+
+ +
+ +
+ +
+
+
+payments +
+ +trending_up + 12.4% + +
+

Today's GMV

+

₹42,850.50

+

vs. ₹38,120.00 yesterday

+
+ +
+
+
+receipt_long +
+ +trending_up + 8% + +
+

Transaction Count

+

184

+

Avg. Ticket: ₹232.88

+
+ +
+
+
+speaker_group +
+
+ + Live +
+
+

Active Soundboxes

+

04 / 05

+

+error + 1 Device Offline (Main Exit) +

+
+ +
+
+
+account_balance +
+Pending +
+

Next Settlement

+

₹38,200

+

Scheduled: Oct 24, 06:00 AM

+
+
+ +
+ +
+
+

Recent Transactions

+View All +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Transaction IDTimeAmountStatusAction
+

#TXN-88421

+

UPI • GPay

+
10:24:12 AM₹450.00 + + + Settled + + + +
+

#TXN-88419

+

UPI • PhonePe

+
10:22:05 AM₹1,200.00 + + + Settled + + + +
+

#TXN-88418

+

UPI • Paytm

+
10:18:44 AM₹85.50 + + + Pending + + + +
+

#TXN-88415

+

UPI • BHIM

+
09:55:12 AM₹210.00 + + + Failed + + + +
+
+
+ +
+
+

Device Health

+
+ +
+
+
+speaker +
+
+

Main Counter

+

SN: SB-2091-AX

+
+
+
+

Online

+

88% Batt

+
+
+ +
+
+
+speaker +
+
+

Patio Station

+

SN: SB-2092-AX

+
+
+
+

Online

+

42% Batt

+
+
+ +
+
+
+speaker_group +
+
+

Main Exit

+

SN: SB-2104-CZ

+
+
+
+

Offline

+

Low Signal

+
+
+
+ +
+ +
+
+Special Offer +
Upgrade to Gen-2 Soundboxes
+

Get 20% off and 5G connectivity for better reliability.

+ +
+ +
+
+
+
+ +
+
+
+history_edu +
+
+

Compliance Status

+

Your KYC verification is active and valid until Dec 2025.

+
+
+
+Verified +more_vert +
+
+
+ + + +' + \ No newline at end of file diff --git a/ui/merchant-detail-view/index.html b/ui/merchant-detail-view/index.html new file mode 100644 index 0000000..0fc5934 --- /dev/null +++ b/ui/merchant-detail-view/index.html @@ -0,0 +1,595 @@ + + + + + +Merchant Detail | Soundbox Ops + + + + + + + + + + + + +
+
+
+search + +
+
+
+
+ + +
+
+
+

Admin User

+

Super Administrator

+
+Administrator Profile +
+
+
+ +
+ +
+ +
+
+

Kopi Kenangan - GI Mall

+
+ + Active + + + Fee Profile: Flat 0.7% + +ID: MID-98234-KK +
+
+
+ + +
+
+
+ +
+
+

GMV Today

+
+

Rp12.500.000

+

+12.4%

+
+
+
+

Active Devices

+
+

4

+

5 Registered

+
+
+
+

Success Rate

+
+

100%

+

Optimal

+
+
+
+ +
+ + + + + +
+ +
+ +
+
+
+

Business Information

+
+
+
+

Legal Entity Name

+ +
+
+

Tax ID (NPWP)

+

01.234.567.8-012.000

+
+
+

Registered Business Address

+

Grand Indonesia, East Mall, Level LG. Jl. M.H. Thamrin No.1, Jakarta Pusat, 10310

+
+
+

Business Category

+

Food & Beverage (Coffee Shop)

+
+
+

Onboarding Date

+

Oct 12, 2023

+
+
+
+ +
+
+

Settlement Bank Account

+
+
+
+account_balance +
+
+

Bank Central Asia (BCA)

+

Account Number: 5020129481

+

Beneficiary: PT Bumi Berkah Boga

+
+
+ Verified +
+
+
+
+ +
+ +
+
+

PIC Contact

+contact_page +
+
+
+
JD
+
+

John Doe

+

Operations Manager

+
+
+
+
+mail +j.doe@kopikenangan.id +
+
+phone ++62 812-3456-7890 +
+
+
+
+ +
+
+ +
+
+
+

GPS: -6.1951, 106.8231

+ +
+
+
+
+ +
+
+
+terminal +

Audit Logs & Raw Payloads

+
+ +
+
+
+
+ +
+
+check_circle +
+
+

Update Successful

+

Merchant details have been synced.

+
+
+ + + + +' + diff --git a/ui/merchant-list-management/index.html b/ui/merchant-list-management/index.html new file mode 100644 index 0000000..d6b7c7b --- /dev/null +++ b/ui/merchant-list-management/index.html @@ -0,0 +1,635 @@ + + + + + +Merchant Management | Soundbox Ops + + + + + + + + + + + +
+
+
+search + +
+
+
+ + +
+
+
+

Alex Rivera

+

Senior Administrator

+
+Administrator Profile +
+
+
+ +
+ +
+
+

Merchant Management

+

Oversee and manage the complete merchant lifecycle and compliance.

+
+ +
+ +
+
+search + +
+
+ + + +
+
+ +
+
+ + + + + + + + + + + + + + +
IDMerchant NameCategoryOutletsDevicesStatusLast TransactionActions
+
+ +
+

+ Showing 1 - 5 of 124 merchants +

+
+ + + + +... + + +
+
+
+ +
+
+

Total Merchants

+

1,248

+
+trending_up ++12.5% +vs last month +
+
+
+

Active Devices

+

4,812

+
+trending_up ++4.2% +vs last month +
+
+
+

Pending KYM

+

24

+
+priority_high +Needs attention +
+
+
+

Total Volume

+

$2.4M

+
+trending_up ++18.4% +vs last month +
+
+
+
+ +
+
+
+

Merchant Details

+ +
+
+ +
+
+ +
+
+

Artisan Brew Co.

+ACTIVE +
+
+
+
+
+

Merchant ID

+

MCH-88291

+
+
+

Tax ID

+

TX-992-001

+
+
+
+

Operational Health

+
+
+
+

94% Positive Uptime

+
+
+

Recent System Audit

+
+ +
{
+  "event": "SETTLEMENT_BATCH_CLOSE",
+  "batch_id": "BT-7721",
+  "total": 4500.22,
+  "status": "SUCCESS",
+  "timestamp": "2023-10-24T14:22:11Z"
+}
+
+
+
+
+
+ + +
+
+
+ + + + +' + diff --git a/ui/merchant-login-portal/index.html b/ui/merchant-login-portal/index.html new file mode 100644 index 0000000..3bbd886 --- /dev/null +++ b/ui/merchant-login-portal/index.html @@ -0,0 +1,286 @@ + + + + + +Soundbox Ops - Merchant Login + + + + + + + + + +
+ +
+ +
+
+ +
+Soundbox Ops Logo +

Merchant Control Center

+

Selamat datang kembali. Silakan masuk untuk mengelola transaksi Anda.

+
+ +
+
+ +
+mail + +
+
+
+
+ +Lupa Kata Sandi? +
+
+lock + + +
+
+
+ + +
+ +
+ +
+

+ Belum memiliki akun merchant? + Register as Merchant +

+
+
+
+ + +
+ + + + + +' + \ No newline at end of file diff --git a/ui/merchant-login/index.html b/ui/merchant-login/index.html new file mode 100644 index 0000000..3bbd886 --- /dev/null +++ b/ui/merchant-login/index.html @@ -0,0 +1,286 @@ + + + + + +Soundbox Ops - Merchant Login + + + + + + + + + +
+ +
+ +
+
+ +
+Soundbox Ops Logo +

Merchant Control Center

+

Selamat datang kembali. Silakan masuk untuk mengelola transaksi Anda.

+
+ +
+
+ +
+mail + +
+
+
+
+ +Lupa Kata Sandi? +
+
+lock + + +
+
+
+ + +
+ +
+ +
+

+ Belum memiliki akun merchant? + Register as Merchant +

+
+
+
+ + +
+ + + + + +' + \ No newline at end of file diff --git a/ui/merchant-settlement-history/index.html b/ui/merchant-settlement-history/index.html new file mode 100644 index 0000000..c233b07 --- /dev/null +++ b/ui/merchant-settlement-history/index.html @@ -0,0 +1,633 @@ + + + + + +Merchant Settlement History - Soundbox Ops + + + + + + + + + + +
+ +
+
+

Settlement History

+
+
+
+ +search +
+
+notifications +settings +
+Administrator Avatar +
+
+
+
+ +
+ +
+ +
+
+
+

Available Balance

+

$12,480.50

+
+
+account_balance +
+
+
+ +trending_up + 8.2% + +vs last week +
+
+ +
+
+
+

Next Payout Date

+

Oct 24, 2023

+
+
+event +
+
+
+

Estimated: $3,150.00

+
+
+ +
+
+
+

Total Settled (MTD)

+

$45,210.00

+
+
+payments +
+
+
+ +trending_up + 12.5% + +vs last month +
+
+
+ +
+
+
+Period: + +
+
+Status: + +
+
+
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Settlement IDDateBank AccountGross AmountNet AmountStatusActions
SET-902341Oct 21, 2023HDFC •••• 4492$2,450.00$2,401.00 + + Processed + + + +
SET-902339Oct 20, 2023HDFC •••• 4492$1,800.00$1,764.00 + + Pending + + +Awaiting Bank +
SET-902331Oct 19, 2023ICICI •••• 1102$4,120.00$4,037.60 + + Processed + + + +
SET-902325Oct 18, 2023HDFC •••• 4492$500.00$490.00 + + Failed + + + +
SET-902311Oct 17, 2023HDFC •••• 4492$3,600.00$3,528.00 + + Processed + + + +
+
+ +
+

Showing 1 - 5 of 124 disbursements

+
+ + + + + +
+
+
+ +
+
+
+

Weekly Settlement Volume

+
+ + Gross + + + Net + +
+
+
+ +
+
+
+
+
+Mon +
+
+
+
+
+
+Tue +
+
+
+
+
+
+Wed +
+
+
+
+
+
+Thu +
+
+
+
+
+
+Fri +
+
+
+
+
+
+Sat +
+
+
+
+
+
+Sun +
+
+
+ +
+

+info + Settlement Cycle +

+
+
+
+
1
+
+
+
+

Daily Batching

+

Transactions from 12:00 AM to 11:59 PM are batched for settlement.

+
+
+
+
+
2
+
+
+
+

Fee Deduction

+

Processing fees (approx. 2%) are automatically calculated and deducted.

+
+
+
+
+
3
+
+
+

Direct Deposit

+

Funds reach your bank account within T+1 working days.

+
+
+
+ +
+
+
+ +
+
+

Settlement Details

+ +
+
+
+

Net Amount Paid

+

$2,401.00

+ + Successful Transfer + +
+
+

Breakdown

+
+Gross Processing Volume +$2,450.00 +
+
+Platform Fees (2%) +-$49.00 +
+
+Adjustment/Refunds +$0.00 +
+
+Total Disbursed +$2,401.00 +
+
+
+

Destination

+
+
+account_balance +
+
+

HDFC Bank India

+

Checking Account •••• 4492

+
+
+
+
+

Transfer Log

+
+
+
+

Transfer Initiated

+

Oct 21, 2023 • 09:12 AM

+
+
+
+

Bank Processing

+

Oct 21, 2023 • 11:45 AM

+
+
+
+

Funds Credited

+

Oct 21, 2023 • 04:30 PM

+
+
+
+
+ + +
+
+
+ + +
+ + + +' + \ No newline at end of file diff --git a/ui/onboarding-bank-account/index.html b/ui/onboarding-bank-account/index.html new file mode 100644 index 0000000..5c2812d --- /dev/null +++ b/ui/onboarding-bank-account/index.html @@ -0,0 +1,333 @@ + + + + + +Soundbox Ops - Merchant Onboarding + + + + + + + + + +
+
+account_balance +
+Soundbox Ops +MERCHANT ONBOARDING +
+
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+check +
+Business Profile +
+ +
+
+check +
+Verification Docs +
+ +
+
+3 +
+Settlement Account +
+ +
+
+4 +
+Device Selection +
+
+ +
+ +
+

Settlement Details

+

+ Link the bank account where you wish to receive daily settlements from your Soundbox transactions. +

+
+
+verified_user +
+Secure Verification +

Real-time penny-drop validation via IMPS/NEFT networks.

+
+
+
+schedule +
+Daily T+1 Settlement +

Funds reach your account within 24 hours of merchant closure.

+
+
+
+
+ +
+
+ +
+ACCOUNT INFORMATION +
+
+ +
+ +
+ +
+ +expand_more +
+
+ +
+ + +
+ +
+ + +

Account number is encrypted and processed via secure channels.

+
+
+ +
+ + + +
+ +
+ + +
+
+
+
+ +
+
+
+lock +PCI-DSS COMPLIANT +
+
+security +AES-256 ENCRYPTION +
+
+

© 2024 Soundbox Ops Infrastructure. All transaction data is processed through authorized banking gateways.

+
+
+
+ + + +' + \ No newline at end of file diff --git a/ui/onboarding-business-info/index.html b/ui/onboarding-business-info/index.html new file mode 100644 index 0000000..2afd1a9 --- /dev/null +++ b/ui/onboarding-business-info/index.html @@ -0,0 +1,310 @@ + + + + + +Soundbox Ops - Merchant Onboarding + + + + + + + + + + + +
+ +
+ +
+
+ +
+ +
+ +
+
1
+Business Info +
+
+
2
+PIC Details +
+
+
3
+Bank Account +
+
+
4
+Documents +
+
+
+ +
+
+

Business Information

+

Please provide the legal details of your business to begin the onboarding process.

+
+
+
+ +
+ + +

Must match the name on your NIB or Akta Pendirian.

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+
+
+
+ + +
+ + + + +' + \ No newline at end of file diff --git a/ui/onboarding-document-upload/index.html b/ui/onboarding-document-upload/index.html new file mode 100644 index 0000000..474d67b --- /dev/null +++ b/ui/onboarding-document-upload/index.html @@ -0,0 +1,380 @@ + + + + + +Merchant Onboarding - KYC Documentation + + + + + + + + + +
+
+
+soundpod +
+Soundbox Ops +Merchant Onboarding +
+
+ +
+ +
+
+
+ +
+
+ +
+
+check +
+Business info +
+
+
+check +
+Bank Account +
+
+
+check +
+Address +
+
+
4
+KYC Upload +
+
+
5
+Review +
+
+
+
+
+
+ +
+

Verify Your Business Identity

+

Please upload the required documents to comply with financial regulations and secure your merchant account. Ensure all photos are clear and legible.

+
+
+ +
+
+
+
+badge +
+
+

Identity Card (KTP)

+

Valid national ID card of the business owner or director.

+
+
+
+Formats: JPG, PNG, PDF +Max size: 5MB +
+
+
+
+ +
+upload_file +
+

Click to upload or drag and drop

+

KTP Front View. Ensure name and photo are visible.

+
+
+
+ +
+
+ +
+
+
+
+account_balance_wallet +
+
+

Tax ID (NPWP)

+

Company or individual tax identification card.

+
+
+
+Formats: JPG, PNG, PDF +Max size: 5MB +
+
+
+
+ +
+file_present +
+

Click to upload or drag and drop

+

Scan or photo of the NPWP card.

+
+
+
+
+
+ +
+
+
+
+add_a_photo +
+
+

Business Location Photo

+

Storefront photo showing business name and signage.

+
+
+
+Formats: JPG, PNG +Max size: 10MB +
+
+
+
+
+ +
+ + +
+
+Validated +
+
+
+ +
+photo_camera +

Upload interior or signage photo

+
+
+
+
+
+
+ +
+info +
+

Our Review Process

+

KYC documents are reviewed within 24-48 business hours. You will receive an email notification once your account status is updated. Providing high-quality, un-cropped photos will expedite this process.

+
+
+ +
+ +
+ + +
+
+
+ + + + + +' + \ No newline at end of file diff --git a/ui/onboarding-pic-details/index.html b/ui/onboarding-pic-details/index.html new file mode 100644 index 0000000..94f8f33 --- /dev/null +++ b/ui/onboarding-pic-details/index.html @@ -0,0 +1,368 @@ + + + + + +Merchant Onboarding - PIC Details + + + + + + + + + +
+
+
+payments +
+
+Soundbox Ops +Merchant Portal +
+
+
+
+help_outline +Support +
+
+
+
+

Session ID

+

#OB-99281

+
+
+
+
+
+ +
+
+ +
+
+ +
+
+check +
+Business Info +
+ +
+
+2 +
+PIC & Contact +
+ +
+
+3 +
+Documents +
+ +
+
+4 +
+Review +
+
+
+
+ +
+
+
+

Person in Charge (PIC)

+

Please provide the contact details of the primary person responsible for operational decisions.

+
+
+ +
+ +
+person + +
+
+ +
+
+ + +
+
+ +
+badge + +
+
+
+ +
+ +
+mail + +
+

This will be used for official settlements and system alerts.

+
+ +
+
+
+ +
+
+ID Flag ++62 +
+ +
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+ +
+
+verified_user +
+
+

Why we need this

+

+ To maintain a secure financial network, we require a verified Point of Contact. This ensures operational alerts, settlement reports, and device updates reach the authorized personnel promptly. +

+
+
+ +
+
+
+shield +Enterprise Security +
+

+ Your data is encrypted using AES-256 standards. We never share personal contact details with third-party marketers. +

+
+ +
+lock +
+
+ +
+

Need help with the onboarding process?

+ +
+
+
+
+ +
+
+ + + +' + \ No newline at end of file diff --git a/ui/outlet-branch-management/index.html b/ui/outlet-branch-management/index.html new file mode 100644 index 0000000..e7cfa66 --- /dev/null +++ b/ui/outlet-branch-management/index.html @@ -0,0 +1,758 @@ + + + + + +Outlet Management | Soundbox Ops + + + + + + + + + + + +
+
+search + +
+
+
+ + +
+
+
+
+

Alex Thompson

+

System Administrator

+
+Administrator Profile +
+
+
+ +
+ +
+
+ +

Outlet Registry

+

Managing 14 operational nodes across 3 regions for Global Retail Group.

+
+
+ + +
+
+ +
+
+

Total Outlets

+
+14 + +trending_up + 2 + +
+

Active across 4 merchant accounts

+
+
+

Active Devices

+
+142 + +check_circle + 98% + +
+

3 currently in maintenance

+
+
+

Daily GMV

+
+$124,502 + +trending_up + 12.4% + +
+

Vs. previous 24h average

+
+
+

Health Score

+
+A+ + +verified + Stable + +
+

Network latency: 142ms avg

+
+
+ +
+
+

Outlet Fleet

+
+ + +
+
+
+
+ + + +
+ + + + + + + + + + + + +
Outlet NameLocationActive DevicesDaily GMVStatusActions
+
+ +
+

Showing 1-4 of 14 outlets

+
+ + + + + +
+
+
+
+ + + +
+
+ +
+
+

Outlet Details

+

Registry Details & Performance

+
+ +
+ +
+ +
+Outlet Location Map +
+

+location_on + View on Live Map +

+
+
+ +
+
+

Weekly Volume

+

$84,203.44

+

+arrow_upward + 8.2% +

+
+
+

Avg. Transaction

+

$42.10

+

Based on 2.4k txn

+
+
+ +
+

Device Fleet Status

+
+
+
+
+
+
+
+

22 Active Soundboxes

+

Transmitting real-time payment alerts.

+

LATENCY: 114ms

+
+
+
+
+
+
+
+
+

2 Provisioning

+

Awaiting SIM activation & merchant link.

+

Action Required

+
+
+
+
+
+
+
+

Next Scheduled Audit

+

Compliance check and hardware diagnostic.

+

DUE IN: 14 DAYS

+
+
+
+
+ +
+
+

Configuration Metadata

+ +
+
+
{
+  "outlet_id": "OUT-49201-DF",
+  "merchant_group": "GLOBAL_RETAIL_001",
+  "region": "US-WEST-1",
+  "capabilities": ["SOUND_ALERTS", "QR_DYNAMIC"],
+  "encryption_level": "AES-256",
+  "last_heartbeat": "2023-11-24T10:42:12Z",
+  "status": "OPERATIONAL"
+}
+
+
+
+ +
+ + +
+
+
+ + + + +' + diff --git a/ui/qris-soundbox-logo/index.html b/ui/qris-soundbox-logo/index.html new file mode 100644 index 0000000..3505ebf --- /dev/null +++ b/ui/qris-soundbox-logo/index.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ui/settlement-batch-management/index.html b/ui/settlement-batch-management/index.html new file mode 100644 index 0000000..32a1a5a --- /dev/null +++ b/ui/settlement-batch-management/index.html @@ -0,0 +1,493 @@ + + + + + +Soundbox Ops - Disbursement Batches + + + + + + + + + + + + + + + + +
+ +
+
+
+search + +
+ +
+
+ + +
+Administrator Profile +
+
+ +
+ +
+
+

Disbursement Batches

+

Manage and track bulk merchant payouts across all bank partners.

+
+ +
+ +
+
+

Pending Payouts

+
+

₹ 14.2M

+ +trending_up 12% + +
+
+
+

Processing

+
+

24

+ +sync Active + +
+
+
+

Avg. Success Rate

+
+

99.4%

+ +check_circle Stable + +
+
+
+

Total Fees (MTD)

+
+

₹ 420K

+vs ₹ 380K +
+
+
+ +
+
+
+account_balance + +
+
+calendar_month +Oct 01 - Oct 15, 2023 +
+ +
+
+Sorted by Batch Date + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Batch IDPeriodMerchantsGross AmountTotal FeesPayout AmountStatus
#BAT-20231015-01Oct 15, 08:00 - 12:001,240₹ 4,24,500.00₹ 1,240.00₹ 4,23,260.00 + + Completed + + +chevron_right +
#BAT-20231015-02Oct 15, 12:00 - 16:00850₹ 2,12,000.00₹ 850.00₹ 2,11,150.00 + + Processing + + +chevron_right +
#BAT-20231014-42Oct 14, 20:00 - 23:59412₹ 95,400.00₹ 412.00₹ 94,988.00 + + Failed + + +chevron_right +
#BAT-20231014-41Oct 14, 16:00 - 20:002,104₹ 12,45,200.00₹ 2,104.00₹ 12,43,096.00 + + Completed + + +chevron_right +
#BAT-20231014-40Oct 14, 12:00 - 16:001,892₹ 8,12,050.00₹ 1,892.00₹ 8,10,158.00 + + Completed + + +chevron_right +
+ +
+

Showing 1 - 5 of 248 batches

+
+ + + + +... + + +
+
+
+ +
+
+

Batch Details

+ +
+
+
+
+Batch ID +#BAT-20231015-01 +
+
+Settlement Bank + +HDFC + HDFC Bank Ltd. + +
+
+
+

Verification Progress

+
+
+
+

100% KYC Verified & Cleaned

+
+
+

Timeline

+
+
+ +

Batch Initialized

+

Oct 15, 2023 • 08:00 AM

+
+
+ +

Merchant Ledger Locked

+

Oct 15, 2023 • 08:15 AM

+
+
+ +

Bank File Uploaded (SFTP)

+

Oct 15, 2023 • 09:30 AM

+
+
+ +

Batch Settled

+

Oct 15, 2023 • 11:45 AM

+
+
+
+
+

Raw API Response

+
+ +
{
+  "batch_id": "BAT-20231015-01",
+  "status": "COMPLETED",
+  "merchant_count": 1240,
+  "net_payout": 423260.00,
+  "currency": "INR",
+  "bank_ref": "HDFC_91230491_SET"
+}
+
+
+
+
+ + +
+
+
+
+ + + +' + \ No newline at end of file diff --git a/ui/shared/admin-api.js b/ui/shared/admin-api.js new file mode 100644 index 0000000..867da61 --- /dev/null +++ b/ui/shared/admin-api.js @@ -0,0 +1,142 @@ +const ADMIN_TOKEN_KEY = "admin_token"; + +function formatMoney(value) { + const number = Number(value || 0); + if (!Number.isFinite(number)) { + return "Rp 0"; + } + return new Intl.NumberFormat("id-ID", { + style: "currency", + currency: "IDR", + maximumFractionDigits: 0 + }).format(number); +} + +function formatDateTime(value) { + if (!value) { + return "-"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat("en-GB", { + dateStyle: "medium", + timeStyle: "short" + }).format(date); +} + +function buildQuery(query) { + const search = new URLSearchParams(); + Object.entries(query || {}).forEach(([key, val]) => { + if (val !== undefined && val !== null && val !== "") { + search.append(key, String(val)); + } + }); + return search.toString(); +} + +async function adminFetch(path, options = {}) { + const token = localStorage.getItem(ADMIN_TOKEN_KEY); + const { + method = "GET", + query, + body, + headers: extraHeaders = {}, + auth = true + } = options; + + const suffix = buildQuery(query || {}); + const url = suffix ? `${path}?${suffix}` : path; + const headers = { + ...extraHeaders + }; + + if (auth) { + if (!token) { + throw new Error("ADMIN_AUTH_MISSING"); + } + headers.Authorization = `Bearer ${token}`; + } + + if (method !== "GET" && body !== undefined) { + headers["Content-Type"] = "application/json"; + } + + const response = await fetch(url, { + method, + headers, + body: method === "GET" || body === undefined ? undefined : JSON.stringify(body) + }); + + const raw = await response.text(); + let payload; + try { + payload = raw ? JSON.parse(raw) : {}; + } catch (error) { + payload = {}; + } + + if (!response.ok) { + const message = + payload?.message || + payload?.error || + `Request failed with status ${response.status}`; + throw new Error(message); + } + + return payload?.data !== undefined ? payload.data : payload; +} + +window.AdminUIAPI = { + ADMIN_TOKEN_KEY, + setToken: (token) => localStorage.setItem(ADMIN_TOKEN_KEY, token), + getToken: () => localStorage.getItem(ADMIN_TOKEN_KEY), + clearToken: () => localStorage.removeItem(ADMIN_TOKEN_KEY), + requireToken: () => { + const token = localStorage.getItem(ADMIN_TOKEN_KEY); + if (!token) { + window.location.href = "/ui/admin-login"; + throw new Error("ADMIN_AUTH_MISSING"); + } + return token; + }, + login: async ({ username, password }) => { + const data = await adminFetch("/admin/login", { + method: "POST", + auth: false, + body: { username, password } + }); + if (data?.token) { + window.AdminUIAPI.setToken(data.token); + } + return data; + }, + listMerchants: () => adminFetch("/admin/merchants"), + listOutlets: (query) => adminFetch("/admin/outlets", { query }), + getOutlet: (id) => adminFetch(`/admin/outlets/${id}`), + getMerchant: (id) => adminFetch(`/admin/merchants/${id}`), + patchMerchant: (id, payload) => + adminFetch(`/admin/merchants/${id}`, { + method: "PATCH", + body: payload + }), + approveMerchant: (id) => adminFetch(`/admin/merchants/${id}/approve`, { method: "POST" }), + rejectMerchant: (id, payload) => + adminFetch(`/admin/merchants/${id}/reject`, { + method: "POST", + body: payload || {} + }), + listTerminals: (query) => adminFetch("/admin/terminals", { query }), + getTerminal: (id) => adminFetch(`/admin/terminals/${id}`), + listDevices: (query) => adminFetch("/admin/devices", { query }), + getDevice: (id) => adminFetch(`/admin/devices/${id}`), + getDeviceHeartbeats: (id, query) => + adminFetch(`/admin/devices/${id}/heartbeats`, { query }), + listTransactions: (query) => adminFetch("/admin/transactions", { query }), + getDashboardSummary: () => adminFetch("/admin/dashboard/summary"), + formatMoney, + formatDateTime +}; diff --git a/ui/soundbox-ops/index.html b/ui/soundbox-ops/index.html new file mode 100644 index 0000000..bab3145 --- /dev/null +++ b/ui/soundbox-ops/index.html @@ -0,0 +1,23 @@ + + + + + + soundbox-ops + + + +

soundbox-ops

+

Folder desain ini hanya menyediakan DESIGN.md (tanpa code.html).

+

Kembali ke katalog

+ + +' + + diff --git a/ui/transaction-history-monitoring/index.html b/ui/transaction-history-monitoring/index.html new file mode 100644 index 0000000..1c86fc0 --- /dev/null +++ b/ui/transaction-history-monitoring/index.html @@ -0,0 +1,857 @@ + + + + + +Transactions | Soundbox Ops + + + + + + + + + + + +
+
+
+search + +
+ +
+
+ + +
+
+
+

Admin User

+

Super Admin

+
+Administrator Profile +
+
+
+ +
+ +
+
+

Total Volume (24h)

+
+

Rp 1.42B

+ +trending_up + +12.4% + +
+
+
+

Success Rate

+
+

99.92%

+ +check_circle + Stable + +
+
+
+

Pending Settlements

+
+

142

+ +schedule + -5.2% + +
+
+
+

Active QRIS Soundboxes

+
+

1,894

+ +sensors + 98% Online + +
+
+
+ +
+
+Status: + +
+
+Merchant: + +
+
+Outlet: + +
+
+Terminal: + +
+
+Range: + +to + +
+
+ + + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + +
TimestampTransaction IDMerchant NameAmountFeeNetStatus
Loading transactions...
+
+ +
+

Showing 1 - 10 of 1,284 transactions

+
+ + + + + +
+
+
+
+ +
+ + + + + + +