53 Commits
v0.5.3 ... main

Author SHA1 Message Date
oxpoe
81d6e79e90 fix(telegram): multi admin ID, chunk pesan, IPv4
Some checks are pending
CI / test (push) Waiting to run
- admin ID dipisah koma/titik-koma/spasi
- notifyAdmin chunk 4000 char + fallback plain
- dns ipv4first untuk hindari aggregate error
- versi 0.6.15
2026-11-06 11:22:04 +07:00
oxpoe
31e8f46e03 feat(portal): kustomisasi banner, status, hero, sosial dari Pengaturan
All checks were successful
CI / test (push) Successful in 1m41s
- banner + warna, status jaringan, hero badge/title/subtitle
- link maps/instagram/facebook/tiktok di footer
- boolean portal_banner_enabled
- versi 0.6.14
2026-11-06 11:13:44 +07:00
oxpoe
e2ebc079f1 fix(webhook): dukung variasi notifikasi ShopeePay
Some checks failed
CI / test (push) Has been cancelled
- kata 'ditambahkan'/'telah ditambahkan' dianggap pembayaran masuk
- regex nominal mendukung frasa tersebut
- versi 0.6.13
2026-11-06 11:11:47 +07:00
oxpoe
19096b36e3 fix(acs): dukung virtual parameters GenieACS Pro
Some checks failed
CI / test (push) Has been cancelled
- pppoeIP/pppIP/WanIP/IPTR069, pppoeUsername2/PPPoEUser, WlanSSID
- lookup PPPoE & tampilan IP/username lebih akurat
- versi 0.6.12
2026-11-06 11:11:19 +07:00
oxpoe
2658efd89d feat(acs): modal atur SSID & sandi dari daftar perangkat
All checks were successful
CI / test (push) Successful in 1m36s
- tombol Atur WiFi di menu aksi
- tampilkan SSID 2.4G/5G saat ini
- endpoint simpan SSID & sandi terpisah
- versi 0.6.11
2026-11-06 11:04:25 +07:00
oxpoe
f66c3e2b7f fix(acs): fallback lookup perangkat via data pelanggan
All checks were successful
CI / test (push) Successful in 1m37s
- cari lewat pppoe/genieacs_tag/mac/phone bila tag/ID tidak cocok
- mengurangi lookup gagal & dashboard timeout
- versi 0.6.10
2026-11-06 11:02:12 +07:00
oxpoe
de120f08a9 fix(acs): SSID hanya SSID1/SSID5, split task 9007, RX Power/LOS
Some checks failed
CI / test (push) Has been cancelled
- port updateSSID/updatePassword dari upstream
- tidak menyentuh SSID2/Guest
- setParameterValues per parameter
- RX power negatif/raw + LOS
- versi 0.6.9
2026-11-06 11:00:29 +07:00
oxpoe
5361c3a1c1 feat(isolir): URL halaman isolir & port redirector di Pengaturan
All checks were successful
CI / test (push) Successful in 1m34s
- isolir_portal_url, isolir_redirect_port
- redirector baca setting per request (tanpa restart)
- generator cloud memakai URL tersebut
- versi 0.6.8
2026-11-06 05:52:02 +07:00
oxpoe
094c47c15d feat(isolir): generator script cloud sederhana
All checks were successful
CI / test (push) Successful in 1m38s
- tab Cloud di modal Script Isolir
- resolve semua A record, warn Cloudflare/CDN
- hook PPPoE, DNS redirect, NAT HTTP, filter allow/reject/drop
- API /admin/api/isolir-cloud-script
- versi 0.6.7
2026-11-06 05:49:15 +07:00
oxpoe
0f7affc498 feat(qr-custom): filter tanggal + pilih baris & hapus terpilih
All checks were successful
CI / test (push) Successful in 1m37s
- filter from/to pada list
- checkbox pilih semua, batch delete dengan password admin
- reset histori tetap
- versi 0.6.6
2026-11-06 05:11:50 +07:00
oxpoe
a2888810ad feat(qr-custom): hapus satu & reset histori dengan password admin
All checks were successful
CI / test (push) Successful in 1m47s
- endpoint delete/reset, verifikasi admin_password
- tombol hapus di detail + reset di header histori
- versi 0.6.5
2026-11-06 05:08:30 +07:00
oxpoe
8fcb50e16a fix(qr-custom): center QR, timer berhenti, refund saat lunas, regenerate expired
All checks were successful
CI / test (push) Successful in 1m44s
- link publik: QR center, nominal/QR/timer disembunyikan saat expired
- reject/refund untuk paid
- regenerate QR expired (kode unik baru) di detail & tabel
- versi 0.6.4
2026-11-06 05:05:58 +07:00
oxpoe
b72dbf3e55 fix(webhook): auto secret token + URL dari public base/host
All checks were successful
CI / test (push) Successful in 2m5s
- ensureWebhookSecret saat boot dan buka Pengaturan
- URL webhook memakai public_base_url fallback host
- tolak default billing-rtrw-secret-key
- versi 0.6.3
2026-11-06 04:55:12 +07:00
oxpoe
6f6960ce42 fix(webhook): sertakan matched_custom_qr_id di respons
All checks were successful
CI / test (push) Successful in 2m3s
2026-11-06 04:47:56 +07:00
oxpoe
fd46731a53 fix(qr-custom): ikon menu + disable sampai QRIS statis siap
All checks were successful
CI / test (push) Successful in 1m43s
- icon bi-qr-code
- menu disembunyikan bila QRIS belum aktif/payload kosong
- form & POST ditolak, tidak membuat data tanpa QR
- versi 0.6.1
2026-11-06 04:38:12 +07:00
oxpoe
bf25260d4c feat: QR-Custom Payment + mode webhook secret GOWA
All checks were successful
CI / test (push) Successful in 1m35s
- tabel custom_qr_payments, service, halaman admin + publik share
- kode unik 20 menit, warning bila bayar setelah batas, manual pay/accept/reject
- webhook payment-notif mencocokkan nominal custom
- menu sidebar QR-Custom Payment
- GOWA secret: auto generate (locked) / manual (simpan lalu locked, ubah)
- versi 0.6.0
2026-11-06 04:32:06 +07:00
oxpoe
973d9e3a14 feat(gowa): tombol salin webhook secret
All checks were successful
CI / test (push) Successful in 1m36s
- input secret diberi id + tombol copy
- feedback ikon centang
- versi 0.5.20
2026-11-06 04:06:45 +07:00
oxpoe
848dd49cce feat(update): floating notif + banner saat restart diperlukan
All checks were successful
CI / test (push) Successful in 1m32s
- marker .restart-required ditulis setelah update sukses
- API /admin/api/restart-required + polling 30s di sidebar
- banner di halaman Update sampai restart
- marker dihapus saat boot proses baru
- versi 0.5.19
2026-11-06 04:01:16 +07:00
oxpoe
ad62aab07d feat(gowa): device id kosong ikut device terhubung + auto webhook secret
All checks were successful
CI / test (push) Successful in 1m45s
- hapus auto-pin gowa_device_id pada QR/status
- hint device id + secret otomatis
- ensureGowaWebhookSecret saat boot & simpan pengaturan
- versi 0.5.18
2026-11-06 03:50:06 +07:00
oxpoe
fadbf0084b fix(gowa): status device array, auto-save device id, QR hide saat terhubung
All checks were successful
CI / test (push) Successful in 1m53s
- status route normalisasi results array + auto isi gowa_device_id
- UI poll status 3s, sembunyikan QR saat open
- resolveDeviceId utamakan device connected
- versi 0.5.17
2026-11-06 03:38:00 +07:00
oxpoe
8a312485a7 feat(update): kotak panduan restart self-hosted & cloud
All checks were successful
CI / test (push) Successful in 2m21s
- PM2, manual pkill, quadlet/systemd, compose
- cloud: otomatis saat update berikutnya / hubungi admin server
- versi 0.5.16
2026-11-05 17:57:03 +07:00
oxpoe
280a8ac9a6 fix(update): pertahankan .env.gowa + hapus checkout pathspec
All checks were successful
CI / test (push) Successful in 3m35s
- .env.gowa masuk preserved entries dan clean exclude
- settings.json/database tidak di-checkout (tidak dilacak git)
- .env.gowa di .gitignore
- versi 0.5.15
2026-11-05 17:53:14 +07:00
oxpoe
1e3067989a feat(settings): tombol Test API di AI (tanpa simpan)
All checks were successful
CI / test (push) Successful in 2m26s
- endpoint POST /admin/settings/test-ai (provider, key, base URL, model)
- UI status valid/tidak + latensi
- versi 0.5.14
2026-11-05 17:49:54 +07:00
oxpoe
7db4cb81a3 fix(wa): notifikasi LUNAS lewat gateway aktif + approve APK
All checks were successful
CI / test (push) Successful in 2m26s
- isConnected GOWA: tangani /devices array (state connected)
- app-customer payment-notif: pakai waNotify, bukan Baileys
- customerAPI approve kolektor + payment-unmatched: kirim WA lunas
- versi 0.5.13
2026-11-05 17:32:31 +07:00
oxpoe
310e91ed97 fix(gowa): Test Koneksi pakai nilai form tanpa simpan
All checks were successful
CI / test (push) Successful in 2m42s
- testReachability menerima override baseUrl/username/password
- UI mengirim nilai form
- versi 0.5.12
2026-11-05 17:08:08 +07:00
oxpoe
f8e6e86619 feat(gowa): tombol Test Koneksi (health, auth, jumlah device)
All checks were successful
CI / test (push) Successful in 2m44s
- service testReachability: /health + /devices
- route POST /admin/whatsapp/test-gowa-connection
- UI status warna hijau/kuning/merah
- versi 0.5.11
2026-11-05 17:01:13 +07:00
oxpoe
b49822b553 feat(gowa): pilihan mode Cloud HTTPS / Local HTTP + polyfill di pengaturan
All checks were successful
CI / test (push) Successful in 1m53s
- setting gowa_mode, hint dinamis, link wiki
- wiki: halaman GOWA (mode, polyfill, troubleshooting)
- versi 0.5.10
2026-11-05 16:02:44 +07:00
oxpoe
b98ad7568d fix(gowa): QR sebagai data URI + rewrite qr_link ke base URL publik
All checks were successful
CI / test (push) Successful in 2m41s
- gambar tidak lagi bergantung request /qr.png kedua
- qr_link host internal diganti origin base URL
- log saat gambar gagal diambil; onerror di UI
- versi 0.5.9
2026-11-05 15:55:29 +07:00
oxpoe
7a5b488882 fix(gowa): tampilkan status HTTP saat QR gagal (401/kredensial)
All checks were successful
CI / test (push) Successful in 3m32s
2026-11-05 15:46:45 +07:00
oxpoe
e28c9debea docs(gowa): UI butuh secure context (crypto.randomUUID) untuk akses HTTP LAN
All checks were successful
CI / test (push) Successful in 1m57s
2026-11-05 15:38:39 +07:00
oxpoe
861b8f6549 fix(gowa): pakai docker.io prefix agar podman tidak minta short-name
Some checks failed
CI / test (push) Failing after 13m31s
2026-11-05 14:20:40 +07:00
oxpoe
19db67138b feat(gowa): compose GOWA + UI untuk podman/docker
Some checks failed
CI / test (push) Has been cancelled
- docker-compose.gowa.yml (image v9.8.0, volume storages/statics, webhook + basic auth)
- .env.gowa.example
- README: cara jalankan + setting OPEN-ISP
- versi 0.5.7
2026-11-05 14:18:36 +07:00
oxpoe
7bb4ca1066 docs(automation): hint auto-hapus tidak lagi hardcode bulan ke-4
All checks were successful
CI / test (push) Successful in 4m5s
2026-11-05 13:26:21 +07:00
oxpoe
d68cc1d664 fix(qris): selaras skema hangus, satu tagihan terbaru
All checks were successful
CI / test (push) Successful in 5m10s
- batalkan pelunasan multi-tagihan: tagihan lama otomatis void saat tagihan baru terbit
- QRIS manual memakai tagihan terbaru, bukan total tunggakan
- versi 0.5.6
2026-11-05 13:19:14 +07:00
oxpoe
a78e6b766a fix(qris): kirim tagihan manual pakai total tunggakan
All checks were successful
CI / test (push) Successful in 2m43s
- ensureInvoiceQris menerima base total agar kode unik konsisten dengan cron/portal
- pesan manual menampilkan total tagihan, bukan satu invoice
2026-11-05 12:57:19 +07:00
oxpoe
fa791cd3cb docs(readme): pulihkan suntingan README dari remote
All checks were successful
CI / test (push) Successful in 4m14s
- tagline 'self-hosted builtin AI'
- baris link demo / donate / main repo (perbaiki typo openisp -> open-isp)
2026-11-05 12:49:27 +07:00
oxpoe
34d4563a5f fix(qris): kode unik lintas order + pelunasan multi-tagihan; fix kirim tagihan manual
Some checks failed
CI / test (push) Has been cancelled
- webhook QRIS: nominal total tagihan + kode -> lunaskan semua tagihan pelanggan
- isQrisAmountTaken() cek invoice+voucher+donasi (cegah ambigu)
- billingSendService: require('qrcode'), nominal unik + {{qris_kode}}, fallback link
- pilih tagihan terlama; pesan pengingat manual yang jujur saat di-skip
- versi 0.5.5
2026-11-05 12:48:59 +07:00
oxpoe
b34e5a34d8 docs(readme): tambah link demo & donasi
All checks were successful
CI / test (push) Successful in 2m13s
- badge demo -> https://demo-openisp.oxpoe.xyz
- badge + bagian Dukungan: donasi -> https://oxpoe.xyz
- demo online di Mulai Cepat & English quick
- badge versi -> 0.5.4
2026-11-05 11:13:46 +07:00
oxpoe
bc7f2bdb09 feat(wa): notifikasi pelanggan tiket + broadcast status layanan
All checks were successful
CI / test (push) Successful in 2m3s
- WA konfirmasi ke pelanggan saat tiket dibuat (web & APK)
- checkbox + target (semua/area/router) di Status Layanan
- WA gangguan/perbaikan saat mode berubah, WA layanan pulih saat NORMAL
- broadcast latar belakang dengan jeda acak, dedupe nomor, log [NoticeWA]
- versi 0.5.4
2026-11-05 09:28:48 +07:00
oxpoe
8231058f79 fix(update): hapus referensi info.* di handler POST (info is not defined)
All checks were successful
CI / test (push) Successful in 1m44s
2026-11-05 09:11:36 +07:00
oxpoe
fe7682fa45 fix(update): timeout git fetch + tampilkan error di log update
All checks were successful
CI / test (push) Successful in 2m30s
2026-11-05 09:00:45 +07:00
oxpoe
7d77dd1424 feat(branding): nama PT dapat diatur di Pengaturan
All checks were successful
CI / test (push) Successful in 1m36s
- field company_subheader di Info Aplikasi
- dipakai sebagai kop invoice/PDF (sudah ada) dan menggantikan baris footer bila diisi
- default kosong: footer tetap 'Made with <heart> from Oxpoe in INA.'
2026-11-05 06:53:53 +07:00
oxpoe
eef690b809 style(branding): footer landing/login
Some checks failed
CI / test (push) Has been cancelled
- PT Artha Mega Data -> Made with <heart> from Oxpoe in INA.
- Fiber Optic Internet Service -> tiawidianti & irpankomara
2026-11-05 06:51:41 +07:00
oxpoe
c8b1335ff4 fix(settings): kartu AI punya tab sendiri agar filter tab sinkron
All checks were successful
CI / test (push) Successful in 2m8s
- tambah tombol tab 'AI'
- beri data-setcat="ai" pada kartu AI & Self-Healing (sebelumnya selalu tampil di semua tab)
2026-11-05 06:41:27 +07:00
oxpoe
c906bca459 fix(support): /donate redirect ke oxpoe.xyz, hapus sisa teks aktivasi & nomor
All checks were successful
CI / test (push) Successful in 1m57s
- /donate, /donasi, /public/donasi -> 302 https://oxpoe.xyz
- locale id/en: teks kode aktivasi diganti netral, nomor kontak dihapus
- README: Gitea utama, Forgejo mirror read-only
- hapus nomor dari scripts/test-admin.js
2026-11-05 06:39:12 +07:00
oxpoe
205db53d7e fix(ui): link repo ke gitea.oxpoe.xyz, buka semua menu, hapus kontak aktivasi
All checks were successful
CI / test (push) Successful in 1m55s
- ganti sisa URL gitea.oxpoe.qzz.io -> gitea.oxpoe.xyz (sidebar, donate, package.json, projectUrl)
- default menu sidebar semuanya Tampil (tidak ada lagi menu terkunci)
- hapus nomor kontak aktivasi palsu 081947215703 dari UI & pesan route
- form pengaturan sidebar tidak lagi meminta password aktivasi
2026-11-05 06:29:50 +07:00
64e321a350 Update README.md
All checks were successful
CI / test (push) Successful in 1m41s
2026-11-04 22:05:48 +00:00
55f8368214 Update README.md
All checks were successful
CI / test (push) Successful in 4m29s
2026-11-04 21:40:16 +00:00
oxpoe
0ca85544aa fix(customer): kartu akun di sidebar desktop bisa diklik
All checks were successful
CI / test (push) Successful in 2m58s
- blok nama/avatar di sidebar sebelumnya tidak punya handler klik sama sekali
- dijadikan <button> dengan switchTab('profile') + aria-label
- avatar mobile di topbar juga dijadikan <button> agar tap lebih andal
2026-11-05 04:12:52 +07:00
oxpoe
f6ab9bbfa7 feat(update): sumber update bisa diatur (update_base_url)
All checks were successful
CI / test (push) Successful in 4m5s
- setting baru update_base_url (default https://gitea.oxpoe.xyz), menggantikan hardcode gitea.oxpoe.qzz.io
- field di Pengaturan: Sumber Update + Repo Update (owner/nama)
- default project_url ikut diperbarui
2026-11-05 04:05:20 +07:00
oxpoe
1cca67668e perf(docs): konversi screenshot JPG lama ke WebP
Some checks failed
CI / test (push) Failing after 1m41s
Total folder screenshot 2,9 MB -> 0,7 MB (19 gambar).
2026-11-05 03:56:23 +07:00
oxpoe
b612d5d232 perf(docs): screenshot ke WebP — 2,3 MB → 0,39 MB
Some checks failed
CI / test (push) Has been cancelled
- konversi 10 screenshot PNG ke WebP (q78, lebar maks 1280px)
- update tautan di README dan docs/screenshots/README.md
2026-11-05 03:56:00 +07:00
oxpoe
5be6582c39 ci(forgejo): gunakan label runner linux-amd64
All checks were successful
CI / test (push) Successful in 5m28s
2026-11-05 03:44:47 +07:00
85 changed files with 2854 additions and 360 deletions

17
.env.gowa.example Normal file
View File

@@ -0,0 +1,17 @@
# Salin ke .env.gowa lalu isi nilainya. File .env.gowa tidak di-commit.
#
# Login dashboard GOWA (Basic Auth): user:password
GOWA_BASIC_AUTH=admin:ganti-password-kuat
# Port dan interface dashboard/API GOWA
GOWA_PORT=3000
GOWA_BIND=0.0.0.0
# URL webhook OPEN-ISP dari dalam container.
# App di host yang sama : http://host.containers.internal:3001/api/webhook/gowa
# App di compose network : http://open-isp:3001/api/webhook/gowa
GOWA_WEBHOOK_URL=http://host.containers.internal:3001/api/webhook/gowa
# Samakan dengan setting gowa_webhook_secret di OPEN-ISP.
# Buat dengan: openssl rand -hex 32
GOWA_WEBHOOK_SECRET=ganti-dengan-hasil-openssl-rand-hex-32

View File

@@ -7,7 +7,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: linux-amd64
steps:
- name: Checkout
uses: actions/checkout@v4

View File

@@ -7,7 +7,7 @@ on:
jobs:
release:
runs-on: ubuntu-latest
runs-on: linux-amd64
steps:
- name: Checkout
uses: actions/checkout@v4

4
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules/
# Environment and Secrets
.env
.env.gowa
*.env
*.jks
*.keystore
@@ -85,3 +86,6 @@ public/downloads/logo-*
# penanda restore tertunda (runtime)
database/.restore-pending
# penanda update menunggu restart
.restart-required

View File

@@ -2,6 +2,231 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.6.15] — 2026-11-06 — "telegram-safe"
### Diperbaiki
- Telegram admin mendukung **beberapa chat ID** (pisah koma/titik-koma/spasi).
- Notifikasi admin dipecah otomatis bila pesan terlalu panjang (batas 4000 karakter).
- DNS Telegram diarahkan ke **IPv4** untuk menghindari aggregate error IPv6.
## [0.6.14] — 2026-11-06 — "portal-custom"
### Ditambahkan
- **Kustomisasi Beranda & Portal Pelanggan** dari Pengaturan:
- banner pengumuman (info/warning/danger/success),
- status jaringan + keterangan,
- badge, judul, dan deskripsi hero,
- link Google Maps, Instagram, Facebook, TikTok di footer.
## [0.6.13] — 2026-11-06 — "webhook-shopeepay"
### Diperbaiki
- Parser notifikasi pembayaran mendukung variasi **ShopeePay / Mitra Shopee** yang memakai kata “ditambahkan” / “telah ditambahkan”, tanpa mengubah aplikasi Android.
## [0.6.12] — 2026-11-06 — "acs-virtual"
### Diperbaiki
- Ekstraksi **GenieACS Pro** memakai virtual parameters: `pppoeIP`, `pppIP`, `WanIP`, `IPTR069`, `pppoeUsername2`, `PPPoEUser`, `WlanSSID`.
- Lookup perangkat via PPPoE mencakup variabel virtual tersebut, sehingga IP/username lebih cepat dan akurat tampil di tabel ACS.
## [0.6.11] — 2026-11-06 — "acs-wifi-modal"
### Ditambahkan
- Tombol **Atur WiFi (SSID / Sandi)** di daftar perangkat ACS.
- Modal menampilkan SSID 2.4G/5G saat ini, simpan SSID dan sandi terpisah.
- Endpoint `POST /admin/acs/api/device/:id/ssid` dan `/password`.
## [0.6.10] — 2026-11-06 — "acs-lookup"
### Diperbaiki
- Lookup perangkat GenieACS kini punya fallback lewat data pelanggan (ID, nomor HP, PPPoE, tag ACS, MAC), sehingga perangkat lebih cepat ditemukan dan dashboard tidak menggantung saat tag/ID tidak cocok.
## [0.6.9] — 2026-11-06 — "acs-wifi-rx"
### Diperbaiki
- **ACS/GenieACS WiFi**: SSID hanya mengubah SSID1 (2.4G) dan SSID5 (5G bila tersedia); tidak lagi menyentuh SSID2/Guest.
- `setParameterValues` dipecah per parameter agar tidak kena TR-069 fault 9007.
- Sinkronisasi nama WiFi baru ke data pelanggan setelah update.
- **RX Power**: dukung nilai negatif, konversi raw CTC EPON/GPON, dan tampilkan **LOS** saat sinyal putus.
## [0.6.8] — 2026-11-06 — "isolir-url-setting"
### Ditambahkan
- Pengaturan **URL Halaman Isolir** (`isolir_portal_url`) dan **Port Redirector Isolir** (`isolir_redirect_port`) di Info Aplikasi.
- Redirector isolir membaca URL dari pengaturan **setiap request**, jadi perubahan URL langsung berlaku tanpa restart.
- Generator script cloud memakai URL halaman isolir dari pengaturan tersebut.
## [0.6.7] — 2026-11-06 — "isolir-cloud"
### Ditambahkan
- Tab **Cloud (Sederhana)** pada modal Script Isolir (Manajemen Router): generate script RouterOS siap tempel untuk billing yang berjalan di cloud.
- Input domain/IP publik billing, port redirector, dan profil PPPoE isolir.
- Berisi: address-list BILLING (semua hasil resolve A), hook profil PPPoE, redirect DNS, NAT HTTP ke redirector, serta filter allow/reject/drop.
- Deteksi Cloudflare/CDN + peringatan bila port redirector tidak ikut diproxy.
- Endpoint `GET /admin/api/isolir-cloud-script`.
## [0.6.6] — 2026-11-06 — "qr-custom-bulk"
### Ditambahkan
- **Filter tanggal** (dari/sampai) pada histori QR-Custom Payment.
- **Pilih semua / per baris** + tombol **Hapus Terpilih** (batch remove) dengan password admin.
- **Reset Histori** tetap tersedia untuk menghapus semua data.
## [0.6.5] — 2026-11-06 — "qr-custom-delete"
### Ditambahkan
- **Hapus satu QR custom** dari detail (wajib password admin).
- **Reset seluruh histori QR custom** dari header tabel (wajib password admin, konfirmasi).
## [0.6.4] — 2026-11-06 — "qr-custom-ux"
### Diperbaiki
- QR pada link publik kini **center**.
- Timer hanya tampil saat QR masih aktif; berhenti/disembunyikan ketika LUNAS, ditolak, atau kedaluwarsa.
- **Reject / Refund** kini tersedia juga untuk pembayaran yang sudah LUNAS.
- QR kedaluwarsa: **nominal dan gambar QR disembunyikan** di link publik maupun detail dashboard.
- Tombol **Regenerate** untuk membuat QR baru + kode unik baru dari QR yang kedaluwarsa (juga tersedia di tabel histori).
## [0.6.3] — 2026-11-06 — "webhook-secret-auto"
### Diperbaiki
- **Secret Token webhook pembayaran** dibuat otomatis (64 hex) bila kosong/default, ditampilkan di Pengaturan dan langsung dipakai pada URL webhook.
- URL webhook memakai **Public Base URL**; bila kosong memakai host saat ini (tidak lagi menghasilkan URL relatif).
- Default lama `billing-rtrw-secret-key` tidak lagi diterima — forwarder harus memakai secret dari Pengaturan.
## [0.6.2] — 2026-11-06 — "webhook-diag"
### Diperbaiki
- Respons webhook pembayaran kini menyertakan `matched_custom_qr_id` untuk memudahkan diagnosis QR-Custom Payment.
## [0.6.1] — 2026-11-06 — "qr-custom-gate"
### Diperbaiki
- Ikon menu **QR-Custom Payment** memakai ikon yang tersedia (`bi-qr-code`).
- Fitur QR-Custom **dinonaktifkan otomatis** bila QRIS Statis (Semi-Otomatis) belum aktif atau `qris_static_payload` kosong:
- menu disembunyikan,
- form pembuatan dinonaktifkan,
- request pembuatan ditolak (tidak ada data/list dibuat tanpa QR).
## [0.6.0] — 2026-11-06 — "qr-custom"
### Ditambahkan
- **QR-Custom Payment** (menu baru di sidebar UTAMA): membuat QRIS nominal bebas dengan kode unik untuk kebutuhan di luar billing (sewa, donasi perusahaan, dll).
- Durasi 20 menit per QR, link share publik, QR dinamis, timer, dan halaman status.
- Webhook pembayaran mencocokkan nominal unik; pembayaran dalam 20 menit → LUNAS + notifikasi admin.
- Pembayaran dengan nominal sama **setelah 20 menit** → status `warning` + notifikasi admin, dapat diselesaikan atau ditolak/refund.
- Panel histori, statistik masuk/pending/warning/rejected, manual pay bila sinkron webhook gagal.
- **Mode Webhook Secret GOWA**: pilihan **Auto Generate** (kolom terkunci, tombol simpan nonaktif) atau **Manual** (isi secret → Simpan → terkunci, tombol Ubah untuk mengganti), plus tombol salin.
## [0.5.20] — 2026-11-06 — "copy-secret"
### Ditambahkan
- Tombol **salin** di samping **Webhook Secret** GOWA (sama seperti tombol salin Webhook URL), dengan umpan balik ikon centang.
## [0.5.19] — 2026-11-06 — "restart-notice"
### Ditambahkan
- Setelah update sukses, aplikasi menulis penanda `.restart-required`.
- **Floating notification** di semua halaman admin: "Billing perlu restart" berisi versi yang sudah diterapkan dan tautan cara restart.
- Banner di halaman Update menjelaskan bahwa versi sudah terbaru tetapi kode lama masih berjalan sampai restart.
- Penanda otomatis dihapus saat proses aplikasi baru start, jadi notifikasi hilang setelah restart.
## [0.5.18] — 2026-11-06 — "gowa-autodevice"
### Diperbaiki
- **Device ID kosong** = otomatis mengikuti device yang terhubung lewat QR dashboard (tidak lagi dipin/disimpan otomatis). Isi Device ID hanya untuk menunjuk device GOWA tertentu.
- **Webhook secret GOWA** dibuat otomatis (32 byte hex) bila kosong atau masih default `secret`, lalu ditampilkan di pengaturan untuk disamakan dengan `WHATSAPP_WEBHOOK_SECRET` di GOWA.
## [0.5.17] — 2026-11-06 — "gowa-connected"
### Diperbaiki
- Status GOWA membaca array `/devices` (banyak device) sehingga tidak lagi selalu menampilkan **Belum Terhubung** ketika Device ID belum diisi.
- Device yang terhubung disimpan otomatis sebagai `gowa_device_id` — tidak perlu menyalin ID manual dari UI GOWA.
- Setelah scan QR, halaman memantau status tiap 3 detik; QR otomatis disembunyikan dan status berubah **Terhubung** saat berhasil.
- Pengiriman memilih device yang benar-benar terhubung bila ada lebih dari satu device.
## [0.5.16] — 2026-11-05 — "restart-help"
### Ditambahkan
- Kotak panduan **Cara Restart Aplikasi** di halaman Update: self-hosted (PM2, manual, quadlet/systemd, Docker/Podman Compose) dan cloud/managed (restart otomatis pada update berikutnya atau hubungi admin server).
## [0.5.15] — 2026-11-05 — "update-keep-env"
### Diperbaiki
- Update aplikasi tidak lagi menghapus `.env.gowa` (kredensial/webhook GOWA) saat `git clean`.
- Log update tidak lagi menampilkan error `pathspec 'settings.json'/'database'` — file yang tidak dilacak git tidak di-checkout.
- `.env.gowa` ditambahkan ke `.gitignore`.
## [0.5.14] — 2026-11-05 — "ai-test"
### Ditambahkan
- Tombol **Test API** di Pengaturan → AI: memvalidasi provider, API key, base URL, dan model yang sedang diisi **tanpa harus menyimpan**. Menampilkan status valid/tidak, HTTP status, dan latensi.
## [0.5.13] — 2026-11-05 — "lunas-notify"
### Diperbaiki
- Notifikasi WA **LUNAS** memakai gateway aktif (GOWA/Fonnte/Meta/Baileys), bukan hanya Baileys.
- Deteksi koneksi GOWA membaca status device dari `/devices` (array) — sebelumnya dianggap tidak terhubung bila Device ID kosong, sehingga notifikasi lunas dilewati.
- Approve pembayaran kolektor dari APK admin dan pencocokan notifikasi pembayaran manual kini mengirim WA lunas ke pelanggan.
## [0.5.12] — 2026-11-05 — "gowa-test-unsaved"
### Diperbaiki
- **Test Koneksi GOWA** kini memakai nilai yang sedang diisi di form (base URL, username, password), jadi tidak perlu simpan pengaturan dulu.
## [0.5.11] — 2026-11-05 — "gowa-test"
### Ditambahkan
- Tombol **Test Koneksi** di Admin → WhatsApp → GOWA: menampilkan apakah GOWA terjangkau, status `/health`, kecocokan username/password Basic Auth, dan jumlah device terdaftar.
## [0.5.10] — 2026-11-05 — "gowa-mode"
### Ditambahkan
- Pilihan **Mode Akses GOWA** di Admin → WhatsApp: **Cloud (HTTPS)** atau **Local (HTTP)**. Mode Local menampilkan peringatan wajib polyfill `crypto.randomUUID` beserta tautan panduan wiki.
- Halaman wiki **GOWA**: setup Compose, perbedaan Cloud/Local, perintah polyfill, setting OPEN-ISP, dan troubleshooting QR/webhook.
## [0.5.9] — 2026-11-05 — "gowa-qr-image"
### Diperbaiki
- QR GOWA dikirim sebagai **data URI** sehingga gambar tetap tampil walau app berjalan multi-proses atau request gambar terpisah gagal.
- `qr_link` dari GOWA yang menunjuk host internal (`127.0.0.1`/container) ditulis ulang memakai base URL publik yang dikonfigurasi.
- Log peringatan `[GOWA] Gagal ambil gambar QR` bila pengambilan gambar gagal.
## [0.5.8] — 2026-11-05 — "gowa-qr-error"
### Diperbaiki
- QR GOWA: pesan gagal kini menampilkan status HTTP dan respons GOWA (mis. `HTTP 401 — Unauthorized`) sehingga penyebabnya langsung terlihat, bukan lagi "pastikan GOWA berjalan".
## [0.5.7] — 2026-11-05 — "gowa-podman"
### Ditambahkan
- `docker-compose.gowa.yml` + `.env.gowa.example`: menjalankan **GOWA** (WhatsApp gateway + dashboard/UI) dengan Docker atau Podman Compose, termasuk webhook ke OPEN-ISP dan Basic Auth.
## [0.5.6] — 2026-11-05 — "qris-hangus"
### Diperbaiki
- **QRIS mengikuti skema hangus**: nominal unik hanya untuk **satu tagihan** (tagihan terbaru). Tagihan lama otomatis `void` saat tagihan baru terbit, jadi tidak ada tunggakan menumpuk.
- Kirim tagihan QRIS manual memakai tagihan **terbaru** (sebelumnya bisa mengambil tagihan lama).
### Catatan
- Nonaktif otomatis setelah **3 bulan** menunggak dan hapus akun pada bulan berikutnya tetap berjalan sesuai pengaturan `auto_deactivate_months` / `auto_delete_overdue_enabled`.
## [0.5.5] — 2026-11-05 — "qris-fix"
### Diperbaiki
- **Kode unik bentrok**: pengecekan nominal unik sekarang mencakup invoice, voucher, dan donasi sekaligus — mencegah pembayaran dianggap ambigu oleh webhook (dulu bisa tidak diproses sama sekali).
- **Kirim tagihan QRIS manual**: gambar QR benar-benar terlampir (path `qrcode` tidak lagi hardcoded ke `/opt/billing-rtrw`), nominal di pesan memakai nominal unik, dan placeholder `{{qris_kode}}` didukung.
- **Kirim tagihan manual** memilih tagihan paling lama lebih dulu (sebelumnya tagihan terbaru).
- Pesan **pengingat manual** kini jujur saat tidak dikirim (WA/notifikasi OFF, gateway belum siap, atau tidak ada target hari ini), bukan “dijalankan: target 0”.
## [0.5.4] — 2026-11-05 — "wa-notice"
### Ditambahkan
- **WA ke pelanggan saat tiket gangguan dibuat** (web & aplikasi): konfirmasi laporan diterima beserta nomor tiket `#TCK-…`.
- **Notifikasi WA pada Status Layanan**: centang "Kirim notifikasi WhatsApp" saat mode berubah ke **Gangguan Masal**/**Perbaikan**, dengan target semua pelanggan aktif, per area, atau per router.
- **WA "Layanan Kembali Normal"** otomatis saat mode dikembalikan ke NORMAL.
- Pengiriman broadcast berjalan di latar belakang dengan jeda acak mengikuti `whatsapp_broadcast_delay`, nomor duplikat digabung, dan hasilnya dicatat di log (`[NoticeWA]`).
## [0.5.3] — 2026-11-05 — "forge-lime"
### Ditambahkan

View File

@@ -1,19 +1,21 @@
> 🪞 **Repositori utama:** [Gitea](https://gitea.oxpoe.xyz/oxpoe/open-isp) · [Forgejo](https://forgejo.oxpoe.xyz/oxpoe/open-isp) — rilis & unduhan ada di sana. GitHub hanya cermin kode.
> 🪞 **Repositori utama:** [Gitea](https://gitea.oxpoe.xyz/oxpoe/open-isp) — rilis & unduhan ada di sana. **Forgejo** (forgejo.oxpoe.xyz) hanya **mirror read-only**.
<p align="center">
<img src="docs/assets/banner.png" alt="OPEN-ISP" width="100%">
</p>
<p align="center">
<b>Sistem billing &amp; manajemen ISP (fiber optic) — open source, self-hosted.</b><br>
<b>Sistem billing &amp; manajemen ISP (fiber optic) — open source, self-hosted builtin AI.</b><br>
PWA pelanggan · Panel admin · Portal teknisi &amp; kolektor · Multi-OLT · RADIUS · WhatsApp/Telegram · QRIS
</p>
<p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.5.3_forge--lime-ADFF2F" alt="Versi 0.5.3 (forge-lime)">
<img src="https://img.shields.io/badge/versi-0.6.15_telegram--safe-ADFF2F" alt="Versi 0.6.15 (telegram-safe)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>
<a href="https://oxpoe.xyz"><img src="https://img.shields.io/badge/donasi-oxpoe.xyz-e5484d?logo=buymeacoffee&logoColor=white" alt="Donasi"></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.stars_count&label=stars&style=flat&color=blue&logo=gitea" alt="Stars">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.forks_count&label=forks&style=flat&color=lightgrey&logo=gitea" alt="Forks">
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.open_issues_count&label=issues&style=flat&color=orange&logo=gitea" alt="Issues">
@@ -21,21 +23,24 @@
---
**[demo](https://demo-openisp.oxpoe.xyz) · [donate](https://oxpoe.xyz) · [main repo](https://gitea.oxpoe.xyz/oxpoe/open-isp)**
---
**OPEN-ISP** adalah aplikasi billing dan manajemen ISP yang lengkap: tagihan otomatis, isolir otomatis, pembayaran (QRIS/webhook), perangkat MikroTik (PPPoE & hotspot), OLT PON multi-vendor, TR-069 (GenieACS/ACS), RADIUS, peta jaringan, notifikasi WhatsApp & Telegram, hingga portal self-service pelanggan dalam satu platform — semuanya berjalan mandiri (self-hosted) dengan SQLite sebagai basis data. **NOTE:** dibuat dan ditulis oleh AI ([Deepseek](https://github.com/deepseek-ai)), on [opencode](https://github.com/anomalyco/opencode).
## Tangkapan Layar
| Dashboard Admin | Daftar Pelanggan |
|---|---|
| ![Dashboard](docs/screenshots/03-admin-dashboard.png) | ![Pelanggan](docs/screenshots/04-admin-customers.png) |
| ![Dashboard](docs/screenshots/03-admin-dashboard.webp) | ![Pelanggan](docs/screenshots/04-admin-customers.webp) |
| Inventaris & Stok Keluar | Pengaturan AI & Self-Healing |
|---|---|
| ![Inventaris](docs/screenshots/05-admin-inventory.png) | ![AI](docs/screenshots/07-admin-settings-ai.png) |
| ![Inventaris](docs/screenshots/05-admin-inventory.webp) | ![AI](docs/screenshots/07-admin-settings-ai.webp) |
| Login Pelanggan | Dashboard Pelanggan |
|---|---|
| ![Login](docs/screenshots/08-customer-login.png) | ![Dashboard Pelanggan](docs/screenshots/09-customer-dashboard.png) |
| ![Login](docs/screenshots/08-customer-login.webp) | ![Dashboard Pelanggan](docs/screenshots/09-customer-dashboard.webp) |
Selengkapnya (10 gambar, desktop + mobile): [`docs/screenshots/`](docs/screenshots/).
@@ -63,16 +68,16 @@ Selengkapnya (10 gambar, desktop + mobile): [`docs/screenshots/`](docs/screensho
<table>
<tr>
<td align="center"><b>Dashboard Admin</b><br><img src="docs/screenshots/admin-dashboard.jpg" width="420"></td>
<td align="center"><b>Data Pelanggan</b><br><img src="docs/screenshots/admin-customers.jpg" width="420"></td>
<td align="center"><b>Dashboard Admin</b><br><img src="docs/screenshots/admin-dashboard.webp" width="420"></td>
<td align="center"><b>Data Pelanggan</b><br><img src="docs/screenshots/admin-customers.webp" width="420"></td>
</tr>
<tr>
<td align="center"><b>Tagihan &amp; Pembayaran</b><br><img src="docs/screenshots/admin-billing.jpg" width="420"></td>
<td align="center"><b>OLT (PON)</b><br><img src="docs/screenshots/admin-olts.jpg" width="420"></td>
<td align="center"><b>Tagihan &amp; Pembayaran</b><br><img src="docs/screenshots/admin-billing.webp" width="420"></td>
<td align="center"><b>OLT (PON)</b><br><img src="docs/screenshots/admin-olts.webp" width="420"></td>
</tr>
<tr>
<td align="center"><b>Portal Pelanggan (PWA)</b><br><img src="docs/screenshots/customer-dashboard.jpg" width="250"></td>
<td align="center"><b>Tagihan Pelanggan</b><br><img src="docs/screenshots/customer-billing.jpg" width="250"></td>
<td align="center"><b>Portal Pelanggan (PWA)</b><br><img src="docs/screenshots/customer-dashboard.webp" width="250"></td>
<td align="center"><b>Tagihan Pelanggan</b><br><img src="docs/screenshots/customer-billing.webp" width="250"></td>
</tr>
</table>
@@ -80,6 +85,10 @@ Selengkapnya: **[Wiki OPEN-ISP](https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki)**.
## Mulai Cepat
### Demo online
Coba tanpa instal: **[demo-openisp.oxpoe.xyz](https://demo-openisp.oxpoe.xyz)** — login `admin` / `demo-isp123`.
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/oxpoe/open-isp)
### GitHub Codespaces (demo instan)
@@ -97,6 +106,25 @@ docker compose up -d
Buka **http://localhost:3001** — login awal `admin` / `demo-isp123` (**segera ganti password**).
Data tersimpan di volume `docker-data/` (settings, database, logs, uploads, sesi WhatsApp).
### WhatsApp gateway GOWA + UI (Podman / Docker Compose)
GOWA dijalankan sebagai container terpisah, lengkap dengan dashboard/scan QR bawaan:
```bash
cp .env.gowa.example .env.gowa # isi GOWA_BASIC_AUTH + GOWA_WEBHOOK_SECRET
podman compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
```
Buka dashboard di **http://<ip-host>:3000** (login Basic Auth), lalu scan QR.
Di OPEN-ISP → **Admin → WhatsApp**: pilih gateway **GOWA**, isi base URL `http://127.0.0.1:3000`
(app di host yang sama), username/password, dan **webhook secret** yang sama dengan `.env.gowa`.
> **UI hanya loading saat diakses lewat `http://IP:3000`?** gowa-ui memakai `crypto.randomUUID`
> yang hanya tersedia di secure context (HTTPS atau `localhost`). Pilih salah satu:
> pakai HTTPS/reverse proxy, buka lewat SSH tunnel (`ssh -L 3000:127.0.0.1:3000 user@server`
> lalu `http://localhost:3000`), atau tambahkan polyfill `crypto.randomUUID` ke
> `storages/ui/index.html` dan set `GOWA_UI_AUTO_UPDATE=false`.
### Manual (Node.js ≥ 20, disarankan 22 LTS)
```bash
@@ -146,8 +174,10 @@ Dirilis dengan lisensi **ISC** — bebas digunakan, dimodifikasi, dan didistribu
OPEN-ISP tumbuh dari karya **Alijaya Net** — ayo dukung juga pengembang aslinya:
- **Donasi Alijaya Net**: [app.alijaya.com/donasi](https://app.alijaya.com/donasi) · WhatsApp [0819-4721-5703](https://wa.me/6281947215703)
- **Demo online**: [demo-openisp.oxpoe.xyz](https://demo-openisp.oxpoe.xyz)
- **Donasi OPEN-ISP**: [oxpoe.xyz](https://oxpoe.xyz) — sukarela, untuk pengembangan fitur.
Dukungan untuk **pengembangan OPEN-ISP** (agar makin canggih): link donasi **menyusul** — sementara ini bisa lewat menu **Dukung OPEN-ISP** di aplikasi.
Dukungan juga bisa lewat menu **Dukung OPEN-ISP** di aplikasi.
---
@@ -155,5 +185,7 @@ Dukungan untuk **pengembangan OPEN-ISP** (agar makin canggih): link donasi **men
**OPEN-ISP** is an open-source ISP billing & management system: customer PWA, admin/technician/collector portals, MikroTik PPPoE & hotspot, multi-vendor OLT (SNMP/CLI), GenieACS TR-069, RADIUS, WhatsApp & Telegram notifications, QRIS payments, and a bilingual (ID/EN) UI.
Live demo: **https://demo-openisp.oxpoe.xyz** (`admin` / `demo-isp123`). Donate: **https://oxpoe.xyz**.
Quick start: `docker compose up -d` → http://localhost:3001 (default login `admin` / `demo-isp123`).
Advanced menus are unlocked with a free activation code — not published in this repository (set via `activation_code` in `settings.json`). License: **ISC**. Based on the base code & repo by **Alijaya Net** ([alijayanet/billing-rtrw](https://github.com/alijayanet/billing-rtrw)). Code by DeepSeek V4.1 Flash — max, on opencode.

View File

@@ -60,6 +60,10 @@ const { SUPPORTED_LANGS, FALLBACK_LANG, normalizeLang, t } = require('./config/i
// Pastikan semua default settings ada (untuk migrasi/update dari Gitea)
if (typeof ensureDefaultSettings === 'function') {
ensureDefaultSettings();
try { require('./config/settingsManager').ensureGowaWebhookSecret(); } catch (e) {}
try { require('./config/settingsManager').ensureWebhookSecret(); } catch (e) {}
// Proses baru = kode terbaru sudah dimuat; hapus penanda restart.
try { fs.rmSync(path.join(__dirname, '.restart-required'), { force: true }); } catch (e) {}
}
// Inisialisasi aplikasi Express
@@ -177,7 +181,7 @@ app.use((req, res, next) => {
res.locals.serviceNotice = noticeSvc.getServiceNotice();
res.locals.uptimeUrl = getSetting('uptime_url', '');
res.locals.heroVer = getSetting('landing_hero_version', '');
res.locals.projectUrl = String(getSetting('project_url', 'https://gitea.oxpoe.qzz.io/oxpoe/open-isp') || '').replace(/\/+$/, '');
res.locals.projectUrl = String(getSetting('project_url', 'https://gitea.oxpoe.xyz/oxpoe/open-isp') || '').replace(/\/+$/, '');
res.locals.appVersion = String(getSetting('app_version', '0.5.1') || '0.5.1');
res.locals.appVersionName = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime');
res.locals.pwaVersion = String(getSetting('pwa_version', '2.0') || '2.0');
@@ -330,6 +334,17 @@ const updateWebhookPaymentNotifMatchDonation = db.prepare(`
SET matched_donation_order_id=?
WHERE id=?
`);
const selectCustomQrByUniqueAmount = db.prepare(`
SELECT id, status, title, amount_unique, expires_at
FROM custom_qr_payments
WHERE status IN ('pending','expired') AND amount_unique = ?
ORDER BY id DESC LIMIT 2
`);
const updateWebhookPaymentNotifMatchCustomQr = db.prepare(`
UPDATE webhook_payment_notifs
SET matched_custom_qr_id=?
WHERE id=?
`);
const markInvoicePaidAppendNote = db.prepare(`
UPDATE invoices
@@ -372,7 +387,7 @@ function parseRupiahAmountFromNotification(content) {
'dana masuk', 'saldo masuk', 'saldo bertambah', 'berhasil top up',
'top up berhasil', 'topup berhasil', 'terima uang', 'telah diterima dari',
'diterima dari', 'qris berhasil', 'qris sukses', 'qr berhasil', 'qr sukses',
'payment received', 'kamu menerima', 'berhasil menerima', 'menambahkan',
'payment received', 'kamu menerima', 'berhasil menerima', 'menambahkan', 'ditambahkan', 'telah ditambahkan', 'berhasil ditambahkan',
'telah diterima sebesar', 'uang diterima', 'menerima dana', 'dana diterima', 'menerima sebesar', 'baru saja menerima', 'telah menerima',
'just received', 'you received', 'you have received', 'funds received', 'has been received', 'incoming transfer', 'credited'
];
@@ -395,7 +410,8 @@ function parseRupiahAmountFromNotification(content) {
'uang masuk', 'telah diterima', 'top up berhasil', 'topup berhasil', 'berhasil top up',
'menerima pembayaran', 'qris statis diterima', 'qris diterima', 'pembayaran qris',
'menerima dana', 'dana diterima', 'menerima sebesar', 'telah menerima',
'just received', 'you received', 'you have received', 'funds received', 'has been received'
'just received', 'you received', 'you have received', 'funds received', 'has been received',
'ditambahkan', 'telah ditambahkan', 'berhasil ditambahkan'
];
const hasStrongIncoming = strongIncoming.some((hint) => lower.includes(hint));
@@ -412,7 +428,7 @@ function parseRupiahAmountFromNotification(content) {
const candidates = [
/(?:\bRp\.?\s*|IDR\s*)([0-9][0-9\.\,\s]*)/i,
/(?:sebesar|senilai|nominal|masuk|transfer|top\s*up|topup|saldo\s+masuk)\s*(?:saldo\s*)?(?:\bRp\.?\s*)?([0-9][0-9\.\,\s]*)/i,
/(?:sebesar|senilai|nominal|masuk|transfer|top\s*up|topup|saldo\s+masuk|ditambahkan)\s*(?:saldo\s*)?(?:\bRp\.?\s*)?([0-9][0-9\.\,\s]*)/i,
];
let raw = null;
@@ -628,8 +644,8 @@ async function trySendWaPaymentSuccess(settings, invoiceId, methodLabel) {
if (!inv) return;
const phone = String(inv.customer_phone || '').trim();
if (!phone) return;
const { sendWA, whatsappStatus } = await import('./services/whatsappBot.mjs');
if (whatsappStatus.connection !== 'open') throw new Error('Bot WhatsApp belum terhubung');
// Pakai gateway aktif (GOWA/Fonnte/Meta/Baileys), bukan hanya Baileys.
const waNotify = require('./services/waNotifyService');
const appUrl = (settings.public_base_url || '').replace(/\/$/, '');
const portalUrl = appUrl ? `${appUrl}/customer` : '';
@@ -654,7 +670,7 @@ async function trySendWaPaymentSuccess(settings, invoiceId, methodLabel) {
});
logger.info(`[WEBHOOK][payment-notif] Sending WA success notif to ${phone} inv=${invoiceId} method=${metode}`);
await sendWA(phone, msg);
await waNotify.sendText(phone, msg);
} catch (e) {
logger.error(`[WEBHOOK][payment-notif] WA success notif failed: ${e?.message || e}`);
}
@@ -887,7 +903,7 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
gotTrim = gotTrim.replace(/^[\"'\s]+|[\"'\s]+$/g, '');
if (/^bearer\s+/i.test(gotTrim)) gotTrim = gotTrim.replace(/^bearer\s+/i, '').trim();
const isSecretValid = (gotTrim === expectedTrim) || (gotTrim === 'billing-rtrw-secret-key');
const isSecretValid = gotTrim === expectedTrim;
if (!isSecretValid) {
logger.warn(`[WEBHOOK][payment-notif] Forbidden: secret_key mismatch. service=${String(service || '-')} (gotLen=${gotTrim.length}${gotTrim ? ', ***' + gotTrim.slice(-3) : ', empty'}, expectedLen=${expectedTrim.length}, ***${expectedTrim.slice(-3)})`);
@@ -995,14 +1011,17 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
let matchedInvoiceId = null;
let matchedVoucherOrderId = null;
let matchedDonationOrderId = null;
let matchedCustomQrId = null;
if (amount != null) {
try {
const invCandidates = selectInvoiceByUniqueAmount.all(amount);
const vCandidates = selectVoucherOrderByUniqueAmount.all(amount);
const dCandidates = selectDonationOrderByUniqueAmount.all(amount);
const cqCandidates = selectCustomQrByUniqueAmount.all(amount);
const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) +
(Array.isArray(vCandidates) ? vCandidates.length : 0) +
(Array.isArray(dCandidates) ? dCandidates.length : 0);
(Array.isArray(dCandidates) ? dCandidates.length : 0) +
(Array.isArray(cqCandidates) ? cqCandidates.length : 0);
if (totalCandidates === 1) {
if (Array.isArray(invCandidates) && invCandidates.length === 1) {
@@ -1066,17 +1085,36 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
logger.error(`[WEBHOOK][payment-notif] Donation fulfill error: ${e?.message || e}`);
}
}
} else if (Array.isArray(cqCandidates) && cqCandidates.length === 1) {
const cq = cqCandidates[0];
const cqId = Number(cq.id || 0);
if (cqId > 0) {
const svc = require('./services/customQrService');
const expired = svc.isExpired(cq);
if (String(cq.status) === 'pending' && !expired) {
svc.markPaidFromWebhook(cqId, notifId || null);
logger.info(`[WEBHOOK][payment-notif] MATCH custom_qr=${cqId} amount=${amount}`);
} else {
svc.markWarningFromWebhook(cqId, notifId || null);
logger.warn(`[WEBHOOK][payment-notif] MATCH custom_qr=${cqId} amount=${amount} SETELAH 20 MENIT (warning)`);
}
matchedCustomQrId = cqId;
if (notifId) {
try { updateWebhookPaymentNotifMatchCustomQr.run(cqId, notifId); } catch {}
}
}
}
} else if (totalCandidates > 1) {
const invIds = Array.isArray(invCandidates) ? invCandidates.map(x => x.id).join(',') : '';
const vIds = Array.isArray(vCandidates) ? vCandidates.map(x => x.id).join(',') : '';
const dIds = Array.isArray(dCandidates) ? dCandidates.map(x => x.id).join(',') : '';
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}]`);
const cqIds = Array.isArray(cqCandidates) ? cqCandidates.map(x => x.id).join(',') : '';
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}] custom_qr=[${cqIds}]`);
}
// Tidak ada tagihan UNPAID dgn nominal itu → kemungkinan notifikasi ke-2 dari app lain
// (mis. GoPay & GoPay Merchant) untuk pembayaran yang SUDAH LUNAS. Hubungkan ke invoice lunas.
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId) {
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId && !matchedCustomQrId) {
const paidInv = selectPaidInvoiceByUniqueAmount.all(amount);
if (Array.isArray(paidInv) && paidInv.length === 1) {
matchedInvoiceId = Number(paidInv[0].id || 0) || null;
@@ -1094,7 +1132,7 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
if (amount != null) {
logger.info(`[WEBHOOK][payment-notif] PARSED service=${String(service || '-')} amount=${amount}`);
return res.status(200).json({ status: 'processed', parsed: true, amount, matched_invoice_id: matchedInvoiceId, matched_voucher_order_id: matchedVoucherOrderId, matched_donation_order_id: matchedDonationOrderId });
return res.status(200).json({ status: 'processed', parsed: true, amount, matched_invoice_id: matchedInvoiceId, matched_voucher_order_id: matchedVoucherOrderId, matched_donation_order_id: matchedDonationOrderId, matched_custom_qr_id: matchedCustomQrId });
}
logger.error(`[WEBHOOK][payment-notif] FAILED parse: "${rawText.replace(/\r?\n/g, ' ').slice(0, 500)}"`);
@@ -1973,13 +2011,8 @@ function ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, notes)
// Halaman Publik Donasi QRIS Statis
app.get(['/donasi', '/donate', '/public/donasi'], (req, res) => {
const settings = getSettingsWithCache();
res.render('donate', {
settings,
company: settings.company_header || 'OPEN-ISP',
lang: req.session?.lang || 'id',
t: res.locals.t || ((k, d) => d)
});
// Halaman dukungan dialihkan ke situs eksternal agar bisa dikustomisasi.
return res.redirect(302, 'https://oxpoe.xyz');
});
// Endpoint Buat Pesanan Donasi dengan Kode Unik
@@ -2069,6 +2102,102 @@ app.post(['/donasi/confirm', '/api/donasi/confirm'], async (req, res) => {
}
});
// ─── QR CUSTOM PAYMENT (publik) ─────────────────────────────────────────────
function cqEscape(v) {
return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
app.get('/custom-qr/:token', (req, res) => {
let row = null;
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
if (!row) return res.status(404).send('QR tidak ditemukan atau sudah dihapus.');
const settings = getSettingsWithCache();
const company = settings.company_header || 'OPEN-ISP';
const statusLabels = { pending: 'Menunggu Pembayaran', paid: 'LUNAS', expired: 'Kedaluwarsa', warning: 'Perlu Verifikasi Admin', rejected: 'Ditolak / Refund' };
const status = String(row.status || 'pending');
const expiresMs = row.expires_at ? new Date(row.expires_at).getTime() : 0;
const expiredNow = status === 'expired' || (status === 'pending' && expiresMs && expiresMs < Date.now());
const showAmount = !expiredNow;
const showQr = !expiredNow && status !== 'paid' && status !== 'rejected';
const showTimer = status === 'pending' && !expiredNow;
const amountText = 'Rp ' + Number(row.amount_unique || 0).toLocaleString('id-ID');
const baseAmountText = 'Rp ' + Number(row.amount || 0).toLocaleString('id-ID');
const html = `<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${cqEscape(row.title)} — ${cqEscape(company)}</title>
<style>
body{margin:0;background:#0b1220;color:#e5e7eb;font-family:system-ui,Segoe UI,Arial;display:grid;place-items:center;min-height:100vh;padding:20px}
.card{max-width:460px;width:100%;background:#0f172a;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:22px;text-align:center}
h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px}
.amt{font-size:26px;font-weight:800;color:#ADFF2F;margin:12px 0 2px}.base{font-size:12px;color:#64748b}
.qr{background:#fff;padding:12px;border-radius:14px;display:block;width:max-content;margin:14px auto}.qr img{display:block;width:240px;height:240px}
.badge{display:inline-block;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:700;margin-top:6px}
.pending{background:rgba(245,158,11,.15);color:#fbbf24}.paid{background:rgba(34,197,94,.15);color:#4ade80}
.expired{background:rgba(239,68,68,.15);color:#f87171}.warning{background:rgba(249,115,22,.15);color:#fb923c}.rejected{background:rgba(148,163,184,.15);color:#cbd5e1}
.timer{font-size:14px;color:#fbbf24;margin-top:8px}.note{font-size:12px;color:#64748b;margin-top:12px}
</style></head><body><div class="card">
<div style="font-size:12px;letter-spacing:1px;color:#64748b;text-transform:uppercase">${cqEscape(row.category || 'Pembayaran')}</div>
<h1>${cqEscape(row.title)}</h1>
${showAmount
? `<div class="amt">${amountText}</div><div class="base">Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}</div>`
: `<div class="amt" style="font-size:18px;color:#f87171">QR Kedaluwarsa</div><div class="base">Nominal dan QR disembunyikan. Minta admin membuat QR baru.</div>`}
<div class="badge ${cqEscape(expiredNow ? 'expired' : status)}" id="statusBadge">${cqEscape(expiredNow ? 'Kedaluwarsa' : (statusLabels[status] || status))}</div>
${showQr ? `<div class="qr"><img src="/custom-qr/${cqEscape(row.share_token)}/qr.png?amount=${Number(row.amount_unique || 0)}" alt="QRIS"></div>` : ''}
${showTimer ? `<div class="timer" id="timer" data-expires="${cqEscape(row.expires_at || '')}"></div>` : ''}
${row.note ? `<div class="note">${cqEscape(row.note)}</div>` : ''}
${showAmount ? `<div class="note">Bayar dengan nominal <b>persis</b> seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.</div>` : ''}
<div class="note">${cqEscape(company)}</div>
</div>
<script>
(function(){
var el=document.getElementById('timer'), exp=el&&el.getAttribute('data-expires');
function tick(){
if(!el) return;
if(!exp) return;
var d=new Date(exp).getTime()-Date.now();
if(d<=0){ el.textContent='Batas 20 menit telah lewat — pembayaran tetap dicek admin.'; return; }
var m=Math.floor(d/60000), s=Math.floor((d%60000)/1000);
el.textContent='Sisa waktu: '+m+' menit '+s+' detik';
}
tick(); setInterval(tick,1000);
var last=${JSON.stringify(status)};
setInterval(function(){
fetch('/custom-qr/${cqEscape(row.share_token)}/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(d && d.status && d.status!==last){ location.reload(); }
}).catch(function(){});
},5000);
})();
</script></body></html>`;
res.set('Cache-Control', 'no-store');
return res.send(html);
});
app.get('/custom-qr/:token/qr.png', async (req, res) => {
let row = null;
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
if (!row) return res.status(404).send('QR tidak ditemukan');
const settings = getSettingsWithCache();
const payload = String(settings.qris_static_payload || '').trim();
if (!payload) return res.status(404).send('QRIS statis belum diatur admin.');
try {
const jpg = await qrisUtil.buildDynamicQrisJpgBuffer(payload, Number(row.amount_unique || row.amount || 0));
res.set('Content-Type', 'image/jpeg');
res.set('Cache-Control', 'no-store');
return res.send(jpg);
} catch (e) {
logger.error('[CustomQR] Gagal membuat QR: ' + e.message);
return res.status(500).send('Gagal membuat QR');
}
});
app.get('/custom-qr/:token/status', (req, res) => {
let row = null;
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
if (!row) return res.status(404).json({ status: 'not_found' });
return res.json({ status: row.status, paid_at: row.paid_at || null });
});
// Mount built-in ACS server endpoint (TR-069)
const acsServerService = require('./services/acsServerService');
app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml', 'text/plain'], limit: '2mb' }), acsServerService.handleCwmpRequest);

View File

@@ -1069,6 +1069,42 @@ try {
console.error('Failed to migrate public_donation_orders:', e);
}
// Safe migration: QR Custom Payment (nominal bebas di luar billing)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS custom_qr_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT DEFAULT '',
amount INTEGER NOT NULL DEFAULT 0,
unique_code INTEGER,
amount_unique INTEGER,
status TEXT DEFAULT 'pending',
share_token TEXT NOT NULL,
note TEXT DEFAULT '',
created_by TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL()),
expires_at TEXT,
paid_at DATETIME,
paid_by TEXT DEFAULT '',
notif_id INTEGER,
warning_at DATETIME,
resolved_by TEXT DEFAULT '',
resolved_at DATETIME,
resolution_note TEXT DEFAULT ''
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_qr_token ON custom_qr_payments(share_token);
CREATE INDEX IF NOT EXISTS idx_custom_qr_status ON custom_qr_payments(status);
CREATE INDEX IF NOT EXISTS idx_custom_qr_amount ON custom_qr_payments(amount_unique, status);
`);
const cols = db.prepare("PRAGMA table_info(webhook_payment_notifs)").all();
if (cols.length > 0 && !cols.some(c => c.name === 'matched_custom_qr_id')) {
db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_custom_qr_id INTEGER");
}
} catch(e) {
console.error('Failed to migrate custom_qr_payments:', e);
}
// Safe migration: automation_runs (riwayat otomatisasi pengiriman tagihan)
try {
db.exec(`

View File

@@ -278,6 +278,26 @@ function formatTimeLocal(date) {
* Memastikan semua setting wajib ada di settings.json dengan default value.
* Dijalankan saat startup aplikasi untuk migrasi/inisialisasi setting baru.
*/
/**
* Pastikan gowa_webhook_secret tidak memakai default 'secret'.
* Dibuat otomatis (32 byte hex) bila kosong/default/terlalu pendek.
*/
function ensureWebhookSecret(force) {
const current = String(getSetting('webhook_secret', '') || '').trim();
if (!force && current && current !== 'billing-rtrw-secret-key' && current.length >= 24) return current;
const secret = crypto.randomBytes(32).toString('hex');
saveSettings({ webhook_secret: secret });
return secret;
}
function ensureGowaWebhookSecret(force) {
const current = String(getSetting('gowa_webhook_secret', '') || '').trim();
if (!force && current && current !== 'secret' && current.length >= 24) return current;
const secret = crypto.randomBytes(32).toString('hex');
saveSettings({ gowa_webhook_secret: secret });
return secret;
}
function ensureDefaultSettings() {
try {
const currentSettings = getSettings();
@@ -293,6 +313,9 @@ function ensureDefaultSettings() {
custom_ai_base_url: '',
custom_ai_model: 'llama3',
ai_self_healing_enabled: false,
company_subheader: '',
update_base_url: 'https://gitea.oxpoe.xyz',
update_repo: 'oxpoe/open-isp',
// RADIUS Server settings (ditambahkan untuk update dari Gitea)
radius_enabled: '0',
radius_secret: 'secret123',
@@ -358,5 +381,7 @@ module.exports = {
getNowLocalISO,
parseDateInTimezone,
startSettingsWatcher,
ensureDefaultSettings
ensureDefaultSettings,
ensureGowaWebhookSecret,
ensureWebhookSecret
};

54
docker-compose.gowa.yml Normal file
View File

@@ -0,0 +1,54 @@
# ─────────────────────────────────────────────────────────────────────────────
# GOWA — WhatsApp gateway + dashboard (UI) untuk OPEN-ISP
#
# Kompatibel dengan docker compose maupun podman compose.
#
# 1. Siapkan kredensial:
# cp .env.gowa.example .env.gowa
# # isi GOWA_BASIC_AUTH dan GOWA_WEBHOOK_SECRET (openssl rand -hex 32)
#
# 2. Jalankan:
# podman compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
# # atau: docker compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
#
# 3. Buka dashboard: http://<ip-host>:3000 (login Basic Auth di atas)
# Scan QR di sana, atau dari OPEN-ISP → WhatsApp.
#
# 4. Di OPEN-ISP (Admin → WhatsApp → GOWA):
# base URL : http://127.0.0.1:3000 (app di host yang sama)
# username/pass : sesuai GOWA_BASIC_AUTH
# webhook secret: sama dengan GOWA_WEBHOOK_SECRET
# gateway : GOWA
#
# Catatan:
# - UI (gowa-ui) diunduh otomatis oleh GOWA saat start; butuh internet.
# Server tanpa internet: set APP_UI_AUTO_UPDATE=false lalu taruh
# gowa-ui.html di volume gowa_storages (folder /app/storages/ui).
# - host.containers.internal = IP host dari dalam container (Podman/Docker).
# Bila tidak resolve, ganti GOWA_WEBHOOK_URL dengan IP LAN host,
# mis. http://10.44.33.51:3001/api/webhook/gowa
# ─────────────────────────────────────────────────────────────────────────────
services:
gowa:
image: docker.io/aldinokemal2104/go-whatsapp-web-multidevice:v9.8.0
container_name: gowa
restart: unless-stopped
ports:
- "${GOWA_BIND:-0.0.0.0}:${GOWA_PORT:-3000}:3000"
volumes:
- gowa_storages:/app/storages
- gowa_statics:/app/statics
environment:
APP_PORT: "3000"
APP_OS: "OPEN-ISP"
APP_BASIC_AUTH: "${GOWA_BASIC_AUTH:?isi GOWA_BASIC_AUTH=user:password}"
APP_UI_ENABLED: "${GOWA_UI_ENABLED:-true}"
APP_UI_AUTO_UPDATE: "${GOWA_UI_AUTO_UPDATE:-true}"
WHATSAPP_ACCOUNT_VALIDATION: "false"
WHATSAPP_WEBHOOK: "${GOWA_WEBHOOK_URL:?isi GOWA_WEBHOOK_URL}"
WHATSAPP_WEBHOOK_SECRET: "${GOWA_WEBHOOK_SECRET:?isi GOWA_WEBHOOK_SECRET}"
WHATSAPP_WEBHOOK_EVENTS: "message"
volumes:
gowa_storages:
gowa_statics:

View File

@@ -47,7 +47,7 @@ Rilis ini menggabungkan pembaruan fitur dari repo upstream (`alijayanet/billing-
- Kolom API key AI disamarkan di halaman Pengaturan.
## Dokumentasi
- 10 screenshot (desktop + mobile) di `docs/screenshots/`.
- 10 screenshot (desktop + mobile) di `docs/screenshots/` (format WebP, ringan).
## Cara upgrade

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,20 +1,22 @@
# Screenshot Aplikasi (v0.5.2)
Format **WebP** (kualitas 78, lebar maks 1280px) — total ±0,36 MB (turun ~84% dari PNG).
Diambil dari instance uji lokal (`http://localhost:3001`) dengan data demo
(`node scripts/seed-demo.js`), viewport 1440×900 (mobile 390×844).
| File | Halaman |
|---|---|
| `01-landing.png` | Landing page publik |
| `02-admin-login.png` | Halaman login admin |
| `03-admin-dashboard.png` | Dashboard admin (ringkasan pelanggan, billing, ONU) |
| `04-admin-customers.png` | Daftar pelanggan |
| `05-admin-inventory.png` | Inventaris stok |
| `06-inventory-stok-keluar.png` | Modal Stok Keluar (FIFO / serial number) |
| `07-admin-settings-ai.png` | Pengaturan → AI & Self-Healing (key disamarkan) |
| `08-customer-login.png` | Login pelanggan + tab Status mengikuti setting `uptime_url` |
| `09-customer-dashboard.png` | Dashboard pelanggan |
| `10-mobile-customer-login.png` | Login pelanggan (tampilan mobile) |
| `01-landing.webp` | Landing page publik |
| `02-admin-login.webp` | Halaman login admin |
| `03-admin-dashboard.webp` | Dashboard admin (ringkasan pelanggan, billing, ONU) |
| `04-admin-customers.webp` | Daftar pelanggan |
| `05-admin-inventory.webp` | Inventaris stok |
| `06-inventory-stok-keluar.webp` | Modal Stok Keluar (FIFO / serial number) |
| `07-admin-settings-ai.webp` | Pengaturan → AI & Self-Healing (key disamarkan) |
| `08-customer-login.webp` | Login pelanggan + tab Status mengikuti setting `uptime_url` |
| `09-customer-dashboard.webp` | Dashboard pelanggan |
| `10-mobile-customer-login.webp` | Login pelanggan (tampilan mobile) |
Catatan: field API key di halaman Pengaturan memakai `type="password"`
sehingga tidak tampil di screenshot maupun saat layar dibagikan.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -10,18 +10,19 @@
const http = require('http');
const PORT = parseInt(process.env.ISOLIR_REDIRECT_PORT || '8099', 10);
let TARGET = process.env.ISOLIR_REDIRECT_URL || '';
// Coba ambil target dari settings.json bila tersedia
// Target dibaca ulang dari settings (bisa diubah dari dashboard tanpa restart).
function currentTarget() {
try {
const { getSetting } = require('./config/settingsManager');
const s = getSetting('isolir_portal_url', '') || '';
if (s) TARGET = s;
} catch (_) { /* abaikan */ }
if (!TARGET) TARGET = 'http://localhost:3001/isolated';
const s = String(getSetting('isolir_portal_url', '') || '').trim();
if (s) return s;
} catch (_) {}
return String(process.env.ISOLIR_REDIRECT_URL || '').trim() || 'http://localhost:3001/isolated';
}
const server = http.createServer((req, res) => {
const TARGET = currentTarget();
// Jangan cache supaya pelanggan selalu diarahkan ke portal terbaru
res.writeHead(302, {
Location: TARGET,
@@ -40,7 +41,7 @@ const server = http.createServer((req, res) => {
server.on('error', (e) => console.error('[isolir-redirect] listen error:', e.message));
server.listen(PORT, '0.0.0.0', () => {
console.log(`[isolir-redirect] listening on 0.0.0.0:${PORT} -> ${TARGET}`);
console.log(`[isolir-redirect] listening on 0.0.0.0:${PORT} -> ${currentTarget()}`);
});
process.on('SIGTERM', () => { server.close(() => process.exit(0)); });

View File

@@ -1178,7 +1178,7 @@
"deactivate_months": "Arrears Limit (months)",
"deactivate_months_hint": "Customer is deactivated (isolated) after this many unpaid months (3 = 3 unpaid new invoices).",
"auto_delete": "Auto-Delete Customer (over limit)",
"auto_delete_hint": "If Active: customers overdue beyond the limit (e.g. 4th month) are deleted automatically — must re-register.",
"auto_delete_hint": "If Active: customers overdue one month past the limit are deleted automatically — must re-register.",
"arrears_notice": "Auto Arrears Warning",
"arrears_notice_hint": "Send a WhatsApp warning when arrears approach the limit. Template in the Templates & Preview tab.",
"manual_action": "Manual Action",
@@ -1283,20 +1283,20 @@
"st_visible": "Visible",
"st_hidden": "Hide",
"st_locked": "Lock",
"pass_label": "Activation Code (Free)",
"pass_ph": "Enter the activation password before saving",
"pass_hint": "Sidebar menu status changes are saved only if the activation password is correct.",
"no_pass": "No activation code yet?",
"get_donation": "Get a FREE activation code on the OPEN-ISP Support page",
"or_contact": "or contact",
"pass_label": "Menu activation",
"pass_ph": "Not required",
"pass_hint": "Not required.",
"no_pass": "Menus can be shown/hidden from this page.",
"get_donation": "Support OPEN-ISP development",
"or_contact": "",
"donation_page": "Free Activation Code (OPEN-ISP Support)",
"save": "Save Sidebar Settings",
"only_admin": "Only the main admin can change sidebar menu status.",
"notes": "Notes",
"note_lock": "If a Locked menu is clicked from the sidebar, the system shows how to get a FREE activation code or contact",
"note_lock": "A locked menu stays closed until enabled from this page.",
"note1": "Hidden status removes the menu from the sidebar and denies direct URL access.",
"note2": "Locked status keeps the menu visible but cannot be opened until changed on this page.",
"note3": "Menu status changes can only be saved after entering the correct activation password."
"note3": ""
},
"acsdev": {
"title": "Device Detail",

View File

@@ -1178,7 +1178,7 @@
"deactivate_months": "Batas Tunggakan (bulan)",
"deactivate_months_hint": "Pelanggan dinonaktif (isolir) bila menunggak sebanyak ini (3 = 3x tagihan baru belum lunas).",
"auto_delete": "Auto-Hapus Pelanggan (lewat batas)",
"auto_delete_hint": "Jika Aktif: pelanggan menunggak lebih dari batas (mis. bulan ke-4) dihapus otomatis — harus daftar ulang.",
"auto_delete_hint": "Jika Aktif: pelanggan menunggak satu bulan setelah batas dihapus otomatis — harus daftar ulang.",
"arrears_notice": "Auto-Peringatan Tunggakan",
"arrears_notice_hint": "Kirim WA peringatan saat menunggak mendekati batas. Template di tab Template & Preview.",
"manual_action": "Aksi Manual",
@@ -1283,20 +1283,20 @@
"st_visible": "Tampil",
"st_hidden": "Sembunyikan",
"st_locked": "Kunci",
"pass_label": "Kode Aktivasi (Gratis)",
"pass_ph": "Masukkan password aktivasi sebelum menyimpan",
"pass_hint": "Perubahan status menu sidebar hanya akan disimpan jika password aktivasi benar.",
"no_pass": "Belum punya kode aktivasi?",
"get_donation": "Dapatkan kode aktivasi GRATIS di halaman Dukungan OPEN-ISP",
"or_contact": "atau hubungi",
"pass_label": "Aktivasi menu",
"pass_ph": "Tidak diperlukan",
"pass_hint": "Tidak diperlukan.",
"no_pass": "Menu bisa ditampilkan/disembunyikan dari halaman ini.",
"get_donation": "Dukung pengembangan OPEN-ISP",
"or_contact": "",
"donation_page": "Kode Aktivasi Gratis (Dukungan OPEN-ISP)",
"save": "Simpan Pengaturan Sidebar",
"only_admin": "Hanya admin utama yang dapat mengubah status menu sidebar.",
"notes": "Catatan",
"note_lock": "Jika menu berstatus Kunci diklik dari sidebar, sistem menampilkan cara mengambil kode aktivasi GRATIS atau menghubungi",
"note_lock": "Menu berstatus Kunci tidak dapat dibuka sampai diaktifkan.",
"note1": "Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.",
"note2": "Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.",
"note3": "Perubahan status menu hanya dapat disimpan setelah memasukkan password aktivasi yang benar."
"note3": ""
},
"acsdev": {
"title": "Detail Perangkat",

View File

@@ -1,6 +1,6 @@
{
"name": "open-isp",
"version": "0.5.3",
"version": "0.6.15",
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js",
"scripts": {
@@ -50,12 +50,12 @@
},
"author": "oxpoe",
"license": "ISC",
"homepage": "https://gitea.oxpoe.qzz.io/oxpoe/open-isp#readme",
"homepage": "https://gitea.oxpoe.xyz/oxpoe/open-isp#readme",
"repository": {
"type": "git",
"url": "git+https://gitea.oxpoe.qzz.io/oxpoe/open-isp.git"
"url": "git+https://gitea.oxpoe.xyz/oxpoe/open-isp.git"
},
"bugs": {
"url": "https://gitea.oxpoe.qzz.io/oxpoe/open-isp/issues"
"url": "https://gitea.oxpoe.xyz/oxpoe/open-isp/issues"
}
}

View File

@@ -492,6 +492,11 @@ function getDeviceParameterValue(device, keys, filterFn) {
}
function extractPppoeIp(d) {
// Virtual parameters GenieACS (lebih cepat & akurat bila tersedia)
for (const k of ['VirtualParameters.pppoeIP', 'VirtualParameters.pppIP', 'VirtualParameters.WanIP', 'VirtualParameters.IPTR069', 'VirtualParameters.pppoe_ip']) {
const val = getNestedValue(d, k);
if (val && typeof val === 'string' && val.trim() && val !== '-' && val !== '0.0.0.0' && val !== 'N/A') return val.trim();
}
const ip = getDeviceParameterValue(d, PPPOE_IP_KEYS, (matchedPath, value, device) => {
if (!value || value === '0.0.0.0' || value === '-') return false;
@@ -511,6 +516,10 @@ function extractPppoeIp(d) {
}
function extractPppoeUser(d) {
for (const k of ['VirtualParameters.pppoeUsername2', 'VirtualParameters.pppoeUsername', 'VirtualParameters.pppUsername', 'VirtualParameters.PPPoEUser', 'VirtualParameters.pppoe_username', 'VirtualParameters.username']) {
const val = getNestedValue(d, k);
if (val && typeof val === 'string' && val.trim() && val !== '-' && val !== 'N/A') return val.trim();
}
const user = getDeviceParameterValue(d, PPPOE_USER_KEYS, (matchedPath, value, device) => {
if (!value || value === '-') return false;
// Abaikan nilai generik yang bukan user PPPoE pelanggan
@@ -705,7 +714,7 @@ function requireSidebarMenuAccess(menuKey) {
}
if (access.reason === 'locked') {
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Hubungi ${sidebarMenuSvc.getFeatureContactPhone()} untuk mendapatkan password.` };
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` };
return res.redirect('/admin/sidebar-settings');
}
@@ -1601,6 +1610,36 @@ router.get('/api/wifi-settings/:deviceId', requireAdmin, async (req, res) => {
}
});
router.post('/api/device/:deviceId/ssid', requireAdmin, express.json(), async (req, res) => {
try {
const deviceId = req.params.deviceId;
const ssid = String(req.body.ssid || '').trim();
if (!ssid || ssid.length < 2) {
return res.status(400).json({ success: false, message: 'Nama WiFi (SSID) minimal 2 karakter' });
}
const ok = await customerDevice.updateSSID(deviceId, ssid);
if (!ok) return res.status(500).json({ success: false, message: 'Gagal mengubah SSID. Pastikan perangkat aktif.' });
return res.json({ success: true, message: `SSID diubah menjadi "${ssid}"` });
} catch (err) {
return res.status(500).json({ success: false, message: err.message });
}
});
router.post('/api/device/:deviceId/password', requireAdmin, express.json(), async (req, res) => {
try {
const deviceId = req.params.deviceId;
const password = String(req.body.password || '').trim();
if (!password || password.length < 8) {
return res.status(400).json({ success: false, message: 'Sandi WiFi minimal 8 karakter' });
}
const ok = await customerDevice.updatePassword(deviceId, password);
if (!ok) return res.status(500).json({ success: false, message: 'Gagal mengubah sandi WiFi. Pastikan perangkat aktif.' });
return res.json({ success: true, message: 'Sandi WiFi berhasil diperbarui' });
} catch (err) {
return res.status(500).json({ success: false, message: err.message });
}
});
router.get('/api/add-wan-status/:deviceId', requireAdmin, async (req, res) => {
try {
const { deviceId } = req.params;

View File

@@ -72,6 +72,7 @@ const _jimpMod = require('jimp');
const Jimp = _jimpMod.Jimp || _jimpMod;
const jsQR = require('jsqr');
const qrisUtil = require('../utils/qrisUtil');
const qrisUniq = require('../services/invoiceQrisService');
const { MultiFormatReader, BarcodeFormat, DecodeHintType, BinaryBitmap, HybridBinarizer, RGBLuminanceSource } = require('@zxing/library');
const QRCode = require('qrcode');
const acsPortal = require('./acsPortal');
@@ -317,7 +318,7 @@ function requireSidebarMenuAccess(menuKey) {
}
if (access.reason === 'locked') {
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Hubungi ${sidebarMenuSvc.FEATURE_CONTACT_PHONE} untuk mendapatkan password.` };
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` };
return res.redirect('/admin/sidebar-settings');
}
@@ -340,9 +341,9 @@ function readTextFileSafe(filePath) {
}
}
function runCmd(cmd, args, cwd) {
function runCmd(cmd, args, cwd, timeoutMs = 0) {
try {
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
return { ok: r.status === 0, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
} catch (e) {
return { ok: false, code: -1, stdout: '', stderr: String(e?.message || e) };
@@ -396,9 +397,11 @@ function getUpdateInfo(repoRoot) {
info.branch = branch;
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
const _base = String(getSetting('update_base_url', 'https://gitea.oxpoe.xyz') || '').trim().replace(/\/+$/, '');
info.repo = _repo || 'origin';
info.baseUrl = _base;
const fetch = _repo
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
? runCmd('git', ['fetch', '--prune', _base + '/' + _repo + '.git', branch], repoRoot, 120000)
: runCmd('git', ['fetch', '--prune'], repoRoot);
if (!fetch.ok) {
info.error = 'Gagal git fetch: ' + (fetch.stderr || fetch.stdout || '').trim();
@@ -3103,7 +3106,6 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
const baseAmount = Number(inv.amount || 0);
if (!Number.isFinite(baseAmount) || baseAmount <= 0) throw new Error('Nominal tagihan tidak valid');
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
const update = db.prepare(`
UPDATE invoices
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
@@ -3116,7 +3118,7 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
for (let i = 0; i < 50; i++) {
const code = 1 + Math.floor(Math.random() * 999);
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
chosenCode = code;
chosenAmount = amount;
break;
@@ -3126,7 +3128,7 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
if (!chosenAmount) {
for (let code = 1; code <= 999; code++) {
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
chosenCode = code;
chosenAmount = amount;
break;
@@ -3200,7 +3202,6 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
if (hasStaticQris && String(inv.status) === 'unpaid' && (!qrisAmountUnique || !qrisCode || (qrisAmountUnique - qrisCode !== baseAmount))) {
const invId = Number(inv.id);
if (Number.isFinite(invId) && invId > 0 && Number.isFinite(baseAmount) && baseAmount > 0) {
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
const update = db.prepare(`
UPDATE invoices
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
@@ -3212,7 +3213,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
if (chosenCode > 0) {
const pAmt = baseAmount + chosenCode;
if (!exists.get('unpaid', pAmt, invId)) {
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
chosenAmount = pAmt;
}
}
@@ -3221,7 +3222,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
for (let i = 0; i < 50; i++) {
const code = 1 + Math.floor(Math.random() * 999);
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
chosenCode = code;
chosenAmount = amount;
break;
@@ -3231,7 +3232,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
if (!chosenAmount) {
for (let code = 1; code <= 999; code++) {
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
chosenCode = code;
chosenAmount = amount;
break;
@@ -3987,11 +3988,6 @@ router.get('/sidebar-settings', requireAdminSession, (req, res) => {
router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
const featurePassword = String(req.body.feature_password || '').trim();
if (!sidebarMenuSvc.isFeaturePasswordValid(featurePassword)) {
throw new Error(`Password aktivasi salah. Hubungi ${sidebarMenuSvc.getFeatureContactPhone()} untuk mendapatkan password yang benar.`);
}
const menuStates = sidebarMenuSvc.sanitizeMenuStates(req.body.menu_state || {});
const success = sidebarMenuSvc.saveMenuStates(menuStates);
if (!success) throw new Error('Gagal menyimpan pengaturan sidebar');
@@ -4017,14 +4013,133 @@ router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.u
res.redirect('/admin/sidebar-settings');
});
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
res.render('admin/service_notice', {
title: 'Status Layanan & Pengumuman', company: company(), activePage: 'service_notice',
settings: getSettings(), msg: flashMsg(req)
// ─── QR CUSTOM PAYMENT (nominal bebas, kode unik 20 menit) ─────────────────
router.get('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_qr'), (req, res) => {
const svc = require('../services/customQrService');
let detail = null;
const detailId = Number(req.query.id) || 0;
if (detailId > 0) { try { detail = svc.getById(detailId); } catch (e) {} }
const filterFrom = String(req.query.from || '').trim();
const filterTo = String(req.query.to || '').trim();
const settings = getSettings();
const baseUrl = String(settings.public_base_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
res.render('admin/custom_qr', {
title: 'QR-Custom Payment',
company: company(),
activePage: 'custom_qr',
msg: flashMsg(req),
rows: svc.list(200, { from: filterFrom, to: filterTo }),
filterFrom, filterTo,
stats: svc.stats(),
detail,
baseUrl,
durationMinutes: svc.DURATION_MINUTES,
hasQrisPayload: !!String(settings.qris_static_payload || '').trim(),
ready: svc.isReady()
});
});
router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), (req, res) => {
router.post('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
const svc = require('../services/customQrService');
const row = svc.create({
title: req.body.title,
category: req.body.category,
amount: req.body.amount,
note: req.body.note,
createdBy: resolvePaidByName(req, 'Admin')
});
req.session._msg = { type: 'success', text: `QR custom dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} — berlaku ${svc.DURATION_MINUTES} menit.` };
return res.redirect('/admin/custom-qr?id=' + row.id);
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal membuat QR custom: ' + e.message };
return res.redirect('/admin/custom-qr');
}
});
router.post('/custom-qr/:id/manual-paid', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
require('../services/customQrService').markManualPaid(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
req.session._msg = { type: 'success', text: 'Pembayaran custom ditandai LUNAS (manual).' };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
});
function _checkAdminPassword(req) {
const pass = String((req.body && req.body.password) || '');
const expected = String(getSetting('admin_password', 'demo-isp123') || 'demo-isp123');
return !!expected && pass === expected;
}
router.post('/custom-qr/:id/delete', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
const n = require('../services/customQrService').remove(req.params.id);
req.session._msg = n ? { type: 'success', text: 'QR custom dihapus.' } : { type: 'error', text: 'Data tidak ditemukan.' };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/custom-qr');
});
router.post('/custom-qr/reset', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
const n = require('../services/customQrService').clearAll();
req.session._msg = { type: 'success', text: `Histori QR custom direset (${n} data dihapus).` };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/custom-qr');
});
router.post('/custom-qr/delete-bulk', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids];
const n = require('../services/customQrService').removeMany(ids);
req.session._msg = { type: 'success', text: `${n} QR custom dihapus.` };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
const q = [];
if (req.body.from) q.push('from=' + encodeURIComponent(req.body.from));
if (req.body.to) q.push('to=' + encodeURIComponent(req.body.to));
res.redirect('/admin/custom-qr' + (q.length ? ('?' + q.join('&')) : ''));
});
router.post('/custom-qr/:id/regenerate', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
const row = require('../services/customQrService').regenerate(req.params.id, resolvePaidByName(req, 'Admin'));
req.session._msg = { type: 'success', text: `QR baru dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} (kode ${row.unique_code}).` };
return res.redirect('/admin/custom-qr?id=' + row.id);
} catch (e) {
req.session._msg = { type: 'error', text: e.message };
return res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
}
});
router.post('/custom-qr/:id/accept', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
require('../services/customQrService').acceptWarning(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
req.session._msg = { type: 'success', text: 'Pembayaran setelah 20 menit diterima.' };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
});
router.post('/custom-qr/:id/reject', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
require('../services/customQrService').reject(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
req.session._msg = { type: 'success', text: 'Pembayaran ditolak / refund dicatat.' };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
});
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
let routers = [];
try { routers = db.prepare('SELECT id, name FROM routers WHERE is_active = 1 ORDER BY name ASC').all(); } catch (_) {}
res.render('admin/service_notice', {
title: 'Status Layanan & Pengumuman', company: company(), activePage: 'service_notice',
settings: getSettings(), msg: flashMsg(req),
areas: customerSvc.getAllCustomerAreas(), routers
});
});
router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), async (req, res) => {
try {
const allowed = ['NORMAL', 'GANGGUAN_MASAL', 'PERBAIKAN'];
let mode = String(req.body.notice_mode || 'NORMAL').trim().toUpperCase();
@@ -4032,6 +4147,7 @@ router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('se
let interval = parseInt(req.body.notice_interval_hours, 10);
if (!interval || interval < 1) interval = 5;
if (interval > 168) interval = 168;
const prevMode = String(getSetting('notice_mode', 'NORMAL') || 'NORMAL').toUpperCase();
saveSettings({
notice_mode: mode,
notice_start: String(req.body.notice_start || '').trim(),
@@ -4041,7 +4157,27 @@ router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('se
const noticeSvc = require('../services/serviceNoticeService');
const n = noticeSvc.computeNotice();
const detail = n.active ? (n.label + (n.estimateText ? ' — estimasi ' + n.estimateText : '')) : 'Normal (tanpa pengumuman)';
req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail };
let waInfo = '';
if (req.body.notice_wa === '1') {
if (mode === prevMode) {
waInfo = ' Notifikasi WA dilewati (mode tidak berubah).';
} else {
try {
const r = await require('../services/serviceNoticeNotifyService').broadcastNotice({
mode,
target: String(req.body.notice_wa_target || 'all'),
area: String(req.body.notice_wa_area || ''),
routerId: parseInt(req.body.notice_wa_router, 10) || 0,
notice: n
});
if (r.ok) waInfo = ` Notifikasi WA dikirim ke ${r.total} pelanggan (latar belakang).`;
else waInfo = ` Notifikasi WA tidak dikirim: ${r.reason === 'empty' ? 'tidak ada nomor valid' : r.reason === 'busy' ? 'proses lain masih berjalan' : 'WhatsApp belum aktif'}.`;
} catch (waErr) {
waInfo = ' Notifikasi WA gagal: ' + waErr.message;
}
}
}
req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail + '.' + waInfo };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal menyimpan status layanan: ' + e.message };
}
@@ -4405,15 +4541,17 @@ router.post('/automation/send-now', requireAdminSession, express.urlencoded({ ex
});
router.get('/settings', requireAdminSession, requireSidebarMenuAccess('settings'), (req, res) => {
const settings = getSettings();
let settings = getSettings();
try { require('../config/settingsManager').ensureWebhookSecret(); settings = getSettings(); } catch (e) {}
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.get('host');
const baseUrl = (settings && settings.app_url ? String(settings.app_url) : `${protocol}://${host}`).replace(/\/+$/, '');
const baseUrl = (settings.public_base_url || (settings && settings.app_url) || `${protocol}://${host}`).toString().replace(/\/+$/, '');
const paymentWebhookUrl = `${baseUrl}/customer/payment/callback`;
res.render('admin/settings', {
title: 'Pengaturan Sistem', company: company(), activePage: 'settings',
settings, msg: flashMsg(req),
paymentWebhookUrl
paymentWebhookUrl,
webhookBaseUrl: baseUrl
});
});
@@ -4578,16 +4716,34 @@ router.post('/settings/regenerate-icons', requireAdminSession, async (req, res)
router.get('/update', requireAdminSession, requireSidebarMenuAccess('update'), restrictToAdmin, (req, res) => {
const repoRoot = path.resolve(__dirname, '..');
const info = getUpdateInfo(repoRoot);
let restartPending = null;
try {
const markerPath = path.join(repoRoot, '.restart-required');
if (fs.existsSync(markerPath)) restartPending = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
} catch (e) { restartPending = null; }
res.render('admin/update', {
title: 'Update Aplikasi',
company: company(),
activePage: 'update',
msg: flashMsg(req),
info,
restartPending,
log: popUpdateLog(req)
});
});
router.get('/api/restart-required', requireAdminSession, (req, res) => {
const repoRoot = path.resolve(__dirname, '..');
try {
const markerPath = path.join(repoRoot, '.restart-required');
if (fs.existsSync(markerPath)) {
const d = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
return res.json({ pending: true, version: d.version || '', from: d.from || '', at: d.at || '' });
}
} catch (e) {}
return res.json({ pending: false });
});
router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
const repoRoot = path.resolve(__dirname, '..');
if (!runCmd('git', ['rev-parse', '--is-inside-work-tree'], repoRoot).ok) {
@@ -4609,6 +4765,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
const preservedEntries = [
{ label: 'settings.json', relativePath: 'settings.json', cleanExclude: 'settings.json' },
{ label: '.env', relativePath: '.env', cleanExclude: '.env' },
{ label: '.env.gowa', relativePath: '.env.gowa', cleanExclude: '.env.gowa' },
{ label: 'database', relativePath: 'database', cleanExclude: 'database' },
{ label: 'public/uploads', relativePath: 'public/uploads', cleanExclude: 'public/uploads' },
{ label: 'public/img', relativePath: 'public/img', cleanExclude: 'public/img' },
@@ -4666,9 +4823,9 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
if (!inside.ok) throw new Error('Folder ini belum menjadi git repository.');
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
info.repo = _repo || 'origin';
const _base = String(getSetting('update_base_url', 'https://gitea.oxpoe.xyz') || '').trim().replace(/\/+$/, '');
const fetch = _repo
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
? runCmd('git', ['fetch', '--prune', _base + '/' + _repo + '.git', branch], repoRoot, 120000)
: runCmd('git', ['fetch', '--prune'], repoRoot);
pushCmd('git fetch --prune', fetch);
if (!fetch.ok) throw new Error('Gagal git fetch.');
@@ -4689,11 +4846,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
fs.mkdirSync(backupRoot, { recursive: true });
backupPreservedEntries();
const resetSettings = runCmd('git', ['checkout', '--', 'settings.json'], repoRoot);
pushCmd('git checkout -- settings.json', resetSettings);
const resetDb = runCmd('git', ['checkout', '--', 'database'], repoRoot);
pushCmd('git checkout -- database', resetDb);
// settings.json / database tidak dilacak git; jangan checkout (menghasilkan error pathspec).
const resetHard = runCmd('git', ['reset', '--hard', _repo ? 'FETCH_HEAD' : `origin/${branch}`], repoRoot);
pushCmd(`git reset --hard origin/${branch}`, resetHard);
if (!resetHard.ok) throw new Error('Gagal reset ke origin/' + branch);
@@ -4736,7 +4889,17 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
}
const localAfter = readTextFileSafe(versionPath) || '-';
req.session._msg = { type: 'success', text: `Update selesai. Versi: ${localBefore} → ${localAfter}. Silakan restart aplikasi.` };
try {
fs.writeFileSync(
path.join(repoRoot, '.restart-required'),
JSON.stringify({ version: localAfter, from: localBefore, at: new Date().toISOString() }),
'utf8'
);
log.push('$ restart-required marker ditulis');
} catch (markerErr) {
log.push('$ restart-required marker gagal: ' + String(markerErr?.message || markerErr));
}
req.session._msg = { type: 'success', text: `Update selesai. Versi: ${localBefore} → ${localAfter}. Restart aplikasi diperlukan agar perubahan aktif.` };
req.session._updateLog = log.join('\n');
} catch (e) {
if (repoChanged) {
@@ -4746,6 +4909,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
log.push(`$ restore preserved files failed: ${String(restoreErr?.message || restoreErr)}`);
}
}
log.push(`$ ERROR: ${e?.message || e}`);
req.session._msg = { type: 'error', text: 'Gagal update: ' + (e?.message || e) };
req.session._updateLog = log.join('\n');
} finally {
@@ -4767,6 +4931,76 @@ router.post('/api/telegram/sync', requireAdminSession, async (req, res) => {
}
});
router.post('/settings/test-ai', requireAdminSession, express.json(), async (req, res) => {
const presets = {
deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
groq: { baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile' },
openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'meta-llama/llama-3.3-70b-instruct' },
custom: { baseUrl: '', model: '' }
};
try {
const b = req.body || {};
const provider = String(b.provider || 'deepseek').trim().toLowerCase();
const p = presets[provider] || presets.deepseek;
const apiKey = String(b.apiKey || '').trim();
const baseUrl = String(b.baseUrl || p.baseUrl || '').trim().replace(/\/+$/, '');
const model = String(b.model || p.model || '').trim();
if (!baseUrl) return res.status(400).json({ ok: false, message: 'Base URL AI kosong.' });
if (!model) return res.status(400).json({ ok: false, message: 'Model AI kosong.' });
if (!apiKey && provider !== 'custom') return res.status(400).json({ ok: false, message: 'API key kosong.' });
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(function () { controller.abort(); }, 15000);
let r;
let text = '';
try {
r = await fetch(baseUrl + '/chat/completions', {
method: 'POST',
headers: Object.assign(
{ 'Content-Type': 'application/json' },
apiKey ? { Authorization: 'Bearer ' + apiKey } : {}
),
body: JSON.stringify({
model: model,
messages: [{ role: 'user', content: 'ping' }],
max_tokens: 5,
stream: false
}),
signal: controller.signal
});
text = await r.text();
} finally {
clearTimeout(timer);
}
const latencyMs = Date.now() - started;
let json = null;
try { json = JSON.parse(text); } catch (e) {}
if (!r.ok) {
const detail = (json && json.error && (json.error.message || json.error.code)) || text || ('HTTP ' + r.status);
const message = (r.status === 401 || r.status === 403)
? 'API key tidak valid / tidak punya akses (HTTP ' + r.status + ').'
: 'Gagal (HTTP ' + r.status + '): ' + String(detail).replace(/\s+/g, ' ').slice(0, 200);
return res.json({ ok: false, provider, model, baseUrl, status: r.status, latencyMs, message });
}
const reply = (json && json.choices && json.choices[0] &&
((json.choices[0].message && json.choices[0].message.content) || json.choices[0].text)) || '';
return res.json({
ok: true, provider, model, baseUrl, status: r.status, latencyMs,
message: 'API valid — ' + (reply ? ('balasan: ' + String(reply).trim().slice(0, 80)) : 'respon OK') + ' (' + latencyMs + ' ms)'
});
} catch (e) {
const message = e && e.name === 'AbortError'
? 'Timeout 15 detik — server AI tidak merespons.'
: ('Tidak bisa menghubungi API: ' + (e && e.message ? e.message : String(e)));
return res.status(500).json({ ok: false, message });
}
});
router.post('/settings', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const newSettings = { ...req.body };
@@ -4775,6 +5009,12 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false;
if (newSettings.update_base_url !== undefined) {
newSettings.update_base_url = String(newSettings.update_base_url).trim().replace(/\/+$/, '');
}
if (newSettings.update_repo !== undefined) {
newSettings.update_repo = String(newSettings.update_repo).trim().replace(/^\/+|\/+$/g, '');
}
newSettings.ai_provider = String(newSettings.ai_provider || 'deepseek').trim().toLowerCase();
newSettings.ai_self_healing_enabled = newSettings.ai_self_healing_enabled === 'true' || newSettings.ai_self_healing_enabled === 'on';
for (const k of ['deepseek_api_key','openai_api_key','groq_api_key','openrouter_api_key','custom_ai_api_key','custom_ai_base_url','custom_ai_model','deepseek_model','openai_model','groq_model','openrouter_model']) {
@@ -4829,6 +5069,7 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
if (newSettings.auto_deactivate_enabled !== undefined) newSettings.auto_deactivate_enabled = (newSettings.auto_deactivate_enabled === 'true');
if (newSettings.auto_deactivate_months !== undefined) newSettings.auto_deactivate_months = parseInt(newSettings.auto_deactivate_months) || 3;
newSettings.use_builtin_acs = (newSettings.use_builtin_acs === 'true' || newSettings.use_builtin_acs === true);
newSettings.portal_banner_enabled = (newSettings.portal_banner_enabled === 'true' || newSettings.portal_banner_enabled === true);
// Multi-Router Mode settings
if (!newSettings.multi_router_mode) newSettings.multi_router_mode = 'disabled';
@@ -4898,6 +5139,29 @@ router.post('/payment-unmatched/:id/match', requireAdminSession, express.urlenco
if (!unpaid) await customerSvc.activateCustomer(inv.customer_id);
}
} catch (e) {}
// Notifikasi WA lunas ke pelanggan
try {
const cust = customerSvc.getCustomerById(inv.customer_id);
if (cust && cust.phone) {
const pkg = cust.package_id ? customerSvc.getPackageById(cust.package_id) : null;
await sendPaymentSuccessWA(
cust.phone,
cust.name,
`${inv.period_month}/${inv.period_year}`,
Number(inv.amount || 0).toLocaleString('id-ID'),
'Manual (Webhook)',
{
customerId: cust.id,
invoiceId: inv.id,
username: cust.pppoe_username || cust.id,
customerCode: cust.customer_code || '',
packageName: pkg?.name || cust.package_name || '-',
paidAt: new Date(), method: 'Manual (Webhook)'
}
);
}
} catch (e) {}
req.session._msg = { type: 'success', text: 'Invoice #' + invId + ' ditandai LUNAS & notif dicocokkan.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal cocokkan: ' + e.message };
@@ -6621,10 +6885,22 @@ router.get('/whatsapp/gowa/qr', requireAdminSession, async (req, res) => {
try {
const gowa = require('../services/gowaWhatsappService');
const dev = await gowa.ensureDevice();
if (dev && dev !== getSetting('gowa_device_id', '')) saveSettings({ gowa_device_id: dev });
// Jangan simpan otomatis: device id kosong = ikuti device yang terhubung via QR.
const r = await gowa.getLoginQr(dev);
if (!r.ok) return res.json({ ok: false, message: 'Gagal membuat QR (' + (r.code || 'pastikan GOWA berjalan') + ').' });
return res.json({ ok: true, image: '/admin/whatsapp/gowa/qr.png?v=' + Date.now(), device_id: r.device_id, duration: r.duration });
if (!r.ok) {
const detail = r.message || r.code || 'pastikan GOWA berjalan & username/password Basic Auth benar';
return res.json({ ok: false, status: r.status || 0, message: 'Gagal membuat QR: ' + (r.status ? 'HTTP ' + r.status + ' — ' : '') + detail + '.' });
}
// Kirim gambar langsung sebagai data URI: tidak bergantung pada request
// kedua /qr.png (aman bila app berjalan multi-proses) dan tidak kena cache.
let image = '/admin/whatsapp/gowa/qr.png?v=' + Date.now();
try {
const img = await gowa.getLastQrImage();
if (img && img.buffer && img.buffer.length) {
image = 'data:' + (img.contentType || 'image/png') + ';base64,' + img.buffer.toString('base64');
}
} catch (e) {}
return res.json({ ok: true, image, device_id: r.device_id, duration: r.duration });
} catch (e) { return res.json({ ok: false, message: e.message }); }
});
@@ -6652,12 +6928,17 @@ router.get('/whatsapp', requireAdminSession, requireSidebarMenuAccess('whatsapp'
fonnte_api_url: getSetting('fonnte_api_url', 'https://api.fonnte.com/send'),
fonnte_country_code: getSetting('fonnte_country_code', '62')
};
const gowaBaseUrl = getSetting('gowa_base_url', 'http://127.0.0.1:3000');
let gowaWebhookSecret = getSetting('gowa_webhook_secret', '');
try { gowaWebhookSecret = require('../config/settingsManager').ensureGowaWebhookSecret(); } catch (e) {}
const gowaSettings = {
gowa_base_url: getSetting('gowa_base_url', 'http://127.0.0.1:3000'),
gowa_mode: getSetting('gowa_mode', /^https:/i.test(String(gowaBaseUrl)) ? 'cloud' : 'local'),
gowa_secret_mode: getSetting('gowa_secret_mode', 'auto') === 'manual' ? 'manual' : 'auto',
gowa_base_url: gowaBaseUrl,
gowa_username: getSetting('gowa_username', ''),
gowa_password: getSetting('gowa_password', ''),
gowa_device_id: getSetting('gowa_device_id', ''),
gowa_webhook_secret: getSetting('gowa_webhook_secret', 'secret')
gowa_webhook_secret: gowaWebhookSecret
};
const gowaWebhookUrl = String(getSetting('app_url', '') || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '') + '/api/webhook/gowa';
const gowaUiHost = String(req.get('host') || '').split(':')[0];
@@ -6690,6 +6971,7 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
fonnte_token,
fonnte_api_url,
fonnte_country_code,
gowa_mode,
gowa_base_url,
gowa_username,
gowa_password,
@@ -6707,11 +6989,15 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
fonnte_token: String(fonnte_token || '').trim(),
fonnte_api_url: String(fonnte_api_url || 'https://api.fonnte.com/send').trim(),
fonnte_country_code: String(fonnte_country_code || '62').trim(),
gowa_mode: String(gowa_mode || 'cloud').trim() === 'local' ? 'local' : 'cloud',
gowa_base_url: String(gowa_base_url || 'http://127.0.0.1:3000').trim(),
gowa_username: String(gowa_username || '').trim(),
gowa_password: String(gowa_password || '').trim(),
gowa_device_id: String(gowa_device_id || '').trim(),
gowa_webhook_secret: String(gowa_webhook_secret || 'secret').trim()
gowa_webhook_secret: (function () {
const v = String(gowa_webhook_secret || '').trim();
return (v && v !== 'secret' && v.length >= 24) ? v : crypto.randomBytes(32).toString('hex');
})()
});
if ((wa_gateway_type || 'baileys') === 'baileys') {
@@ -6727,6 +7013,46 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
res.redirect('/admin/whatsapp');
});
router.post('/whatsapp/test-gowa-connection', requireAdminSession, express.json(), async (req, res) => {
try {
const gowaSvc = require('../services/gowaWhatsappService');
const b = req.body || {};
const r = await gowaSvc.testReachability({
baseUrl: b.gowa_base_url,
username: b.gowa_username,
password: b.gowa_password
});
return res.json(Object.assign({ ok: r.reachable, success: r.reachable }, r));
} catch (e) {
return res.status(500).json({ ok: false, success: false, message: e.message });
}
});
router.post('/whatsapp/gowa-secret', requireAdminSession, express.json(), async (req, res) => {
try {
const sm = require('../config/settingsManager');
const b = req.body || {};
const mode = String(b.mode || '').toLowerCase() === 'manual' ? 'manual' : 'auto';
if (mode === 'auto') {
const secret = sm.ensureGowaWebhookSecret(true);
saveSettings({ gowa_secret_mode: 'auto' });
return res.json({ ok: true, mode: 'auto', secret });
}
const secret = String(b.secret || '').trim();
if (secret) {
if (secret === 'secret' || secret.length < 16) {
return res.status(400).json({ ok: false, message: 'Secret minimal 16 karakter dan tidak boleh "secret".' });
}
saveSettings({ gowa_webhook_secret: secret, gowa_secret_mode: 'manual' });
return res.json({ ok: true, mode: 'manual', saved: true, secret });
}
saveSettings({ gowa_secret_mode: 'manual' });
return res.json({ ok: true, mode: 'manual', saved: false, secret: getSetting('gowa_webhook_secret', '') });
} catch (e) {
return res.status(500).json({ ok: false, message: e.message });
}
});
router.post('/whatsapp/test-gowa', requireAdminSession, express.json(), async (req, res) => {
try {
const { phone, message } = req.body;
@@ -7207,8 +7533,14 @@ router.post('/whatsapp/auto-billing', requireAdminSession, express.urlencoded({
router.post('/whatsapp/run-reminder-now', requireAdminSession, async (req, res) => {
try {
const r = await require('../services/cronService').runAutoBillingReminder();
const skipLabels = {
disabled: 'pengingat otomatis sedang OFF',
wa_disabled: 'WhatsApp atau notifikasi tagihan sedang OFF',
gateway: 'gateway WhatsApp belum siap',
no_targets: 'tidak ada pelanggan pada jadwal pengingat hari ini'
};
const msg = (r && r.skipped)
? ('Pengingat dilewati (' + r.skipped + '). Pastikan opsi otomatis & WA aktif.')
? ('Pengingat tidak dikirim: ' + (skipLabels[r.skipped] || r.skipped) + '.')
: ('Pengingat dijalankan: target ' + ((r && r.target) || 0) + ', terkirim ' + ((r && r.sent) || 0) + ', gagal ' + ((r && r.failed) || 0) + '.');
return res.json({ ok: true, message: msg, data: r || null });
} catch (e) {
@@ -7234,11 +7566,24 @@ router.get('/api/whatsapp/status', requireAdmin, async (req, res) => {
} else if (gatewayType === 'gowa') {
let st = null;
try { st = await require('../services/gowaWhatsappService').gowaStatus(); } catch (e) { st = { ok: false, error: e.message }; }
const rr = (st && st.data && st.data.results) || {};
const loggedIn = !!(rr.is_logged_in || rr.isConnected || rr.connected);
const isConn = !!(rr.is_connected || rr.connected);
// /devices mengembalikan array (banyak device); /devices/:id/status mengembalikan objek.
const raw = st && st.data && st.data.results;
const list = Array.isArray(raw) ? raw : ((raw && typeof raw === 'object') ? [raw] : []);
const pick = list.find(function (d) {
return d && (d.is_connected || d.is_logged_in || d.connected ||
d.state === 'connected' || d.state === 'logged_in');
}) || list[0] || {};
const loggedIn = !!(pick.is_logged_in || pick.is_connected || pick.connected ||
pick.state === 'connected' || pick.state === 'logged_in');
const isConn = !!(pick.is_connected || pick.connected || pick.state === 'connected');
const conn = isConn ? 'open' : (loggedIn ? 'connecting' : 'close');
res.json({ connection: conn, gateway: 'gowa', loggedIn: loggedIn, user: { id: 'gowa_gateway', name: loggedIn ? 'GOWA (WhatsApp)' : 'GOWA Gateway' }, lastUpdate: new Date().toISOString() });
const deviceId = String(pick.device_id || pick.id || '').trim();
// device id kosong = selalu ikuti device yang terhubung (tidak dipin otomatis)
res.json({
connection: conn, gateway: 'gowa', loggedIn: loggedIn, deviceId: deviceId,
user: { id: deviceId || 'gowa_gateway', name: loggedIn ? 'GOWA (WhatsApp)' : 'GOWA Gateway' },
lastUpdate: new Date().toISOString()
});
} else if (gatewayType === 'baileys') {
const { whatsappStatus, startWhatsAppBot, isBotRunning } = await import('../services/whatsappBot.mjs');
if (getSetting('whatsapp_enabled', true) && !isBotRunning() && whatsappStatus.connection !== 'open' && whatsappStatus.connection !== 'qr') {
@@ -7329,9 +7674,12 @@ router.post('/whatsapp/repair-keys', requireAdminSession, async (req, res) => {
// ─── ROUTERS (MULTI-ROUTER) ──────────────────────────────────────────────────
router.get('/routers', requireAdminSession, requireSidebarMenuAccess('mikrotik'), (req, res) => {
const _settings = getSettings();
const defaultPublicBase = String(_settings.public_base_url || _settings.app_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
res.render('admin/routers', {
title: 'Manajemen Router', company: company(), activePage: 'mikrotik',
routers: mikrotikService.getAllRouters(), msg: flashMsg(req)
routers: mikrotikService.getAllRouters(), msg: flashMsg(req),
defaultPublicBase
});
});
@@ -7643,6 +7991,19 @@ router.post('/api/routers/:id/setup-firewall', requireAdmin, async (req, res) =>
}
});
router.get('/api/isolir-cloud-script', requireAdmin, async (req, res) => {
try {
const data = await mikrotikService.generateIsolirCloudScript({
host: req.query.host,
port: req.query.port,
profile: req.query.profile
});
res.json({ success: true, ...data });
} catch (e) {
res.json({ success: false, error: e.message });
}
});
router.get('/api/isolir-portal-script', requireAdmin, async (req, res) => {
try {
const data = await mikrotikService.generateIsolirPortalScript();

View File

@@ -18,6 +18,7 @@ const adminSvc = require('../services/adminService');
const techSvc = require('../services/techService');
const mikrotikService = require('../services/mikrotikService');
const qrisUtil = require('../utils/qrisUtil');
const qrisUniq = require('../services/invoiceQrisService');
const QRCode = require('qrcode');
// Helper Token JWT / HMAC
@@ -2358,6 +2359,37 @@ router.post('/app/admin/collector-payments/approve', requireAdminApiAuth, async
if (unpaidCount === 0) {
try { await customerSvc.activateCustomer(pay.customer_id); } catch (_) {}
}
// Notifikasi WA lunas ke pelanggan (gateway aktif)
try {
const customer = customerSvc.getCustomerById(pay.customer_id);
const inv = billingSvc.getInvoiceById(pay.invoice_id);
if (customer && customer.phone && inv) {
const settings = getSettingsWithCache();
const waNotify = require('../services/waNotifyService');
const whatsappService = require('../services/whatsappService');
const pkg = customer.package_id ? customerSvc.getPackageById(customer.package_id) : null;
const appUrl = String(settings.public_base_url || '').replace(/\/$/, '');
const template = db.getAppSetting('whatsapp_payment_success_message', '');
const msg = whatsappService.formatPaymentSuccessMessage({
customerName: customer.name,
invoiceId: inv.id,
customerUsername: customer.pppoe_username || customer.id || '-',
customerCode: customer.customer_code || '',
packageName: pkg?.name || customer.package_name || '-',
periodMonth: inv.period_month,
periodYear: inv.period_year,
amount: inv.amount,
paymentMethod: collectorLabel,
paidAt: new Date(),
companyName: settings.company_header || 'OPEN-ISP',
companyPhone: settings.company_phone || '',
portalUrl: appUrl ? appUrl + '/customer' : '',
customTemplate: template
});
await waNotify.sendText(customer.phone, msg);
}
} catch (_) {}
}
res.json({ success: true, message: 'Pembayaran berhasil di-approve dan tagihan ditandai LUNAS' });
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
@@ -3509,6 +3541,11 @@ router.post('/tickets/create', requireCustomerApiAuth, async (req, res) => {
for (const digits of recipients) {
try { await waNotify.sendText(digits, waMsg); } catch (_) {}
}
// WA konfirmasi ke pelanggan yang membuat tiket
try {
await require('../services/serviceNoticeNotifyService').notifyTicketCreated(customer, ticketId, subject);
} catch (_) {}
}
} catch (waErr) {
logger.error(`[CustomerAPI] Ticket WA Notification Error: ${waErr.message}`);
@@ -3670,7 +3707,6 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
return { uniqueCode: currentCode, amountUnique: currentAmount };
}
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
let chosenCode = 0;
let chosenAmount = 0;
@@ -3678,7 +3714,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
if (custId > 0) {
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
const prefAmount = baseAmount + prefCode;
if (!exists.get('unpaid', prefAmount, invId)) {
if (!qrisUniq.isQrisAmountTaken(prefAmount, { excludeInvoiceId: invId })) {
chosenCode = prefCode;
chosenAmount = prefAmount;
}
@@ -3688,7 +3724,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
if (!chosenAmount) {
for (let code = 1; code <= 499; code++) {
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
chosenCode = code;
chosenAmount = amount;
break;
@@ -3700,7 +3736,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
if (!chosenAmount) {
for (let code = 500; code <= 999; code++) {
const amount = baseAmount + code;
if (!exists.get('unpaid', amount, invId)) {
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
chosenCode = code;
chosenAmount = amount;
break;

View File

@@ -26,6 +26,7 @@ const QRCode = require('qrcode');
const _jimpMod = require('jimp');
const Jimp = _jimpMod.Jimp || _jimpMod;
const qrisUtil = require('../utils/qrisUtil');
const qrisUniq = require('../services/invoiceQrisService');
const whatsappService = require('../services/whatsappService');
const { BinaryBitmap, HybridBinarizer, RGBLuminanceSource, MultiFormatReader, BarcodeFormat, DecodeHintType } = require('@zxing/library');
let loginRateLimiter = (req, res, next) => next();
@@ -437,16 +438,10 @@ function getBaseUrl(req, settings) {
function isQrisAmountAvailable(amount, opts = {}) {
const amt = Number(amount || 0);
if (!Number.isFinite(amt) || amt <= 0) return false;
const excludeInvoiceId = Number(opts.excludeInvoiceId || 0);
const excludeVoucherOrderId = Number(opts.excludeVoucherOrderId || 0);
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, excludeInvoiceId);
if (inv && inv.id) return false;
const ord = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, excludeVoucherOrderId);
if (ord && ord.id) return false;
return true;
return !qrisUniq.isQrisAmountTaken(amt, {
excludeInvoiceId: opts.excludeInvoiceId,
excludeVoucherOrderId: opts.excludeVoucherOrderId
});
}
router.get('/qris/static.jpg', async (req, res) => {
@@ -3002,6 +2997,11 @@ router.post('/tickets/create', uploadCustomer.array('photos', 5), async (req, re
if (!shouldSendWa(key)) continue;
await waNotify.sendText(digits, waMsg);
}
// WA konfirmasi ke pelanggan yang membuat tiket
try {
await require('../services/serviceNoticeNotifyService').notifyTicketCreated(customer, ticketId, subject);
} catch (_) {}
}
} catch (waErr) {
logger.error(`[Ticket] WA Notification Error: ${waErr.message}`);

View File

@@ -48,7 +48,7 @@ function testIsAdminNumber(number) {
// Test beberapa nomor
const testNumbers = [
'6281947215703',
'081947215703',
'083807665697',
'082218094778',
'081234567890' // Nomor test yang bukan admin

View File

@@ -40,15 +40,19 @@ function spintax(t) {
});
}
function buildMessage(tplRaw, c, inv) {
function buildMessage(tplRaw, c, inv, qr) {
const amount = Number(inv.amount || 0);
const qrisAmount = Number((qr && qr.amount) || 0) || amount;
const qrisCode = Number((qr && qr.uniqueCode) || 0) || 0;
const qrisAttached = !!(qr && qr.payload);
const period = inv.period_month + '/' + inv.period_year;
return spintax(String(tplRaw || '')
.replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id))
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
.replace(/{{paket}}/gi, inv.package_name || '-')
.replace(/{{tagihan}}/gi, amount.toLocaleString('id-ID'))
.replace(/{{qris_nominal}}/gi, amount.toLocaleString('id-ID'))
.replace(/{{qris_nominal}}/gi, qrisAmount.toLocaleString('id-ID'))
.replace(/{{qris_kode}}/gi, String(qrisCode).padStart(3, '0'))
.replace(/{{rincian}}/gi, period)
.replace(/{{periode}}/gi, period)
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
@@ -57,7 +61,7 @@ function buildMessage(tplRaw, c, inv) {
.replace(/{{link_portal}}/gi, portalLink())
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
.replace(/{{qris_qr}}/gi, 'QRIS terlampir'));
.replace(/{{qris_qr}}/gi, qrisAttached ? 'QRIS terlampir' : ('Portal Pelanggan: ' + portalLink())));
}
function buildTargets(isolateDay) {
@@ -72,6 +76,7 @@ function buildTargets(isolateDay) {
try { invs = billingSvc.getUnpaidInvoicesByCustomerId(c.id) || []; } catch (e) {}
if (!invs.length) continue;
if (!c.phone || String(c.phone).replace(/\D/g, '').length < 9) continue;
// skema hangus: hanya tagihan terbaru yang valid; tagihan lama otomatis void saat tagihan baru terbit
targets.push({ c, inv: invs[invs.length - 1] });
}
return targets;
@@ -108,26 +113,22 @@ async function start(opts = {}) {
const waNotify = require('./waNotifyService');
let QRCode = null, ensureInvoiceQris = null;
if (isQris) { try { QRCode = require('/opt/billing-rtrw/node_modules/qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
if (isQris) { try { QRCode = require('qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
(async () => {
for (let i = 0; i < targets.length; i++) {
if (STATUS.stopped) break;
const { c, inv } = targets[i];
try {
const caption = buildMessage(tplRaw, c, inv);
if (isQris && QRCode && ensureInvoiceQris) {
const qr = ensureInvoiceQris(inv) || {};
if (qr.payload) {
const qr = (isQris && ensureInvoiceQris) ? (ensureInvoiceQris(inv) || {}) : null;
const caption = buildMessage(tplRaw, c, inv, qr);
if (isQris && QRCode && qr && qr.payload) {
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
const buf = Buffer.from(String(url).split(',')[1], 'base64');
await waNotify.sendImage(c.phone, buf, caption);
} else {
await waNotify.sendText(c.phone, caption);
}
} else {
await waNotify.sendText(c.phone, caption);
}
STATUS.sent++; STATUS.last = c.name;
} catch (e) { STATUS.failed++; STATUS.last = c.name + ' (gagal)'; }
if (i < targets.length - 1 && !STATUS.stopped) {

View File

@@ -11,6 +11,7 @@ const usageSvc = require('./usageService');
const { getSetting } = require('../config/settingsManager');
const db = require('../config/database');
const qrisUtil = require('../utils/qrisUtil');
const qrisUniq = require('./invoiceQrisService');
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
// Helper: Random delay generator untuk smart rate limiting
@@ -60,7 +61,7 @@ async function runAutoBillingReminder() {
const enabled = getSetting('whatsapp_auto_billing_enabled', false);
const waEnabled = getSetting('whatsapp_enabled', false);
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: 'disabled' };
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: (!waEnabled || !billingEnabled) ? 'wa_disabled' : 'disabled' };
const gatewayType = getSetting('wa_gateway_type', 'gowa');
const waSvc = require('./whatsappService');
@@ -70,13 +71,13 @@ async function runAutoBillingReminder() {
const token = getSetting('meta_access_token', '');
if (!phoneId || !token) {
logger.warn('[CRON] Kredensial Meta API belum diisi, pengingat tagihan otomatis dilewati.');
return;
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
}
} else if (gatewayType === 'fonnte') {
const token = getSetting('fonnte_token', '');
if (!token) {
logger.warn('[CRON] Token Fonnte belum diisi, pengingat tagihan otomatis dilewati.');
return;
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
}
} else {
// Gateway gowa/baileys/lainnya → cek koneksi gateway aktif (gateway-aware)
@@ -84,7 +85,7 @@ async function runAutoBillingReminder() {
const connected = await waNotify.isConnected().catch(() => false);
if (!connected) {
logger.warn('[CRON] WhatsApp gateway belum terhubung, pengingat tagihan otomatis dilewati.');
return;
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
}
}
@@ -213,14 +214,13 @@ async function runAutoBillingReminder() {
// Jika amt tidak ada atau amt tidak cocok dengan akumulasi total tagihan + kode, buat/perbarui nominal unik
if ((!code || !amt || (amt - code !== baseAmount)) && invId > 0 && baseAmount > 0) {
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
const custId = Number(c?.id || inv?.customer_id || 0);
let tryCode = 0;
let tryAmt = 0;
if (code > 0) {
const pAmt = baseAmount + code;
if (!exists.get('unpaid', pAmt, invId)) {
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
tryCode = code;
tryAmt = pAmt;
}
@@ -229,7 +229,7 @@ async function runAutoBillingReminder() {
if (!tryAmt && custId > 0) {
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
const pAmt = baseAmount + prefCode;
if (!exists.get('unpaid', pAmt, invId)) {
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
tryCode = prefCode;
tryAmt = pAmt;
}
@@ -238,7 +238,7 @@ async function runAutoBillingReminder() {
if (!tryAmt) {
for (let randCode = 1; randCode <= 499; randCode++) {
const pAmt = baseAmount + randCode;
if (!exists.get('unpaid', pAmt, invId)) {
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
tryCode = randCode;
tryAmt = pAmt;
break;

235
services/customQrService.js Normal file
View File

@@ -0,0 +1,235 @@
'use strict';
/**
* customQrService.js — QR nominal bebas (di luar billing) dengan kode unik 20 menit.
* Status: pending -> paid | expired -> warning | rejected
*/
const crypto = require('crypto');
const db = require('../config/database');
const { isQrisAmountTaken } = require('./invoiceQrisService');
const { logger } = require('../config/logger');
const { getSetting } = require('../config/settingsManager');
const DURATION_MINUTES = 20;
function isReady() {
try {
const enabled = getSetting('qris_static_enabled', true);
const off = enabled === false || enabled === 'false' || enabled === 0 || enabled === '0';
const payload = String(getSetting('qris_static_payload', '') || '').trim();
return !off && payload.length > 0;
} catch (e) { return false; }
}
function nowIso() { return new Date().toISOString(); }
function sweepExpired() {
try {
db.prepare("UPDATE custom_qr_payments SET status='expired' WHERE status='pending' AND expires_at IS NOT NULL AND expires_at < ?").run(nowIso());
} catch (e) {}
}
function isTaken(amount, excludeId) {
if (isQrisAmountTaken(amount, { excludeCustomQrId: excludeId || 0 })) return true;
try {
const row = db.prepare("SELECT id FROM custom_qr_payments WHERE status IN ('pending','expired') AND amount_unique=? AND id!=? LIMIT 1").get(amount, Number(excludeId) || 0);
return !!(row && row.id);
} catch (e) { return false; }
}
function create({ title, category, amount, note, createdBy }) {
if (!isReady()) throw new Error('QRIS Statis (Semi-Otomatis) belum aktif/berisi payload. Atur dulu di Pengaturan → QRIS.');
const base = Math.max(1, Math.floor(Number(amount) || 0));
if (!title || !String(title).trim()) throw new Error('Judul wajib diisi.');
if (!base) throw new Error('Nominal wajib diisi.');
let code = 0;
let amountUnique = 0;
for (let i = 0; i < 60; i++) {
const c = 1 + Math.floor(Math.random() * 999);
if (!isTaken(base + c)) { code = c; amountUnique = base + c; break; }
}
if (!amountUnique) {
for (let c = 1; c <= 999; c++) {
if (!isTaken(base + c)) { code = c; amountUnique = base + c; break; }
}
}
if (!amountUnique) throw new Error('Gagal membuat kode unik (slot 1-999 penuh).');
const token = crypto.randomBytes(12).toString('hex');
const expiresAt = new Date(Date.now() + DURATION_MINUTES * 60 * 1000).toISOString();
const r = db.prepare(`
INSERT INTO custom_qr_payments (title, category, amount, unique_code, amount_unique, status, share_token, note, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
`).run(String(title).trim(), String(category || '').trim(), base, code, amountUnique, token, String(note || '').trim(), String(createdBy || ''), expiresAt);
return getById(Number(r.lastInsertRowid));
}
function getById(id) {
sweepExpired();
return db.prepare('SELECT * FROM custom_qr_payments WHERE id=?').get(Number(id) || 0);
}
function getByToken(token) {
sweepExpired();
return db.prepare('SELECT * FROM custom_qr_payments WHERE share_token=?').get(String(token || ''));
}
function list(limit = 100, opts = {}) {
sweepExpired();
const where = [];
const params = [];
if (opts.from) { where.push('date(created_at) >= date(?)'); params.push(String(opts.from)); }
if (opts.to) { where.push('date(created_at) <= date(?)'); params.push(String(opts.to)); }
const sql = 'SELECT * FROM custom_qr_payments' +
(where.length ? (' WHERE ' + where.join(' AND ')) : '') +
' ORDER BY id DESC LIMIT ?';
params.push(Math.max(1, Math.min(500, Number(limit) || 100)));
return db.prepare(sql).all(...params);
}
function stats() {
sweepExpired();
const paid = db.prepare("SELECT COUNT(*) c, COALESCE(SUM(amount_unique),0) t FROM custom_qr_payments WHERE status='paid'").get();
const pending = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='pending'").get();
const warning = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='warning'").get();
const rejected = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='rejected'").get();
return {
paidCount: Number(paid.c || 0),
paidTotal: Number(paid.t || 0),
pendingCount: Number(pending.c || 0),
warningCount: Number(warning.c || 0),
rejectedCount: Number(rejected.c || 0)
};
}
function candidatesByAmount(amount) {
sweepExpired();
return db.prepare("SELECT * FROM custom_qr_payments WHERE amount_unique=? AND status IN ('pending','expired') ORDER BY id DESC LIMIT 2").all(Number(amount) || 0);
}
function markManualPaid(id, actor, note) {
const row = getById(id);
if (!row) throw new Error('Data tidak ditemukan.');
if (row.status === 'paid') return row;
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by=?, resolution_note=? WHERE id=?")
.run(String(actor || 'Admin'), String(note || 'Manual pay'), row.id);
return getById(id);
}
function acceptWarning(id, actor, note) {
const row = getById(id);
if (!row) throw new Error('Data tidak ditemukan.');
if (row.status !== 'warning') throw new Error('Hanya pembayaran berstatus warning yang bisa diselesaikan.');
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by=?, resolved_by=?, resolved_at=NOW_LOCAL(), resolution_note=? WHERE id=?")
.run(String(actor || 'Admin'), String(actor || 'Admin'), String(note || 'Diterima setelah 20 menit'), row.id);
return getById(id);
}
function remove(id) {
const r = db.prepare('DELETE FROM custom_qr_payments WHERE id=?').run(Number(id) || 0);
return r.changes || 0;
}
function removeMany(ids) {
const list = (Array.isArray(ids) ? ids : [ids])
.map(function (x) { return Number(x); })
.filter(function (n) { return Number.isFinite(n) && n > 0; });
if (!list.length) return 0;
const marks = list.map(function () { return '?'; }).join(',');
const r = db.prepare('DELETE FROM custom_qr_payments WHERE id IN (' + marks + ')').run(...list);
return r.changes || 0;
}
function clearAll() {
const r = db.prepare('DELETE FROM custom_qr_payments').run();
return r.changes || 0;
}
function regenerate(id, actor) {
const old = getById(id);
if (!old) throw new Error('Data tidak ditemukan.');
if (old.status === 'pending' && !isExpired(old)) throw new Error('QR masih aktif. Tunggu kedaluwarsa atau gunakan QR yang ada.');
const row = create({
title: old.title,
category: old.category,
amount: old.amount,
note: old.note,
createdBy: actor
});
return row;
}
function reject(id, actor, note) {
const row = getById(id);
if (!row) throw new Error('Data tidak ditemukan.');
if (row.status === 'rejected') return row;
db.prepare("UPDATE custom_qr_payments SET status='rejected', resolved_by=?, resolved_at=NOW_LOCAL(), resolution_note=? WHERE id=?")
.run(String(actor || 'Admin'), String(note || 'Refund / ditolak'), row.id);
return getById(id);
}
async function notifyAdminPayment(row, isWarning) {
try {
const waNotify = require('./waNotifyService');
const text = isWarning
? `⚠️ *PEMBAYARAN QR CUSTOM (LEWAT 20 MENIT)*\n\n` +
`Judul: ${row.title}\nKategori: ${row.category || '-'}\n` +
`Nominal: Rp ${Number(row.amount_unique || 0).toLocaleString('id-ID')}\n` +
`Dibuat: ${row.created_at}\n\n` +
`Nominal persis masuk setelah batas waktu. Buka menu QR-Custom Payment untuk menyelesaikan atau menolak (refund).`
: `✅ *PEMBAYARAN QR CUSTOM DITERIMA*\n\n` +
`Judul: ${row.title}\nKategori: ${row.category || '-'}\n` +
`Nominal: Rp ${Number(row.amount_unique || 0).toLocaleString('id-ID')}\n\n` +
`Pembayaran cocok dalam 20 menit.`;
await waNotify.notifyAdmin(text);
} catch (e) { logger.warn('[CustomQR] notifyAdmin gagal: ' + e.message); }
}
function markPaidFromWebhook(id, notifId) {
const row = getById(id);
if (!row) return null;
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by='Webhook', notif_id=? WHERE id=?")
.run(notifId || null, row.id);
const fresh = getById(id);
notifyAdminPayment(fresh, false).catch(() => {});
return fresh;
}
function markWarningFromWebhook(id, notifId) {
const row = getById(id);
if (!row) return null;
db.prepare("UPDATE custom_qr_payments SET status='warning', warning_at=NOW_LOCAL(), notif_id=? WHERE id=?")
.run(notifId || null, row.id);
const fresh = getById(id);
notifyAdminPayment(fresh, true).catch(() => {});
return fresh;
}
function isExpired(row) {
if (!row || !row.expires_at) return false;
const t = new Date(row.expires_at).getTime();
return Number.isFinite(t) && t < Date.now();
}
module.exports = {
DURATION_MINUTES,
isReady,
create,
list,
stats,
getById,
getByToken,
candidatesByAmount,
markManualPaid,
acceptWarning,
regenerate,
remove,
removeMany,
clearAll,
reject,
markPaidFromWebhook,
markWarningFromWebhook,
isExpired,
sweepExpired
};

View File

@@ -112,6 +112,34 @@ async function resolveDeviceToken(input) {
const found = await findDeviceWithTagVariants(token);
if (found && found.device && found.device._id) return found.device;
// Fallback: cari lewat data pelanggan (ID, HP, PPPoE, tag ACS, atau MAC).
try {
const db = require('../config/database');
const cust = db.prepare(`
SELECT pppoe_username, genieacs_tag, mac_address, phone
FROM customers
WHERE phone = ? OR id = ? OR pppoe_username = ? OR genieacs_tag = ? OR mac_address = ?
LIMIT 1
`).get(token, token, token, token, token);
if (cust) {
const candidates = Array.from(new Set([
cust.pppoe_username, cust.genieacs_tag, cust.mac_address, cust.phone
].map(v => String(v || '').trim()).filter(v => v && v !== token)));
for (const cand of candidates) {
const byPppoe = await findDeviceByPppoe(cand);
if (byPppoe && byPppoe._id) return byPppoe;
const byTag = await findDeviceByTag(cand);
if (byTag && byTag._id) return byTag;
const variant = await findDeviceWithTagVariants(cand);
if (variant && variant.device && variant.device._id) return variant.device;
}
}
} catch (e) {
logger.debug(`[resolveDeviceToken] Customer DB fallback error: ${e.message}`);
}
return null;
}
@@ -132,6 +160,7 @@ const parameterPaths = {
'Device.DeviceInfo.SoftwareVersion'
],
ssid: [
'VirtualParameters.WlanSSID',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
'Device.WiFi.SSID.1.SSID',
'Device.WiFi.SSID.2.SSID'
@@ -166,6 +195,8 @@ const parameterPaths = {
pppoeIP: [
'VirtualParameters.pppoeIP',
'VirtualParameters.pppIP',
'VirtualParameters.WanIP',
'VirtualParameters.IPTR069',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.1.ExternalIPAddress',
@@ -176,8 +207,10 @@ const parameterPaths = {
'Device.IP.Interface.1.IPv4Address.1.IPAddress'
],
pppUsername: [
'VirtualParameters.pppoeUsername2',
'VirtualParameters.pppoeUsername',
'VirtualParameters.pppUsername',
'VirtualParameters.PPPoEUser',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.2.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.3.Username',
@@ -696,11 +729,19 @@ function mapDeviceData(device, tag, isPppoeActive = false) {
} catch {}
let rxPower = getParameterWithPaths(device, parameterPaths.rxPower);
if (rxPower !== 'N/A' && rxPower !== '-' && rxPower !== '') {
const num = parseFloat(rxPower);
if (!isNaN(num) && num > 0) {
const dbVal = 30 + (Math.log10(num * Math.pow(10, -7)) * 10);
rxPower = (Math.ceil(dbVal * 100) / 100).toFixed(2);
if (rxPower !== 'N/A' && rxPower !== '-' && rxPower !== '' && rxPower !== null && rxPower !== undefined) {
const raw = parseFloat(rxPower);
if (!isNaN(raw)) {
if (raw === 0 || raw >= 65535) {
rxPower = 'LOS';
} else if (raw < 0 && raw > -50) {
rxPower = raw.toFixed(2);
} else if (raw <= -50) {
rxPower = (raw / 100).toFixed(2);
} else if (raw > 0) {
const dbm = 30 + (Math.log10(raw * 1e-7) * 10);
rxPower = (Math.round(dbm * 100) / 100).toFixed(2);
}
}
}
const pppoeIP = extractPppoeIp(device);
@@ -851,6 +892,7 @@ async function updateSSID(tag, newSSID, actor = null) {
try {
const ssidClean = String(newSSID ?? '').replace(/[\r\n\t]+/g, '').trim();
if (!ssidClean) { logger.warn('[updateSSID] SSID kosong, dibatalkan.'); return false; }
newSSID = ssidClean;
const device = await resolveDeviceToken(tag);
if (!device) return false;
const deviceId = encodeURIComponent(device._id);
@@ -863,62 +905,96 @@ async function updateSSID(tag, newSSID, actor = null) {
const tasksUrl = `/devices/${deviceId}/tasks`;
const parameterValues = [];
const igd = device.InternetGatewayDevice;
const dev181 = device.Device;
// Check supported paths in DB
// Check supported paths directly from loaded device object
if (igd && igd.LANDevice && igd.LANDevice['1'] && igd.LANDevice['1'].WLANConfiguration) {
const wlan = igd.LANDevice['1'].WLANConfiguration;
// Selalu ubah SSID 1 (2.4GHz Utama)
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
// HANYA jika parameter SSID 5G (WLANConfiguration.5) benar-benar ada di perangkat.
// JANGAN PERNAH mengubah WLANConfiguration.2 (SSID 2) karena itu adalah SSID2/Guest pada perangkat single-band!
const hasFiveG = Boolean(
wlan['5'] && (
wlan['5'].SSID !== undefined ||
wlan['5']._object === true ||
wlan['5'].Enable !== undefined ||
wlan['5'].KeyPassphrase !== undefined
)
);
if (hasFiveG) {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID', `${newSSID}-5G`, 'xsd:string']);
}
} else if (dev181 && dev181.WiFi) {
parameterValues.push(['Device.WiFi.SSID.1.SSID', newSSID, 'xsd:string']);
} else {
// Fallback check in local DB
const db = require('../config/database');
const row = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(device._id);
const flatParams = row && row.params ? JSON.parse(row.params) : null;
if (flatParams) {
// SSID 2.4G paths
const paths24G = [
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
'Device.WiFi.SSID.1.SSID'
];
paths24G.forEach(p => {
if (flatParams[p] !== undefined) {
parameterValues.push([p, ssidClean, 'xsd:string']);
if (flatParams['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID'] !== undefined) {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
} else if (flatParams['Device.WiFi.SSID.1.SSID'] !== undefined) {
parameterValues.push(['Device.WiFi.SSID.1.SSID', newSSID, 'xsd:string']);
}
});
// SSID 5G paths
const paths5G = [
'Device.WiFi.SSID.2.SSID'
];
for (const idx of [5, 6, 7, 8]) {
paths5G.push(`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.SSID`);
if (flatParams['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID'] !== undefined) {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID', `${newSSID}-5G`, 'xsd:string']);
}
paths5G.forEach(p => {
if (flatParams[p] !== undefined) {
parameterValues.push([p, `${ssidClean}-5G`, 'xsd:string']);
}
});
}
// Fallback if no parameters match or device not bootstrapped yet
if (parameterValues.length === 0) {
parameterValues.push(
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', ssidClean, 'xsd:string'],
['Device.WiFi.SSID.1.SSID', ssidClean, 'xsd:string']
);
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
}
}
let ok = false;
for (const pv of parameterValues) {
try {
await instance.post(tasksUrl, {
name: 'setParameterValues',
parameterValues: parameterValues
}, { timeout: 20000 });
parameterValues: [pv]
}, { timeout: 15000 });
ok = true;
} catch (e) {
logger.error(`[updateSSID] Failed to set SSID: ${e.message}`);
logger.error(`[updateSSID] Failed to set parameter ${pv[0]}: ${e.message}`);
}
}
// Refresh + inform dijalankan di latar belakang agar respons cepat (tidak menahan UI)
// Refresh objects untuk trigger update parameter dari ONU
try {
await instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 15000 });
} catch (e) {}
// Tunggu sesaat agar task dieksekusi oleh ONU
if (ok) {
instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 12000 }).catch(() => {});
instance.post(tasksUrl, { name: 'inform' }, { timeout: 12000 }).catch(() => {});
logger.info(`[updateSSID] Set SSID dikirim untuk ${tag}; refresh & inform berjalan di latar belakang`);
await new Promise(resolve => setTimeout(resolve, 3000));
// Sinkronkan nama WiFi (SSID) baru ke database pelanggan
try {
const db = require('../config/database');
const pppoeUser = extractPppoeUser(device);
const tagCandidate = (device._tags && device._tags[0]) || null;
const snCandidate = device._deviceId?._SerialNumber || null;
const idCandidate = device._id || null;
const cust = db.prepare(`
SELECT id FROM customers
WHERE id = ?
OR (pppoe_username IS NOT NULL AND pppoe_username != '' AND (pppoe_username = ? OR pppoe_username = ?))
OR (genieacs_tag IS NOT NULL AND genieacs_tag != '' AND (genieacs_tag = ? OR genieacs_tag = ?))
OR (mac_address IS NOT NULL AND mac_address != '' AND (mac_address = ? OR mac_address = ? OR mac_address = ?))
OR phone = ?
LIMIT 1
`).get(tag, tag, pppoeUser, tag, tagCandidate, tag, snCandidate, idCandidate, tag);
if (cust && cust.id) {
db.prepare('UPDATE customers SET wifi_ssid = ? WHERE id = ?').run(newSSID, cust.id);
}
} catch (err) {
logger.debug(`[updateSSID] Failed to sync customer wifi_ssid: ${err.message}`);
}
}
// Catat audit trail jika berhasil
@@ -970,71 +1046,78 @@ async function updatePassword(tag, newPassword, actor = null) {
logger.info(`[updatePassword] Setting password for device ${deviceId}, tag ${tag}`);
const parameterValues = [];
const igd = device.InternetGatewayDevice;
const dev181 = device.Device;
// Check supported paths in DB
const db = require('../config/database');
const row = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(device._id);
const flatParams = row && row.params ? JSON.parse(row.params) : null;
if (igd && igd.LANDevice && igd.LANDevice['1'] && igd.LANDevice['1'].WLANConfiguration) {
const wlan = igd.LANDevice['1'].WLANConfiguration;
const wlan1 = wlan['1'] || {};
if (flatParams) {
// 2.4G password paths
const paths24G = [
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey',
'Device.WiFi.AccessPoint.1.Security.KeyPassphrase',
'Device.WiFi.AccessPoint.1.Security.PreSharedKey'
];
paths24G.forEach(p => {
if (flatParams[p] !== undefined) {
parameterValues.push([p, pw, 'xsd:string']);
if (wlan1.KeyPassphrase !== undefined) {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
}
});
// 5G password paths
const paths5G = [
'Device.WiFi.AccessPoint.2.Security.KeyPassphrase',
'Device.WiFi.AccessPoint.2.Security.PreSharedKey'
];
for (const idx of [5, 6, 7, 8]) {
paths5G.push(
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.KeyPassphrase`,
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.PreSharedKey.1.KeyPassphrase`,
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.PreSharedKey.1.PreSharedKey`
);
if (wlan1.PreSharedKey && typeof wlan1.PreSharedKey === 'object') {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
}
paths5G.forEach(p => {
if (flatParams[p] !== undefined) {
parameterValues.push([p, pw, 'xsd:string']);
}
});
}
// Fallback if no parameters match or device not bootstrapped yet
if (parameterValues.length === 0) {
parameterValues.push(
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string'],
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase', pw, 'xsd:string'],
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string'],
['Device.WiFi.AccessPoint.1.Security.KeyPassphrase', pw, 'xsd:string']
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
}
// 5G if available - HANYA jika WLANConfiguration.5 ada di perangkat. JANGAN PERNAH mengubah WLANConfiguration.2!
const hasFiveG = Boolean(
wlan['5'] && (
wlan['5'].SSID !== undefined ||
wlan['5'].KeyPassphrase !== undefined ||
wlan['5'].PreSharedKey !== undefined ||
wlan['5']._object === true ||
wlan['5'].Enable !== undefined
)
);
if (hasFiveG) {
const wlan5 = wlan['5'] || {};
if (wlan5.KeyPassphrase !== undefined) {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.KeyPassphrase', pw, 'xsd:string']);
}
if (wlan5.PreSharedKey && typeof wlan5.PreSharedKey === 'object') {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
}
if (!wlan5.KeyPassphrase && (!wlan5.PreSharedKey || typeof wlan5.PreSharedKey !== 'object')) {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.KeyPassphrase', pw, 'xsd:string']);
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
}
}
} else if (dev181 && dev181.WiFi) {
parameterValues.push(['Device.WiFi.AccessPoint.1.Security.KeyPassphrase', pw, 'xsd:string']);
} else {
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
}
let ok = false;
for (const pv of parameterValues) {
try {
await instance.post(tasksUrl, {
name: 'setParameterValues',
parameterValues: parameterValues
}, { timeout: 20000 });
parameterValues: [pv]
}, { timeout: 15000 });
ok = true;
} catch (e) {
logger.error(`[updatePassword] Failed to set password: ${e.message}`);
logger.debug(`[updatePassword] Param ${pv[0]} failed: ${e.message}`);
}
}
// Refresh di latar belakang agar respons cepat
instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 12000 }).catch(() => {});
// Refresh object - only refresh InternetGatewayDevice path yang lebih universal
try {
await instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 15000 });
} catch (e) {}
// Skip Device.WiFi.AccessPoint refresh karena tidak semua ONU support (CIOT tidak support)
// Tunggu sesaat agar task dieksekusi oleh ONU
if (ok) {
await new Promise(resolve => setTimeout(resolve, 3000));
}
// Catat audit trail jika berhasil
if (ok && actor) {
auditTrail.logAuditTrail({

View File

@@ -44,7 +44,11 @@ async function resolveDeviceId(force) {
try { list = await listDevices(); } catch (e) { list = []; }
const has = function (id) { return list.some(function (d) { return String(d.id) === id; }); };
let chosen;
const connected = list.find(function (d) {
return d && (d.state === 'connected' || d.is_connected || d.is_logged_in || d.connected);
});
if (configured && has(configured)) chosen = configured; // device id diset & ada -> pakai itu
else if (connected) chosen = String(connected.id); // utamakan device yang benar-benar terhubung
else if (list.length === 1) chosen = String(list[0].id); // cuma 1 device -> otomatis
else if (list.length > 1) chosen = configured || String(list[0].id); // >1 device -> pakai yg diset / pertama
else chosen = configured; // GOWA tak beri list -> serahkan ke GOWA
@@ -131,6 +135,48 @@ async function sendGowaDocument(toPhone, documentBuffer, filename = 'file.pdf',
});
return { success: true, gateway: 'gowa', raw: json };
}
async function testReachability(opts = {}) {
// Nilai form boleh dipakai tanpa menyimpan pengaturan lebih dulu.
const base = String(opts.baseUrl || baseUrl()).trim().replace(/\/+$/, '') || baseUrl();
const headers = {};
if (opts.username && opts.password) {
headers.Authorization = 'Basic ' + Buffer.from(opts.username + ':' + opts.password).toString('base64');
} else {
Object.assign(headers, commonHeaders());
}
const out = { baseUrl: base, reachable: false, healthStatus: 0, authOk: false, devices: 0, message: '' };
try {
const res = await fetch(base + '/health');
out.healthStatus = res.status;
out.reachable = res.ok;
} catch (e) {
out.message = 'Tidak terjangkau: ' + e.message;
return out;
}
if (!out.reachable) {
out.message = 'GOWA menjawab, tetapi /health mengembalikan HTTP ' + out.healthStatus;
return out;
}
try {
const res = await fetch(base + '/devices', { headers: headers });
out.authStatus = res.status;
const json = await res.json().catch(function () { return null; });
const list = (json && Array.isArray(json.results)) ? json.results : [];
if (res.ok) {
out.authOk = true;
out.devices = list.length;
out.message = 'GOWA terjangkau' + (list.length ? ' — ' + list.length + ' device terdaftar' : ' — belum ada device, scan QR untuk menautkan');
} else if (res.status === 401) {
out.message = 'GOWA terjangkau, tetapi username/password Basic Auth salah atau kosong';
} else {
out.message = 'GOWA terjangkau, tetapi /devices membalas HTTP ' + res.status;
}
} catch (e) {
out.message = 'GOWA terjangkau, tetapi gagal cek kredensial: ' + e.message;
}
return out;
}
async function testGowaConnection(phone, message) {
return await sendGowaMessage(phone, message || 'Tes koneksi GOWA berhasil ✅');
}
@@ -168,21 +214,61 @@ async function ensureDevice() {
async function getLoginQr(deviceId) {
const dev = deviceId || deviceHeader();
const url = baseUrl() + '/app/login' + (dev ? ('?device_id=' + encodeURIComponent(dev)) : '');
try {
const res = await fetch(url, { headers: commonHeaders() });
const json = await res.json().catch(function () { return null; });
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch (e) {}
if (!res.ok) {
const detail = String(
(json && (json.message || json.error || json.code)) || text || ''
).replace(/\s+/g, ' ').trim().slice(0, 160);
return {
ok: false,
status: res.status,
code: (json && json.code) || null,
message: detail || ('HTTP ' + res.status)
};
}
const r = (json && json.results) || {};
const link = r.qr_link || r.image_path || null;
if (link) _lastQr = { link: link, at: Date.now() };
return { ok: !!link, qr_link: link, duration: r.qr_duration || null, device_id: r.device_id || dev || null, code: (json && json.code) || null };
} catch (e) {
return { ok: false, status: 0, code: null, message: e.message };
}
async function getLastQrImage() {
if (!_lastQr.link) return null;
}
// GOWA bisa mengembalikan qr_link absolut ke host internal (127.0.0.1 / nama
// container). Ambil path-nya saja lalu tempel ke base URL yang dikonfigurasi,
// supaya gambar tetap bisa diambil saat GOWA diakses lewat domain/reverse proxy.
function resolveQrLink(link) {
const l = String(link || '').trim();
if (!l) return '';
if (/^data:/i.test(l)) return l;
try {
const res = await fetch(_lastQr.link, { headers: commonHeaders() });
if (!res.ok) return null;
const buf = Buffer.from(await res.arrayBuffer());
return { buffer: buf, contentType: res.headers.get('content-type') || 'image/png' };
} catch (e) { return null; }
const u = new URL(l, baseUrl() + '/');
const base = new URL(baseUrl());
return base.origin + u.pathname + u.search;
} catch (e) {
return baseUrl() + (l.charAt(0) === '/' ? l : '/' + l);
}
}
module.exports = { sendGowaMessage, sendGowaImage, sendGowaDocument, testGowaConnection, gowaStatus, listDevices, ensureDevice, getLoginQr, getLastQrImage, normalizePhoneJid, baseUrl, resolveDeviceId, invalidateDeviceCache };
async function getLastQrImage() {
if (!_lastQr.link) return null;
const url = resolveQrLink(_lastQr.link);
try {
const res = await fetch(url, { headers: commonHeaders() });
if (!res.ok) {
logger.warn(`[GOWA] Gagal ambil gambar QR: HTTP ${res.status} (${url})`);
return null;
}
const buf = Buffer.from(await res.arrayBuffer());
return { buffer: buf, contentType: res.headers.get('content-type') || 'image/png' };
} catch (e) {
logger.warn(`[GOWA] Gagal ambil gambar QR: ${e.message} (${url})`);
return null;
}
}
module.exports = { sendGowaMessage, sendGowaImage, sendGowaDocument, testGowaConnection, testReachability, gowaStatus, listDevices, ensureDevice, getLoginQr, getLastQrImage, normalizePhoneJid, baseUrl, resolveDeviceId, invalidateDeviceCache };

View File

@@ -7,6 +7,32 @@ const db = require('../config/database');
const qrisUtil = require('../utils/qrisUtil');
const { getSettings } = require('../config/settingsManager');
/**
* Cek apakah nominal unik sudah dipakai order lain yang masih menunggu bayar.
* WAJIB mencakup invoice, voucher, dan donasi: webhook mencocokkan ketiganya
* sekaligus, jadi nominal yang dipakai dua order berbeda akan dianggap ambigu
* dan pembayarannya tidak diproses.
*/
function isQrisAmountTaken(amount, opts = {}) {
const amt = Number(amount || 0);
if (!Number.isFinite(amt) || amt <= 0) return false;
const exInv = Number(opts.excludeInvoiceId || 0);
const exVoucher = Number(opts.excludeVoucherOrderId || 0);
const exDonation = Number(opts.excludeDonationOrderId || 0);
const exCustomQr = Number(opts.excludeCustomQrId || 0);
try {
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, exInv);
if (inv && inv.id) return true;
const voc = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, exVoucher);
if (voc && voc.id) return true;
const don = db.prepare('SELECT id FROM public_donation_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, exDonation);
if (don && don.id) return true;
const cq = db.prepare("SELECT id FROM custom_qr_payments WHERE status IN ('pending','expired') AND amount_unique=? AND id!=? LIMIT 1").get(amt, exCustomQr);
if (cq && cq.id) return true;
} catch (e) { /* tabel opsional belum ada */ }
return false;
}
function ensureInvoiceQris(inv) {
const settings = getSettings() || {};
const invId = Number((inv && inv.id) || 0);
@@ -17,11 +43,10 @@ function ensureInvoiceQris(inv) {
let amountUnique = Number(inv.qris_amount_unique || 0);
if (!(amountUnique > 0 && code > 0 && (amountUnique - code) === base)) {
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
let chosen = 0, chosenAmt = 0;
for (let c = 1; c <= 999; c++) {
const a = base + c;
if (!exists.get('unpaid', a, invId)) { chosen = c; chosenAmt = a; break; }
if (!isQrisAmountTaken(a, { excludeInvoiceId: invId })) { chosen = c; chosenAmt = a; break; }
}
if (chosen) {
code = chosen;
@@ -43,4 +68,4 @@ function ensureInvoiceQris(inv) {
return { uniqueCode: code, amount: finalAmount, payload };
}
module.exports = { ensureInvoiceQris };
module.exports = { ensureInvoiceQris, isQrisAmountTaken };

View File

@@ -1632,6 +1632,112 @@ async function generateIsolirPortalScript() {
};
}
/**
* Script isolir sederhana untuk billing yang berjalan di CLOUD.
* Asumsi: redirector billing (port isolir_redirect_port) terbuka dari publik.
*/
async function generateIsolirCloudScript(opts = {}) {
const settings = getSettingsWithCache() || {};
const rawHost = String(opts.host || settings.public_base_url || settings.app_url || '').trim();
if (!rawHost) throw new Error('Isi domain/IP publik billing terlebih dahulu.');
let url = null;
try {
url = new URL(/^https?:\/\//i.test(rawHost) ? rawHost : ('https://' + rawHost));
} catch (e) {
throw new Error('Domain/IP billing tidak valid.');
}
const hostname = url.hostname;
const baseUrl = url.origin;
let billingIps = [];
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
billingIps = [hostname];
} else {
try {
const list = await dns.promises.resolve4(hostname);
billingIps = (list || []).filter(Boolean);
} catch (e) {}
if (!billingIps.length) {
try { billingIps = [await resolveIpv4(hostname)]; } catch (e) { billingIps = []; }
}
}
if (!billingIps.length) throw new Error('IP publik billing tidak bisa di-resolve. Masukkan IP origin langsung.');
const billingIp = billingIps[0];
const cfRanges = ['104.16.', '104.17.', '104.18.', '104.19.', '104.20.', '104.21.', '104.22.', '104.23.', '104.24.', '104.25.', '104.26.', '104.27.', '172.64.', '172.65.', '172.66.', '172.67.', '162.158.', '173.245.', '103.21.', '103.22.', '103.31.', '141.101.', '108.162.', '190.93.', '188.114.', '197.234.', '198.41.', '131.0.72.'];
const isCdn = billingIps.some(ip => cfRanges.some(p => ip.startsWith(p)));
const redirectPort = Math.max(1, Math.min(65535, parseInt(opts.port, 10) || parseInt(settings.isolir_redirect_port, 10) || 8099));
const profile = String(opts.profile || 'isolir').trim() || 'isolir';
const isolirUrl = String(settings.isolir_portal_url || (baseUrl + '/isolated')).trim();
const onUp = `/ip firewall address-list remove [find list=LIST_ISOLIR address=$\\"remote-address\\"]; /ip firewall address-list add list=LIST_ISOLIR address=$\\"remote-address\\" comment=$user timeout=23h`;
const onDown = `/ip firewall address-list remove [find list=LIST_ISOLIR address=$\\"remote-address\\"]`;
const lines = [
'# ============================================================',
'# ISOLIR PAGE — BILLING CLOUD (script sederhana)',
'# Tempel di Terminal RouterOS. Ubah variabel di bawah bila perlu.',
'# ============================================================',
...(isCdn ? [
'# PERINGATAN: ' + hostname + ' memakai Cloudflare/CDN.',
'# Port ' + redirectPort + ' biasanya TIDAK ikut diproxy Cloudflare.',
'# Untuk NAT redirector, gunakan IP ORIGIN (DNS-only) atau pastikan',
'# port ' + redirectPort + ' terbuka langsung di server origin.',
'# ============================================================',
''
] : []),
':local billingIp "' + billingIp + '"',
':local isolirPort ' + redirectPort,
':local isolirProfile "' + profile + '"',
'',
'# --- Bersihkan rule lama buatan script ini ---',
'/ip firewall nat remove [find comment~"ISOLIR_CLOUD_"]',
'/ip firewall filter remove [find comment~"ISOLIR_CLOUD_"]',
'/ip firewall address-list remove [find list=BILLING comment="BILLING_ISOLIR"]',
'',
'# --- Izinkan billing (semua IP hasil resolve) ---',
...billingIps.map(function (ip) { return '/ip firewall address-list add list=BILLING address=' + ip + ' comment="BILLING_ISOLIR"'; }),
'',
'# --- Hook profil PPPoE isolir ---',
'/ppp profile set [find name=$isolirProfile] on-up="' + onUp + '" on-down="' + onDown + '"',
'',
'# --- DNS isolir diarahkan ke router ---',
'/ip firewall nat add chain=dstnat protocol=udp dst-port=53 src-address-list=LIST_ISOLIR action=redirect to-ports=53 comment="ISOLIR_CLOUD_DNS_UDP"',
'/ip firewall nat add chain=dstnat protocol=tcp dst-port=53 src-address-list=LIST_ISOLIR action=redirect to-ports=53 comment="ISOLIR_CLOUD_DNS_TCP"',
'',
'# --- HTTP pelanggan isolir -> redirector cloud ---',
'/ip firewall nat add chain=dstnat protocol=tcp dst-port=80 src-address-list=LIST_ISOLIR dst-address-list=!BILLING action=dst-nat to-addresses=$billingIp to-ports=$isolirPort comment="ISOLIR_CLOUD_HTTP"',
'',
'# --- Filter ---',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=udp dst-port=53 action=accept comment="ISOLIR_CLOUD_DNS"',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=53 action=accept comment="ISOLIR_CLOUD_DNS_TCP"',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR dst-address-list=BILLING action=accept comment="ISOLIR_CLOUD_ALLOW_BILLING"',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=443 action=reject comment="ISOLIR_CLOUD_REJECT_HTTPS"',
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR action=drop comment="ISOLIR_CLOUD_DROP"',
'',
'# ============================================================',
'# CATATAN BILLING CLOUD',
'# 1. Redirector billing harus bisa diakses: http://' + billingIp + ':' + redirectPort,
'# (buka port ' + redirectPort + ' di firewall/security group cloud).',
'# 2. Di billing set ISOLIR_REDIRECT_URL=' + isolirUrl,
'# atau setting isolir_portal_url.',
'# 3. Halaman isolir: ' + isolirUrl,
'# 4. Jika pakai Cloudflare/CDN, pastikan IP edge yang dipakai ada di',
'# address-list BILLING, atau gunakan IP origin langsung.',
'# ============================================================',
];
return {
script: lines.join('\n'),
billingHost: hostname,
billingIp,
billingIps,
isCdn,
redirectPort,
profile,
isolirUrl,
warning: isCdn ? ('Domain memakai Cloudflare/CDN. NAT redirector ke ' + billingIp + ':' + redirectPort + ' biasanya gagal — gunakan IP origin (DNS-only) atau buka port redirector di origin.') : ''
};
}
const ISOLIR_ADDR_LIST = 'LIST_ISOLIR';
/** Nama profil PPPoE isolir yang dipakai pelanggan di router ini (distinct dari DB). */
@@ -2220,6 +2326,7 @@ module.exports = {
setupIsolirFirewall,
ensurePppProfileIsolirAddressListHook,
generateIsolirPortalScript,
generateIsolirCloudScript,
manageStaticIp,
removeStaticIp
};

View File

@@ -0,0 +1,136 @@
'use strict';
/**
* serviceNoticeNotifyService.js
* Notifikasi WhatsApp ke pelanggan untuk:
* - tiket gangguan yang baru dibuat
* - pengumuman gangguan/perbaikan dari halaman Status Layanan
* - pemberitahuan layanan pulih (mode kembali NORMAL)
*
* Pengiriman broadcast berjalan di latar belakang dengan jeda acak
* mengikuti setting whatsapp_broadcast_delay (anti-banned).
*/
const customerSvc = require('./customerService');
const waNotify = require('./waNotifyService');
const { getSetting } = require('../config/settingsManager');
const { logger } = require('../config/logger');
let _running = false;
function digits(p) {
return String(p || '').replace(/\D/g, '');
}
function companyName() {
return getSetting('company_header', 'OPEN-ISP') || 'OPEN-ISP';
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
function delayMs() {
const sec = Number(getSetting('whatsapp_broadcast_delay', 5)) || 5;
return Math.max(2000, sec * 1000) + Math.floor(Math.random() * 1500);
}
function ticketMessage(customer, ticketId, subject) {
return `✅ *Laporan Diterima*\n\n` +
`Halo *${customer.name || 'Pelanggan'}*, laporan gangguan Anda sudah kami terima.\n\n` +
`🎫 *No. Tiket:* #TCK-${ticketId}\n` +
`📝 *Subjek:* ${subject}\n\n` +
`Tim teknisi akan segera menindaklanjuti. Kami akan mengabari lagi setelah laporan selesai.\n\n` +
`_${companyName()}_`;
}
function noticeMessage(notice, customer) {
let text = `⚠️ *${notice.label || 'Informasi Layanan'}*\n\n` +
`Halo *${customer.name || 'Pelanggan'}*,\n${notice.text || ''}\n`;
if (notice.startText) text += `\n🕒 *Mulai:* ${notice.startText}`;
if (notice.estimateText) text += `\n⏳ *Estimasi Selesai:* ${notice.estimateText}`;
text += `\n\nMohon maaf atas ketidaknyamanannya.\n\n_${companyName()}_`;
return text;
}
function recoveryMessage(customer) {
return `✅ *Layanan Kembali Normal*\n\n` +
`Halo *${customer.name || 'Pelanggan'}*, gangguan/perbaikan layanan sudah selesai dan internet Anda dapat digunakan kembali seperti biasa.\n\n` +
`Terima kasih atas kesabaran Anda.\n\n_${companyName()}_`;
}
async function notifyTicketCreated(customer, ticketId, subject) {
try {
if (!getSetting('whatsapp_enabled', false)) return false;
const phone = digits(customer && customer.phone);
if (phone.length < 8) return false;
return await waNotify.sendText(phone, ticketMessage(customer, ticketId, subject || 'Gangguan Layanan'));
} catch (e) {
logger.error(`[NoticeWA] notifyTicketCreated: ${e.message}`);
return false;
}
}
function selectTargets({ target, area, routerId }) {
let list = [];
try {
list = customerSvc.getAllCustomers(
'',
target === 'router' && routerId ? Number(routerId) : null,
'active',
target === 'area' ? String(area || '') : '',
{}
) || [];
} catch (e) {
logger.warn(`[NoticeWA] gagal memilih target: ${e.message}`);
return [];
}
const seen = new Set();
const out = [];
for (const c of list) {
const phone = digits(c.phone);
if (phone.length >= 8 && !seen.has(phone)) {
seen.add(phone);
out.push({ ...c, phone });
}
}
return out;
}
/**
* Kirim pengumuman ke pelanggan aktif (latar belakang).
* @returns {{ok:boolean,total?:number,reason?:string}}
*/
async function broadcastNotice({ mode, target, area, routerId, notice }) {
if (_running) return { ok: false, reason: 'busy' };
if (!getSetting('whatsapp_enabled', false)) return { ok: false, reason: 'wa_disabled' };
const recipients = selectTargets({ target: target || 'all', area, routerId });
if (!recipients.length) return { ok: false, reason: 'empty' };
const isRecovery = mode === 'NORMAL';
_running = true;
logger.info(`[NoticeWA] mulai mode=${mode} target=${target || 'all'} total=${recipients.length}`);
(async () => {
let sent = 0;
let failed = 0;
for (const c of recipients) {
await sleep(delayMs());
try {
const ok = await waNotify.sendText(c.phone, isRecovery ? recoveryMessage(c) : noticeMessage(notice, c));
if (ok) sent++; else failed++;
} catch (e) {
failed++;
}
}
_running = false;
logger.info(`[NoticeWA] selesai mode=${mode} terkirim=${sent} gagal=${failed}`);
})();
return { ok: true, total: recipients.length };
}
function isRunning() {
return _running;
}
module.exports = { notifyTicketCreated, broadcastNotice, isRunning };

View File

@@ -3,7 +3,7 @@ const { getSetting, getSettings, saveSettings } = require('../config/settingsMan
const { getAppSetting, saveAppSetting } = require('../config/database');
const FEATURE_PASSWORD_HASH = 'af03458614be223016c5bc1c0788eaefc6a6bfd0c11cdcd4bc520f49d0cb474a'; // sha256 kode aktivasi — plaintext hanya di settings.json (activation_code), tidak di repo
const FEATURE_CONTACT_PHONE = '081947215703';
const FEATURE_CONTACT_PHONE = '';
function getFeaturePasswordHash() {
return getSetting('feature_password_hash', FEATURE_PASSWORD_HASH);
@@ -32,6 +32,7 @@ const MENU_DEFINITIONS = [
{ key: 'map', section: 'main', href: '/admin/map', icon: 'bi bi-map', labelKey: 'admin.nav.network_map', labelDefault: 'Peta Jaringan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 4, activePages: ['map'] },
{ key: 'whatsapp', section: 'main', href: '/admin/whatsapp', icon: 'bi bi-whatsapp', labelKey: 'admin.nav.whatsapp', labelDefault: 'WhatsApp', roles: ['admin', 'cashier'], activePages: ['whatsapp','broadcast','whatsapp_live_chat','whatsapp_templates'] },
{ key: 'automation', section: 'main', href: '/admin/automation', icon: 'bi bi-robot', labelKey: 'admin.nav.automation', labelDefault: 'Automation', roles: ['admin'], activePages: ['automation'] },
{ key: 'custom_qr', section: 'main', href: '/admin/custom-qr', icon: 'bi bi-qr-code', labelKey: 'admin.nav.custom_qr', labelDefault: 'QR-Custom Payment', roles: ['admin'], activePages: ['custom_qr'] },
{ key: 'grp_customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers', labelDefault: 'Pelanggan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 2, activePages: ['customers', 'onu_stickers'], children: [
{ key: 'customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers_data', labelDefault: 'Data Pelanggan', roles: ['admin', 'cashier'], activePages: ['customers'] },
@@ -103,6 +104,7 @@ const DEFAULT_MENU_STATES = {
olts: STATE_HIDDEN,
whatsapp: STATE_VISIBLE,
automation: STATE_VISIBLE,
custom_qr: STATE_VISIBLE,
broadcast: STATE_VISIBLE,
whatsapp_live_chat: STATE_VISIBLE,
promo_slides: STATE_VISIBLE,
@@ -115,16 +117,16 @@ const DEFAULT_MENU_STATES = {
cashiers_reports: STATE_VISIBLE,
collector_payments: STATE_VISIBLE,
tickets: STATE_VISIBLE,
inventory: STATE_LOCKED,
attendance: STATE_LOCKED,
payroll: STATE_LOCKED,
inventory: STATE_VISIBLE,
attendance: STATE_VISIBLE,
payroll: STATE_VISIBLE,
cash_in: STATE_VISIBLE,
expenses: STATE_VISIBLE,
expense_categories: STATE_VISIBLE,
cashier_attendance: STATE_VISIBLE,
technicians: STATE_LOCKED,
cashiers: STATE_LOCKED,
collectors: STATE_LOCKED,
technicians: STATE_VISIBLE,
cashiers: STATE_VISIBLE,
collectors: STATE_VISIBLE,
areas: STATE_VISIBLE,
update: STATE_VISIBLE,
vouchers_discounts: STATE_VISIBLE,
@@ -259,7 +261,7 @@ function enrichMenu(menu, states) {
locked,
hidden,
hrefResolved: menu.href,
lockedMessage: locked ? `Menu "${menu.labelDefault}" terkunci. Hubungi ${getFeatureContactPhone()} untuk mendapatkan password aktivasi.` : ''
lockedMessage: locked ? `Menu "${menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` : ''
};
if (Array.isArray(menu.children)) {
out.children = menu.children.map((ch) => enrichMenu(ch, states));
@@ -267,12 +269,21 @@ function enrichMenu(menu, states) {
return out;
}
function isCustomQrReady() {
try {
const enabled = getSetting('qris_static_enabled', true);
const off = enabled === false || enabled === 'false' || enabled === 0 || enabled === '0';
return !off && String(getSetting('qris_static_payload', '') || '').trim().length > 0;
} catch (e) { return false; }
}
function getSidebarSections(session) {
const states = getStoredMenuStates();
return SECTION_DEFINITIONS.map((section) => {
const items = MENU_DEFINITIONS
.filter((menu) => menu.section === section.key)
.filter((menu) => isMenuAllowedForSession(menu, session))
.filter((menu) => !(menu.key === 'custom_qr' && !isCustomQrReady()))
.map((menu) => {
const enriched = enrichMenu(menu, states);
if (Array.isArray(enriched.children)) {

View File

@@ -1,3 +1,5 @@
const dns = require('dns');
try { dns.setDefaultResultOrder('ipv4first'); } catch (e) {}
const { CompatBot } = require('./telegramCompat');
const { getSetting, getNowLocal } = require('../config/settingsManager');
const { logger } = require('../config/logger');
@@ -76,10 +78,14 @@ function initTelegram() {
}).catch(e => logger.error('Telegram Bot Error (getMe):', e.message));
}).catch(e => logger.error('Telegram Bot Error (deleteWebHook):', e.message));
// Middleware Admin Check (Fetch latest ID every time)
// Admin Check — dukung beberapa ID (pisahkan koma/titik-koma/spasi)
const getAdminIds = () => {
const raw = String(getSetting('telegram_admin_id', '') || getSetting('telegram_admin_chat_id', '') || '');
return raw.split(/[,;\s]+/).map(s => s.trim()).filter(Boolean);
};
const isAdmin = (msg) => {
const currentAdminId = getSetting('telegram_admin_id', '').toString();
return msg.from.id.toString() === currentAdminId;
const id = String(msg?.from?.id || '');
return !!id && getAdminIds().includes(id);
};
// Helper Mikhmon Parser
@@ -1178,13 +1184,24 @@ function initTelegram() {
// Export for manual re-init from settings
async function notifyAdmin(text) {
try {
const adminId = String(getSetting('telegram_admin_id', '') || '').trim();
if (!bot || !adminId || !text) return false;
const ids = adminId.split(/[,s]+/).filter(Boolean);
const raw = String(getSetting('telegram_admin_id', '') || getSetting('telegram_admin_chat_id', '') || '');
const ids = raw.split(/[,;\s]+/).map(s => s.trim()).filter(Boolean);
if (!bot || !ids.length || !text) return false;
// Telegram membatasi panjang pesan; pecah dengan aman.
const str = String(text);
const maxLen = 4000;
const chunks = [];
for (let i = 0; i < str.length; i += maxLen) chunks.push(str.slice(i, i + maxLen));
let ok = false;
for (const id of ids) {
try { await bot.sendMessage(id, text, { parse_mode: 'Markdown' }); ok = true; }
catch (e) { try { await bot.sendMessage(id, String(text)); ok = true; } catch (e2) {} }
for (const chunk of chunks) {
try { await bot.sendMessage(id, chunk, { parse_mode: 'Markdown' }); ok = true; }
catch (e) {
try { await bot.sendMessage(id, chunk); ok = true; } catch (e2) {}
}
}
}
return ok;
} catch (e) { return false; }

View File

@@ -140,8 +140,12 @@ async function isConnected() {
try {
if (gw === 'gowa') {
const st = await require('./gowaWhatsappService').gowaStatus();
const d = (st && st.data && st.data.results) || (st && st.data) || {};
val = !!(d.is_connected || d.is_logged_in || d.connected);
const raw = (st && st.data && st.data.results) || (st && st.data) || {};
const list = Array.isArray(raw) ? raw : [raw];
val = list.some(function (d) {
return !!(d && (d.is_connected || d.is_logged_in || d.connected ||
d.state === 'connected' || d.state === 'logged_in'));
});
} else if (gw === 'baileys') {
const mod = await import('./whatsappBot.mjs');
const s = mod.whatsappStatus;

View File

@@ -1 +1 @@
0.5.3
0.6.15

View File

@@ -189,6 +189,7 @@
<div class="act-menu">
<button type="button" class="act-item primary" onclick="refreshDevice('<%= d.id %>')"><i class="bi bi-arrow-repeat"></i> Refresh Device</button>
<button type="button" class="act-item" onclick="openAddWanModal('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>', '<%= d.sn %>', '<%= d.manufacturer || "" %>', '<%= d.model || "" %>')"><i class="bi bi-diagram-3"></i> Add WAN (All-in-One)</button>
<button type="button" class="act-item" onclick="openWifiModal('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>', '<%= d.sn %>')"><i class="bi bi-wifi"></i> Atur WiFi (SSID / Sandi)</button>
<div class="act-sep"></div>
<button type="button" class="act-item danger" onclick="deleteDevice('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>')"><i class="bi bi-trash"></i> <%= t('admin.acs.del_device','Hapus Perangkat') %></button>
</div>
@@ -276,6 +277,37 @@
</div>
<!-- Unified Add WAN All-in-One Modal -->
<div class="mo" id="wifiModal">
<div class="mb" style="max-width:520px">
<div class="mh">
<span class="mt"><i class="bi bi-wifi"></i> Atur WiFi ONU</span>
<button class="mc" onclick="closeModal('wifiModal')">&times;</button>
</div>
<div class="mbody">
<input type="hidden" id="wifiDeviceId">
<input type="hidden" id="wifiAcsId">
<div style="background:var(--sub-bg);border:1px dashed var(--border);border-radius:10px;padding:12px;margin-bottom:14px;font-size:13px">
<div><b>SN:</b> <span id="wifiSn" class="text-primary" style="font-weight:700">-</span></div>
<div style="margin-top:4px"><b>SSID saat ini:</b> <span id="wifiCurrentSsid" style="color:var(--muted)">Memuat…</span></div>
</div>
<div class="fg">
<label class="fl">SSID Baru (2.4G)</label>
<input class="fc" id="wifiSsidInput" placeholder="Nama WiFi baru">
<div style="font-size:11px;color:var(--muted);margin-top:4px">SSID 5G otomatis menjadi <code>&lt;nama&gt;-5G</code> jika didukung perangkat. SSID2/Guest tidak diubah.</div>
</div>
<button type="button" class="btn btn-p btn-sm" onclick="saveWifiSsid(this)"><i class="bi bi-save"></i> Simpan SSID</button>
<hr style="border-color:var(--border);margin:18px 0">
<div class="fg">
<label class="fl">Sandi WiFi Baru</label>
<input class="fc" type="password" id="wifiPassInput" placeholder="Minimal 8 karakter">
</div>
<button type="button" class="btn btn-p btn-sm" onclick="saveWifiPassword(this)"><i class="bi bi-shield-lock"></i> Simpan Sandi</button>
<div id="wifiMsg" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('wifiModal')">Tutup</button></div>
</div>
</div>
<div class="mo" id="addWanModal">
<div class="mb" style="max-width:650px">
<div class="mh">
@@ -1071,6 +1103,73 @@ async function bulkDelete() {
}
}
function openWifiModal(deviceId, acsId, sn) {
document.getElementById('wifiDeviceId').value = deviceId;
document.getElementById('wifiAcsId').value = acsId || '';
document.getElementById('wifiSn').textContent = sn || deviceId;
document.getElementById('wifiSsidInput').value = '';
document.getElementById('wifiPassInput').value = '';
document.getElementById('wifiCurrentSsid').textContent = 'Memuat…';
document.getElementById('wifiMsg').textContent = '';
openModal('wifiModal');
fetch('/admin/acs/api/wifi-settings/' + encodeURIComponent(deviceId) + '?acsId=' + encodeURIComponent(acsId || ''))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d || !d.success) {
document.getElementById('wifiCurrentSsid').textContent = (d && (d.message || d.error)) || 'Gagal memuat WiFi';
return;
}
var bands = d.bands || [];
var b1 = bands.find(function (b) { return String(b.index) === '1'; }) || bands[0] || null;
var b5 = bands.find(function (b) { return String(b.index) === '5'; });
var parts = [];
if (b1) parts.push('2.4G: ' + String(b1.ssid || '-'));
if (b5) parts.push('5G: ' + String(b5.ssid || '-'));
document.getElementById('wifiCurrentSsid').textContent = parts.join(' • ') || '-';
if (b1) document.getElementById('wifiSsidInput').value = String(b1.ssid || '').replace(/-5G$/, '');
})
.catch(function (e) { document.getElementById('wifiCurrentSsid').textContent = 'Error: ' + e.message; });
}
async function saveWifiSsid(btn) {
var id = document.getElementById('wifiDeviceId').value;
var ssid = String(document.getElementById('wifiSsidInput').value || '').trim();
var msg = document.getElementById('wifiMsg');
if (ssid.length < 2) { msg.textContent = 'SSID minimal 2 karakter.'; return; }
if (!confirm('Ubah SSID perangkat ini?')) return;
var old = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan…';
msg.textContent = '';
try {
var r = await fetch('/admin/acs/api/device/' + encodeURIComponent(id) + '/ssid', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid })
});
var d = await r.json();
msg.style.color = d.success ? 'var(--success)' : 'var(--danger)';
msg.textContent = d.success ? d.message : ('Gagal: ' + (d.message || 'unknown'));
} catch (e) { msg.style.color = 'var(--danger)'; msg.textContent = 'Error: ' + e.message; }
finally { btn.disabled = false; btn.innerHTML = old; }
}
async function saveWifiPassword(btn) {
var id = document.getElementById('wifiDeviceId').value;
var pass = String(document.getElementById('wifiPassInput').value || '').trim();
var msg = document.getElementById('wifiMsg');
if (pass.length < 8) { msg.textContent = 'Sandi minimal 8 karakter.'; return; }
if (!confirm('Ubah sandi WiFi perangkat ini?')) return;
var old = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan…';
msg.textContent = '';
try {
var r = await fetch('/admin/acs/api/device/' + encodeURIComponent(id) + '/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pass })
});
var d = await r.json();
msg.style.color = d.success ? 'var(--success)' : 'var(--danger)';
msg.textContent = d.success ? d.message : ('Gagal: ' + (d.message || 'unknown'));
if (d.success) document.getElementById('wifiPassInput').value = '';
} catch (e) { msg.style.color = 'var(--danger)'; msg.textContent = 'Error: ' + e.message; }
finally { btn.disabled = false; btn.innerHTML = old; }
}
async function deleteDevice(deviceId, acsId) {
if (!confirm('Hapus perangkat ' + deviceId + ' dari database ACS?')) return;
try {

View File

@@ -127,7 +127,7 @@
<option value="true" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
<option value="false" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
</select>
<div class="hint"><%= t('admin.automation.auto_delete_hint', 'Jika Aktif: pelanggan menunggak lebih dari batas (mis. bulan ke-4) dihapus otomatis — harus daftar ulang.') %></div>
<div class="hint"><%= t('admin.automation.auto_delete_hint', 'Jika Aktif: pelanggan menunggak satu bulan setelah batas dihapus otomatis — harus daftar ulang.') %></div>
</div>
<div class="fg">
<label class="fl"><i class="bi bi-exclamation-triangle"></i> <%= t('admin.automation.arrears_notice', 'Auto-Peringatan Tunggakan') %></label>

213
views/admin/custom_qr.ejs Normal file
View File

@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: activePage || 'custom_qr', company }) %>
<div class="mw">
<div class="topbar">
<div class="tb-title"><button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button><i class="bi bi-qr-code-simple"></i> QR-Custom Payment</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> Admin</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> Logout</a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<% if (typeof ready !== 'undefined' && !ready) { %>
<div class="alert alert-d"><i class="bi bi-exclamation-triangle"></i> <b>QR-Custom Payment dinonaktifkan.</b> Aktifkan <b>QRIS Statis (Semi-Otomatis)</b> dan isi <b>QRIS static payload</b> di <a href="/admin/settings" style="color:inherit;text-decoration:underline">Pengaturan</a> terlebih dahulu.</div>
<% } %>
<% if (typeof ready === 'undefined' || ready) { %>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-plus-circle"></i> Buat QR Nominal Custom</h6></div>
<div class="card-bd">
<form method="POST" action="/admin/custom-qr">
<div class="form-row">
<div class="fg" style="flex:2"><label class="fl">Judul *</label><input class="fc" name="title" required placeholder="mis. Pembayaran sewa tower / donasi perusahaan"></div>
<div class="fg"><label class="fl">Kategori</label><input class="fc" name="category" placeholder="mis. Sewa, Donasi, Lain-lain"></div>
<div class="fg"><label class="fl">Nominal (Rp) *</label><input class="fc" type="number" min="1" name="amount" required placeholder="100000"></div>
</div>
<div class="form-row">
<div class="fg" style="flex:2"><label class="fl">Catatan (opsional)</label><input class="fc" name="note" placeholder="Keterangan untuk penerima link"></div>
<div class="fg" style="display:flex;align-items:flex-end"><button class="btn btn-p" type="submit"><i class="bi bi-qr-code"></i> Generate QR (20 menit)</button></div>
</div>
</form>
</div>
</div>
<% } %>
<div class="form-row mb16">
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Pembayaran masuk</div><div style="font-size:22px;font-weight:800"><%= stats.paidCount %></div><div class="muted" style="font-size:11px">Rp <%= Number(stats.paidTotal||0).toLocaleString('id-ID') %></div></div></div></div>
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Menunggu (pending)</div><div style="font-size:22px;font-weight:800;color:#fbbf24"><%= stats.pendingCount %></div></div></div></div>
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Warning (lewat 20 menit)</div><div style="font-size:22px;font-weight:800;color:#fb923c"><%= stats.warningCount %></div></div></div></div>
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Ditolak / refund</div><div style="font-size:22px;font-weight:800"><%= stats.rejectedCount %></div></div></div></div>
</div>
<% if (detail) { %>
<% const cqExpired = detail.status === 'expired' || (detail.status === 'pending' && detail.expires_at && new Date(detail.expires_at).getTime() < Date.now()); %>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %><% if (!cqExpired) { %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %><% } %></h6></div>
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px">
<div style="text-align:center">
<% if (detail.status === 'paid' || detail.status === 'rejected' || cqExpired) { %>
<div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">
<% if (cqExpired) { %>QR kedaluwarsa — nominal &amp; QR disembunyikan.<% } else { %>QR tidak aktif — status: <b><%= detail.status %></b><% } %>
</div>
<% } else { %>
<img src="/custom-qr/<%= detail.share_token %>/qr.png" alt="QRIS" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
<div id="cqTimer" data-expires="<%= detail.expires_at || '' %>" style="margin-top:8px;font-size:13px;color:#fbbf24"></div>
<% } %>
</div>
<div style="font-size:13px;line-height:1.8">
<div><b>Status:</b> <%= detail.status %></div>
<% if (!cqExpired) { %>
<div><b>Nominal dasar:</b> Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %></div>
<div><b>Kode unik:</b> <%= detail.unique_code %></div>
<div><b>Nominal bayar:</b> Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></div>
<% } %>
<div><b>Kategori:</b> <%= detail.category || '-' %></div>
<div><b>Dibuat:</b> <%= detail.created_at %></div>
<div><b>Kedaluwarsa:</b> <%= detail.expires_at || '-' %></div>
<% if (detail.note) { %><div><b>Catatan:</b> <%= detail.note %></div><% } %>
<div style="margin-top:8px"><b>Link share:</b><br>
<input class="fc" readonly id="cqLink" value="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" style="font-size:12px">
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
<button class="btn btn-g btn-sm" onclick="copyCqLink()"><i class="bi bi-copy"></i> Salin Link</button>
<a class="btn btn-g btn-sm" href="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i> Buka</a>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">
<% if (detail.status !== 'paid') { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/manual-paid" onsubmit="return confirm('Tandai pembayaran ini LUNAS secara manual?')">
<input type="hidden" name="note" value="Manual pay dari panel admin">
<button class="btn btn-p btn-sm"><i class="bi bi-check2-circle"></i> Manual Pay</button>
</form>
<% } %>
<% if (detail.status === 'warning') { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/accept" onsubmit="return confirm('Terima pembayaran setelah 20 menit?')">
<button class="btn btn-p btn-sm"><i class="bi bi-check2"></i> Selesaikan</button>
</form>
<% } %>
<% if (detail.status !== 'rejected') { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/reject" onsubmit="return confirm('Tolak / refund pembayaran ini?')">
<input type="hidden" name="note" value="Refund dari panel admin">
<button class="btn btn-g btn-sm"><i class="bi bi-x-circle"></i> Reject / Refund</button>
</form>
<% } %>
<% if (cqExpired) { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/regenerate" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
<button class="btn btn-p btn-sm"><i class="bi bi-arrow-repeat"></i> Regenerate QR</button>
</form>
<% } %>
</div>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/delete" onsubmit="return confirm('Hapus QR ini dari histori?')" style="display:flex;gap:6px;margin-top:12px;flex-wrap:wrap">
<input class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:200px">
<button class="btn btn-g btn-sm"><i class="bi bi-trash"></i> Hapus QR ini</button>
</form>
</div>
</div>
</div>
<% } %>
<div class="card">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
<h6 style="margin:0"><i class="bi bi-clock-history"></i> Histori QR Custom</h6>
<form method="POST" action="/admin/custom-qr/reset" onsubmit="return confirm('Reset SEMUA histori QR custom? Tindakan ini tidak bisa dibatalkan.')" style="display:flex;gap:6px;align-items:center">
<input class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:180px">
<button class="btn btn-g btn-sm"><i class="bi bi-trash3"></i> Reset Histori</button>
</form>
</div>
<div class="card-bd" style="border-bottom:1px solid var(--border)">
<form method="GET" action="/admin/custom-qr" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<div class="fg"><label class="fl">Dari tanggal</label><input class="fc" type="date" name="from" value="<%= filterFrom || '' %>"></div>
<div class="fg"><label class="fl">Sampai tanggal</label><input class="fc" type="date" name="to" value="<%= filterTo || '' %>"></div>
<button class="btn btn-p btn-sm" type="submit"><i class="bi bi-funnel"></i> Filter</button>
<a class="btn btn-g btn-sm" href="/admin/custom-qr"><i class="bi bi-x-circle"></i> Reset Filter</a>
</form>
</div>
<form id="cqBulkForm" method="POST" action="/admin/custom-qr/delete-bulk"></form>
<div class="card-bd" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--border)">
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer">
<input type="checkbox" id="cqAll" onclick="cqToggleAll(this)"> Pilih semua
</label>
<input form="cqBulkForm" class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:180px">
<input form="cqBulkForm" type="hidden" name="from" value="<%= filterFrom || '' %>">
<input form="cqBulkForm" type="hidden" name="to" value="<%= filterTo || '' %>">
<button form="cqBulkForm" class="btn btn-g btn-sm" onclick="return confirm('Hapus QR yang dipilih?')"><i class="bi bi-trash"></i> Hapus Terpilih</button>
</div>
<div class="card-bd" style="overflow:auto">
<table class="table" style="width:100%;font-size:12.5px">
<thead><tr><th style="width:30px"></th><th>Judul</th><th>Kategori</th><th>Nominal</th><th>Status</th><th>Dibuat</th><th>Kedaluwarsa</th><th>Aksi</th></tr></thead>
<tbody>
<% if (!rows || rows.length === 0) { %>
<tr><td colspan="8" class="muted" style="text-align:center;padding:20px">Belum ada QR custom.</td></tr>
<% } %>
<% rows.forEach(function(r) { %>
<tr>
<td><input type="checkbox" class="cq-check" form="cqBulkForm" name="ids" value="<%= r.id %>"></td>
<td><b><%= r.title %></b><% if (r.note) { %><div class="muted" style="font-size:11px"><%= r.note %></div><% } %></td>
<td><%= r.category || '-' %></td>
<td>Rp <%= Number(r.amount_unique||0).toLocaleString('id-ID') %></td>
<td>
<span style="font-weight:700;color:<%= r.status==='paid'?'#4ade80':(r.status==='warning'?'#fb923c':(r.status==='rejected'?'#94a3b8':(r.status==='expired'?'#f87171':'#fbbf24'))) %>"><%= r.status %></span>
</td>
<td><%= r.created_at %></td>
<td><%= r.expires_at || '-' %></td>
<td style="white-space:nowrap">
<a class="btn btn-g btn-sm" href="/admin/custom-qr?id=<%= r.id %>"><i class="bi bi-eye"></i></a>
<% if (r.status !== 'paid' && r.status !== 'rejected') { %>
<form method="POST" action="/admin/custom-qr/<%= r.id %>/manual-paid" style="display:inline" onsubmit="return confirm('Tandai LUNAS manual?')">
<button class="btn btn-g btn-sm" title="Manual pay"><i class="bi bi-check2-circle"></i></button>
</form>
<% } %>
<% if (r.status === 'expired') { %>
<form method="POST" action="/admin/custom-qr/<%= r.id %>/regenerate" style="display:inline" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
<button class="btn btn-g btn-sm" title="Regenerate"><i class="bi bi-arrow-repeat"></i></button>
</form>
<% } %>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function cqToggleAll(el) {
var on = !!(el && el.checked);
document.querySelectorAll('.cq-check').forEach(function (c) { c.checked = on; });
}
function copyCqLink() {
var el = document.getElementById('cqLink');
if (!el) return;
try { el.select(); document.execCommand('copy'); } catch (e) {}
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(el.value).catch(function () {});
}
(function () {
var el = document.getElementById('cqTimer');
if (!el) return;
var exp = el.getAttribute('data-expires');
function tick() {
if (!exp) { el.textContent = ''; return; }
var d = new Date(exp).getTime() - Date.now();
if (d <= 0) { el.textContent = 'Lewat 20 menit — pembayaran tetap dicek.'; return; }
var m = Math.floor(d / 60000), s = Math.floor((d % 60000) / 1000);
el.textContent = 'Sisa waktu: ' + m + ' menit ' + s + ' detik';
}
tick(); setInterval(tick, 1000);
})();
</script>
</body>
</html>

View File

@@ -129,7 +129,7 @@ document.addEventListener('DOMContentLoaded', () => {
<i class="bi bi-box-arrow-left"></i><span><%= t('common.logout', 'Logout') %></span>
</a>
<div class="mt-4 px-3 py-2 text-center" style="border-top: 1px solid rgba(255,255,255,0.1);">
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
<a href="https://gitea.oxpoe.xyz/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
OPEN-ISP BILLING SYSTEM
</a>
<div style="color:#64748b;font-size:10px;margin-top:3px;line-height:1.5">
@@ -163,7 +163,7 @@ document.addEventListener('DOMContentLoaded', () => {
<%= t('admin.lock.code_label','Kode aktivasi menu ini') %> <strong style="color:#86efac">GRATIS</strong> <%= t('admin.lock.wiki_note','— tanpa perlu donasi untuk membukanya.') %>
</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:8px">
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" class="btn btn-p" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px">
<a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki" target="_blank" rel="noopener" class="btn btn-p" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px">
<i class="bi bi-git"></i> <%= t('admin.lock.get_code','Ambil Kode Aktivasi (Gratis)') %>
</a>
<a href="/admin/sidebar-settings" class="btn btn-g" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px" onclick="hideLockedMenuNotice()">
@@ -241,3 +241,31 @@ document.addEventListener('DOMContentLoaded', () => {
return false;
};
</script>
<div id="restart-fab" style="display:none;position:fixed;right:18px;bottom:84px;z-index:9999;max-width:330px">
<div style="background:#7c2d12;color:#fff;border:1px solid #f59e0b;border-radius:12px;padding:12px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);font-size:13px;line-height:1.45">
<b><i class="bi bi-exclamation-triangle-fill"></i> Billing perlu restart</b>
<div id="restart-fab-text" style="margin-top:4px;color:#fde68a">Update sudah diterapkan, tetapi kode lama masih berjalan.</div>
<a href="/admin/update" style="display:inline-block;margin-top:8px;background:#f59e0b;color:#111;font-weight:700;padding:6px 10px;border-radius:8px;text-decoration:none">Cara restart</a>
</div>
</div>
<script>
(function () {
function checkRestartRequired() {
fetch('/admin/api/restart-required', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (d) {
var el = document.getElementById('restart-fab');
if (!el) return;
el.style.display = (d && d.pending) ? 'block' : 'none';
if (d && d.pending && d.version) {
var t = document.getElementById('restart-fab-text');
if (t) t.textContent = 'Update v' + d.version + ' sudah diterapkan. Restart aplikasi agar fitur/fix baru aktif.';
}
})
.catch(function () {});
}
checkRestartRequired();
setInterval(checkRestartRequired, 30000);
})();
</script>

View File

@@ -527,6 +527,9 @@
<button type="button" class="btn btn-sm btn-g" id="tab-btn-script" onclick="switchIsolirTab('script')">
<i class="bi bi-filetype-txt me-1"></i> Copy Script RouterOS (.rsc)
</button>
<button type="button" class="btn btn-sm btn-g" id="tab-btn-cloud" onclick="switchIsolirTab('cloud')">
<i class="bi bi-cloud-arrow-up me-1"></i> Cloud (Sederhana)
</button>
</div>
<!-- TAB 1: Auto Setup via API -->
@@ -554,6 +557,23 @@
<div id="isolir-auto-setup-result" style="display:none;"></div>
</div>
<!-- TAB 3: Billing Cloud (script sederhana) -->
<div id="isolir-tab-cloud" style="display:none;">
<div class="alert alert-info p-3 small mb-3" style="background:rgba(56,189,248,0.10); border:1px solid rgba(56,189,248,0.3); color:#e0f2fe;">
<h6 class="fw-bold mb-1"><i class="bi bi-cloud me-1"></i> Billing di Cloud</h6>
<p class="mb-1 small">Script sederhana: DNS diarahkan ke router, HTTP pelanggan isolir di-NAT ke <b>redirector billing</b>, HTTPS ditolak agar browser fallback ke HTTP.</p>
<p class="mb-0 small">Syarat: port redirector (default <b>8099</b>) harus bisa diakses dari publik, dan <code>ISOLIR_REDIRECT_URL</code> di billing diisi <code>https://domain/isolated</code>.</p>
</div>
<div class="form-row" style="display:flex;gap:8px;flex-wrap:wrap">
<div class="fg" style="flex:2;min-width:200px"><label class="fl">Domain / IP publik billing (disarankan IP origin, bukan CDN)</label><input class="fc" id="ic-host" value="<%= typeof defaultPublicBase !== 'undefined' ? defaultPublicBase : '' %>" placeholder="https://billing.domain.com"></div>
<div class="fg" style="flex:1;min-width:100px"><label class="fl">Port redirector</label><input class="fc" id="ic-port" type="number" value="8099"></div>
<div class="fg" style="flex:1;min-width:100px"><label class="fl">Profil PPPoE</label><input class="fc" id="ic-profile" value="isolir"></div>
</div>
<button type="button" class="btn btn-p btn-sm my-2" onclick="loadIsolirCloudScript(this)"><i class="bi bi-magic"></i> Generate Script Cloud</button>
<div id="isolir-cloud-meta" class="small text-muted mb-2"></div>
<textarea id="isolir-cloud-body" class="fc" readonly rows="18" style="width:100%;font-family:ui-monospace,monospace;font-size:12px;background:rgba(15,23,42,0.9);color:#818cf8;border-radius:8px;"></textarea>
</div>
<!-- TAB 2: RouterOS Manual Script (.rsc) -->
<div id="isolir-tab-script" style="display:none;">
<p class="small text-muted mb-2"><%= t('admin.rt.script_hint','Script ini di-generate otomatis berdasarkan IP server & URL billing (app_url). Anda dapat menyalin dan menempelkannya di Winbox Terminal MikroTik.') %></p>
@@ -563,6 +583,7 @@
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('isolirScriptModal')"><%= t('common.close', 'Tutup') %></button>
<button type="button" class="btn btn-p" id="btn-copy-cloud-modal" style="display:none;" onclick="copyIsolirCloudScript()"><i class="bi bi-clipboard me-1"></i> Salin Script Cloud</button>
<button type="button" class="btn btn-p" id="btn-copy-script-modal" style="display:none;" onclick="copyIsolirScript()"><i class="bi bi-clipboard me-1"></i> <%= t('admin.routers.isolir_script_copy', 'Salin script') %></button>
</div>
</div>
@@ -891,25 +912,58 @@ async function testConn(id) {
}
function switchIsolirTab(tab) {
const autoTab = document.getElementById('isolir-tab-auto');
const scriptTab = document.getElementById('isolir-tab-script');
const autoBtn = document.getElementById('tab-btn-auto');
const scriptBtn = document.getElementById('tab-btn-script');
const copyBtn = document.getElementById('btn-copy-script-modal');
if (tab === 'auto') {
autoTab.style.display = 'block';
scriptTab.style.display = 'none';
autoBtn.className = 'btn btn-sm btn-p';
scriptBtn.className = 'btn btn-sm btn-g';
if (copyBtn) copyBtn.style.display = 'none';
} else {
autoTab.style.display = 'none';
scriptTab.style.display = 'block';
autoBtn.className = 'btn btn-sm btn-g';
scriptBtn.className = 'btn btn-sm btn-p';
if (copyBtn) copyBtn.style.display = 'inline-block';
const panels = {
auto: document.getElementById('isolir-tab-auto'),
script: document.getElementById('isolir-tab-script'),
cloud: document.getElementById('isolir-tab-cloud')
};
const btns = {
auto: document.getElementById('tab-btn-auto'),
script: document.getElementById('tab-btn-script'),
cloud: document.getElementById('tab-btn-cloud')
};
const copyScript = document.getElementById('btn-copy-script-modal');
const copyCloud = document.getElementById('btn-copy-cloud-modal');
Object.keys(panels).forEach(function (k) {
if (panels[k]) panels[k].style.display = (k === tab) ? 'block' : 'none';
if (btns[k]) btns[k].className = 'btn btn-sm ' + (k === tab ? 'btn-p' : 'btn-g');
});
if (copyScript) copyScript.style.display = tab === 'script' ? 'inline-block' : 'none';
if (copyCloud) copyCloud.style.display = tab === 'cloud' ? 'inline-block' : 'none';
}
async function loadIsolirCloudScript(btn) {
const hostEl = document.getElementById('ic-host');
const portEl = document.getElementById('ic-port');
const profEl = document.getElementById('ic-profile');
const body = document.getElementById('isolir-cloud-body');
const meta = document.getElementById('isolir-cloud-meta');
const old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Membuat...'; }
if (body) body.value = '';
if (meta) meta.textContent = 'Membuat script...';
try {
const url = '/admin/api/isolir-cloud-script?host=' + encodeURIComponent(hostEl ? hostEl.value : '') +
'&port=' + encodeURIComponent(portEl ? portEl.value : '') +
'&profile=' + encodeURIComponent(profEl ? profEl.value : '');
const res = await fetch(url);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Gagal membuat script.');
if (body) body.value = data.script || '';
if (meta) meta.textContent = 'Billing: ' + (data.billingHost || '-') + ' → ' + ((data.billingIps || [data.billingIp]).join(', ')) + ' | redirect port ' + data.redirectPort + ' | ' + (data.isolirUrl || '') + (data.warning ? ' — ' + data.warning : '');
} catch (e) {
if (meta) meta.textContent = 'Error: ' + e.message;
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = old; }
}
}
function copyIsolirCloudScript() {
const ta = document.getElementById('isolir-cloud-body');
if (!ta || !ta.value) return;
ta.select();
try { document.execCommand('copy'); } catch (e) {}
alert('Script cloud disalin ke clipboard.');
}
async function setupFirewall(routerId) {

View File

@@ -68,6 +68,43 @@
<input class="fc" name="notice_note" id="n_note" value="<%= settings.notice_note || '' %>" placeholder="<%= t('admin.service_notice.note_ph', 'Kosongkan untuk teks bawaan') %>">
</div>
</div>
<div class="form-row" style="margin-top:4px">
<div class="fg">
<label class="fl" style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" name="notice_wa" value="1" id="n_wa">
Kirim notifikasi WhatsApp ke pelanggan saat status berubah
</label>
<div class="hint">Terkirim otomatis saat mode diubah (gangguan/perbaikan, atau kembali NORMAL = layanan pulih).</div>
</div>
<div class="fg">
<label class="fl">Target Notifikasi</label>
<select class="fs" name="notice_wa_target" id="n_wa_target">
<option value="all">Semua pelanggan aktif</option>
<option value="area">Per area</option>
<option value="router">Per router</option>
</select>
</div>
</div>
<div class="form-row" id="n_wa_extra" style="display:none">
<div class="fg" id="n_wa_area_wrap">
<label class="fl">Area</label>
<select class="fs" name="notice_wa_area">
<option value="">— pilih area —</option>
<% (areas || []).forEach(function(a){ %>
<option value="<%= a %>"><%= a %></option>
<% }); %>
</select>
</div>
<div class="fg" id="n_wa_router_wrap" style="display:none">
<label class="fl">Router</label>
<select class="fs" name="notice_wa_router">
<option value="">— pilih router —</option>
<% (routers || []).forEach(function(r){ %>
<option value="<%= r.id %>"><%= r.name %></option>
<% }); %>
</select>
</div>
</div>
</div>
<div class="card-bd" style="border-top:1px solid var(--border);padding-top:16px;text-align:right">
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> <%= t('common.save', 'Simpan') %></button>
@@ -140,6 +177,19 @@
[els.mode, els.start, els.interval, els.note].forEach(function(el){ el.addEventListener(ev, render); });
});
render();
var waTarget = document.getElementById('n_wa_target');
var waExtra = document.getElementById('n_wa_extra');
var waArea = document.getElementById('n_wa_area_wrap');
var waRouter = document.getElementById('n_wa_router_wrap');
function renderWaTarget(){
var v = waTarget.value;
waExtra.style.display = v === 'all' ? 'none' : 'flex';
waArea.style.display = v === 'area' ? '' : 'none';
waRouter.style.display = v === 'router' ? '' : 'none';
}
waTarget.addEventListener('change', renderWaTarget);
renderWaTarget();
</script>
</body>
</html>

View File

@@ -173,6 +173,7 @@
<button type="button" class="set-tab" data-cat="notif" onclick="setSetTab('notif')"><i class="bi bi-bell"></i> <%= t('admin.settings.tab_notif','Notifikasi & WhatsApp') %></button>
<button type="button" class="set-tab" data-cat="keamanan" onclick="setSetTab('keamanan')"><i class="bi bi-shield-lock"></i> <%= t('admin.settings.tab_security','Keamanan') %></button>
<button type="button" class="set-tab" data-cat="pembayaran" onclick="setSetTab('pembayaran')"><i class="bi bi-wallet2"></i> <%= t('admin.settings.tab_payment','Pembayaran') %></button>
<button type="button" class="set-tab" data-cat="ai" onclick="setSetTab('ai')"><i class="bi bi-robot"></i> AI</button>
<button type="button" class="set-tab" data-cat="branding" onclick="setSetTab('branding')"><i class="bi bi-palette"></i> Branding</button>
</div>
<div class="set-search">
@@ -209,7 +210,7 @@
</div>
<div class="card mb16">
<div class="card mb16" data-setcat="ai">
<div class="card-hd"><h6><i class="bi bi-robot"></i> AI &amp; Self-Healing</h6></div>
<div class="card-bd settings-grid">
<div>
@@ -237,6 +238,10 @@
<div class="fg"><label class="fl">Custom Base URL</label><input class="fc" name="custom_ai_base_url" value="<%= settings.custom_ai_base_url || '' %>" placeholder="http://localhost:11434/v1"></div>
<div class="fg"><label class="fl">Custom API Key</label><input class="fc" type="password" autocomplete="new-password" name="custom_ai_api_key" value="<%= settings.custom_ai_api_key || '' %>" placeholder="opsional untuk Ollama"></div>
<div class="fg"><label class="fl">Custom Model</label><input class="fc" name="custom_ai_model" value="<%= settings.custom_ai_model || 'llama3' %>"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px">
<button type="button" class="btn btn-g" onclick="testAiApi(this)"><i class="bi bi-plug"></i> Test API</button>
<span id="ai-test-status" style="font-size:12px;color:var(--muted)">Menguji API key &amp; model yang sedang diisi (tanpa simpan).</span>
</div>
</div>
<div>
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-activity"></i> Auto Repair</h6>
@@ -268,11 +273,26 @@
<label class="fl"><%= t('admin.settings.company_name', 'Nama Aplikasi / Perusahaan') %></label>
<input class="fc" name="company_header" value="<%= settings.company_header || '' %>">
</div>
<div class="fg">
<label class="fl">Nama PT / Sub-header (kop invoice &amp; PDF)</label>
<input class="fc" name="company_subheader" value="<%= settings.company_subheader || '' %>" placeholder="Contoh: PT Nama Perusahaan">
<div style="font-size:10px;color:var(--muted);margin-top:4px">Dipakai pada kop invoice/PDF. Bisa berbeda tiap instalasi.</div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.set.public_url','URL Publik Aplikasi (Public Base URL)') %></label>
<input class="fc" name="public_base_url" value="<%= settings.public_base_url || '' %>" placeholder="https://portal.domain-anda.com">
<div style="font-size:10px; color:var(--muted); margin-top:4px"><%= t('admin.set.link_hint','Digunakan untuk menyusun tautan login pelanggan yang dikirim via WhatsApp. Contoh: ') %><span class="mono">https://portal.domain-anda.com</span></div>
</div>
<div class="fg">
<label class="fl">URL Halaman Isolir</label>
<input class="fc" name="isolir_portal_url" value="<%= settings.isolir_portal_url || '' %>" placeholder="https://domain-anda/isolated">
<div style="font-size:10px;color:var(--muted);margin-top:4px">Tujuan redirect pelanggan terisolir (dipakai redirector &amp; generator script cloud).</div>
</div>
<div class="fg">
<label class="fl">Port Redirector Isolir</label>
<input class="fc" type="number" min="1" max="65535" name="isolir_redirect_port" value="<%= settings.isolir_redirect_port || 8099 %>">
<div style="font-size:10px;color:var(--muted);margin-top:4px">Port server redirector di billing (default 8099). Ubah NAT MikroTik bila port diganti.</div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.set.id_prefix','Prefix ID Pelanggan') %></label>
<input class="fc" name="customer_id_prefix" value="<%= settings.customer_id_prefix || 'SKY' %>" placeholder="SKY" style="text-transform:uppercase" maxlength="12">
@@ -372,6 +392,71 @@
</div>
<!-- GenieACS & MikroTik -->
<div class="card mb16" data-setcat="umum">
<div class="card-hd"><h6><i class="bi bi-window-desktop"></i> Kustomisasi Beranda &amp; Portal Pelanggan</h6></div>
<div class="card-bd settings-grid">
<div>
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-megaphone"></i> Banner &amp; Status</h6>
<div class="fg">
<label class="fl">Banner Pengumuman Portal</label>
<select class="fs" name="portal_banner_enabled">
<option value="false" <%= !settings.portal_banner_enabled ? 'selected' : '' %>>Nonaktif</option>
<option value="true" <%= settings.portal_banner_enabled ? 'selected' : '' %>>Aktif</option>
</select>
</div>
<div class="fg">
<label class="fl">Tipe Banner</label>
<select class="fs" name="portal_banner_type">
<option value="info" <%= (settings.portal_banner_type === 'info' || !settings.portal_banner_type) ? 'selected' : '' %>>Info (Biru)</option>
<option value="warning" <%= settings.portal_banner_type === 'warning' ? 'selected' : '' %>>Peringatan (Kuning)</option>
<option value="danger" <%= settings.portal_banner_type === 'danger' ? 'selected' : '' %>>Gangguan (Merah)</option>
<option value="success" <%= settings.portal_banner_type === 'success' ? 'selected' : '' %>>Promo (Hijau)</option>
</select>
</div>
<div class="fg">
<label class="fl">Teks Banner</label>
<textarea class="fc" name="portal_banner_text" rows="2" placeholder="Contoh: Maintenance jaringan Kamis 01.00-04.00 WIB."><%= settings.portal_banner_text || '' %></textarea>
</div>
<div class="fg">
<label class="fl">Status Jaringan</label>
<select class="fs" name="portal_network_status">
<option value="normal" <%= (settings.portal_network_status === 'normal' || !settings.portal_network_status) ? 'selected' : '' %>>🟢 Normal</option>
<option value="maintenance" <%= settings.portal_network_status === 'maintenance' ? 'selected' : '' %>>🟠 Pemeliharaan</option>
<option value="issue" <%= settings.portal_network_status === 'issue' ? 'selected' : '' %>>🔴 Ada Kendala</option>
</select>
</div>
<div class="fg">
<label class="fl">Keterangan Status (opsional)</label>
<input class="fc" name="portal_network_status_text" value="<%= settings.portal_network_status_text || '' %>" placeholder="Kosongkan untuk teks bawaan">
</div>
</div>
<div>
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-card-heading"></i> Hero &amp; Media Sosial</h6>
<div class="fg">
<label class="fl">Badge Kecil</label>
<input class="fc" name="portal_hero_badge" value="<%= settings.portal_hero_badge || '' %>" placeholder="Portal Pelanggan">
</div>
<div class="fg">
<label class="fl">Judul Utama</label>
<input class="fc" name="portal_hero_title" value="<%= settings.portal_hero_title || '' %>" placeholder="Kosongkan untuk teks bawaan">
</div>
<div class="fg">
<label class="fl">Deskripsi</label>
<textarea class="fc" name="portal_hero_subtitle" rows="2" placeholder="Kosongkan untuk teks bawaan"><%= settings.portal_hero_subtitle || '' %></textarea>
</div>
<div class="fg">
<label class="fl">Link Google Maps Kantor</label>
<input class="fc" name="social_maps_url" value="<%= settings.social_maps_url || '' %>" placeholder="https://maps.google.com/...">
</div>
<div class="form-row">
<div class="fg"><label class="fl">Instagram</label><input class="fc" name="social_instagram" value="<%= settings.social_instagram || '' %>" placeholder="https://instagram.com/..."></div>
<div class="fg"><label class="fl">Facebook</label><input class="fc" name="social_facebook" value="<%= settings.social_facebook || '' %>" placeholder="https://facebook.com/..."></div>
</div>
<div class="fg"><label class="fl">TikTok</label><input class="fc" name="social_tiktok" value="<%= settings.social_tiktok || '' %>" placeholder="https://tiktok.com/@..."></div>
</div>
</div>
</div>
<div data-setcat="jaringan">
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-router"></i> <%= t('admin.settings.external_api', 'API Eksternal') %></h6>
@@ -640,9 +725,18 @@
</script>
<div class="fg">
<label class="fl"><%= t('admin.set.repo_link','Link Repositori Proyek (Gitea)') %></label>
<input class="fc" name="project_url" value="<%= settings.project_url || 'https://gitea.oxpoe.qzz.io/oxpoe/open-isp' %>" placeholder="https://gitea.oxpoe.qzz.io/oxpoe/open-isp">
<input class="fc" name="project_url" value="<%= settings.project_url || 'https://gitea.oxpoe.xyz/oxpoe/open-isp' %>" placeholder="https://gitea.oxpoe.xyz/oxpoe/open-isp">
<div style="font-size:10px; color:var(--muted); margin-top:4px"><%= t('admin.set.repo_hint','Dipakai untuk tautan ') %><b>Repo</b>, <b>Form/Issue</b> (<code>/issues/new/choose</code>), <b>Wiki</b>, dan <b>Lisensi</b> di landing page.</div>
</div>
<div class="fg">
<label class="fl">Sumber Update (host Git)</label>
<input class="fc" name="update_base_url" value="<%= settings.update_base_url || 'https://gitea.oxpoe.xyz' %>" placeholder="https://gitea.oxpoe.xyz">
<div style="font-size:10px; color:var(--muted); margin-top:4px">Dipakai menu <b>Update</b> untuk <code>git fetch</code>. Bisa Gitea (<code>https://gitea.oxpoe.xyz</code>) atau Forgejo (<code>https://forgejo.oxpoe.xyz</code>).</div>
</div>
<div class="fg">
<label class="fl">Repo Update (owner/nama)</label>
<input class="fc" name="update_repo" value="<%= settings.update_repo || 'oxpoe/open-isp' %>" placeholder="oxpoe/open-isp">
</div>
<div class="fg">
<label class="fl"><%= t('admin.set.uptime_link','Link Uptime Website (Status)') %></label>
<input class="fc" name="uptime_url" value="<%= settings.uptime_url || '' %>" placeholder="https://status.domain-anda.com">
@@ -796,7 +890,7 @@
<div style="margin-top: 15px; border-top: 1px dashed rgba(0,0,0,0.1); padding-top: 15px;">
<h6 style="font-size:11px; margin-bottom: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;"><i class="bi bi-phone"></i> Hubungkan Webhook Notifikasi E-Wallet (Mode 2)</h6>
<%
const _base = (settings.public_base_url || '').replace(/\/+$/, '');
const _base = String(typeof webhookBaseUrl !== 'undefined' && webhookBaseUrl ? webhookBaseUrl : (settings.public_base_url || '')).replace(/\/+$/, '');
const _secret = String(settings.webhook_secret || '');
const macroWebhookUrlFull = _base + '/api/webhook/v1/payment-notif?secret_key=' + encodeURIComponent(_secret);
%>
@@ -822,6 +916,7 @@
<option value="false" <%= (settings.webhook_unmatched_notify_enabled === undefined ? true : settings.webhook_unmatched_notify_enabled) ? '' : 'selected' %>><%= t('common.inactive','Nonaktif') %></option>
</select>
<div class="hint"><%= t('admin.set.wh_wa_hint','Kirim WA ke admin tiap ada pembayaran terdeteksi tapi belum cocok (untuk dicocokkan manual via jam notif vs bukti transfer).') %></div>
<div class="hint" style="margin-top:6px"><i class="bi bi-info-circle"></i> Secret dibuat otomatis jika kosong. URL memakai <b>Public Base URL</b>; bila kosong memakai host saat ini. Salin URL + token ke aplikasi notification forwarder.</div>
</div>
<div class="fg" style="margin-top:10px">
<label class="fl"><i class="bi bi-clock-history"></i> <%= t('admin.set.wh_retention','Retensi Notifikasi Webhook (Otomasi)') %></label>
@@ -1237,6 +1332,46 @@ function setSearch(q) {
})();
</script>
<script>
async function testAiApi(btn) {
var st = document.getElementById('ai-test-status');
var provider = (document.querySelector('select[name="ai_provider"]') || {}).value || 'deepseek';
var map = {
deepseek: { key: 'deepseek_api_key', model: 'deepseek_model', base: '' },
openai: { key: 'openai_api_key', model: 'openai_model', base: '' },
groq: { key: 'groq_api_key', model: 'groq_model', base: '' },
openrouter: { key: 'openrouter_api_key', model: 'openrouter_model', base: '' },
custom: { key: 'custom_ai_api_key', model: 'custom_ai_model', base: 'custom_ai_base_url' }
};
var m = map[provider] || map.deepseek;
function val(name) { var el = document.querySelector('[name="' + name + '"]'); return el ? el.value : ''; }
var old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menguji...'; }
if (st) { st.style.color = 'var(--muted)'; st.textContent = 'Menguji ' + provider + '...'; }
try {
var r = await fetch('/admin/settings/test-ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: provider,
apiKey: val(m.key),
model: val(m.model),
baseUrl: m.base ? val(m.base) : ''
})
});
var d = await r.json();
if (st) {
st.style.color = d.ok ? 'var(--success)' : 'var(--danger)';
st.textContent = (d.ok ? '✅ ' : '❌ ') + (d.message || (d.ok ? 'API valid.' : 'API tidak valid.'));
}
} catch (e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = '❌ Gagal menguji: ' + e.message; }
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = old; }
}
}
</script>
<script>
async function selfHeal(mode) {
const out = document.getElementById('selfheal-out');

View File

@@ -43,7 +43,7 @@
</div>
<div class="card-bd">
<p class="hint" style="margin-top:0">
<%= t('admin.sb.intro', 'Setiap menu bisa diatur menjadi Tampil, Sembunyikan, atau Kunci. Menu yang dikunci akan meminta password khusus sebelum dibuka.') %>
<%= t('admin.sb.intro', 'Setiap menu bisa diatur menjadi Tampil atau Sembunyikan.') %>
</p>
<% if (canManageSidebar) { %>
@@ -76,7 +76,7 @@
<select class="fs" name="menu_state[<%= menu.key %>]">
<option value="visible" <%= menu.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
<option value="hidden" <%= menu.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
<option value="locked" <%= menu.state === 'locked' ? 'selected' : '' %>><%= t('admin.sb.st_locked', 'Kunci') %></option>
</select>
</td>
</tr>
@@ -97,7 +97,7 @@
<select class="fs" name="menu_state[<%= ch.key %>]">
<option value="visible" <%= ch.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
<option value="hidden" <%= ch.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
<option value="locked" <%= ch.state === 'locked' ? 'selected' : '' %>><%= t('admin.sb.st_locked', 'Kunci') %></option>
</select>
</td>
</tr>
@@ -108,12 +108,9 @@
</div>
<div class="sidebar-settings-save-grid" style="margin-top:16px">
<div class="fg" style="margin:0">
<label class="fl"><%= t('admin.sb.pass_label', 'Password Aktivasi (Seikhlasnya)') %></label>
<input type="password" class="fc" name="feature_password" placeholder="<%= t('admin.sb.pass_ph', 'Masukkan password aktivasi sebelum menyimpan') %>" required>
<div class="hint" style="margin-top:6px"><%= t('admin.sb.pass_hint', 'Perubahan status menu sidebar hanya akan disimpan jika password aktivasi benar.') %></div>
</div>
<div class="hint" style="align-self:center">
<%= t('admin.sb.no_pass', 'Belum punya kode aktivasi?') %> <a href="/donate" target="_blank" rel="noopener" style="color:var(--primary);font-weight:700;"><i class="bi bi-heart-fill me-1"></i> <%= t('admin.sb.get_donation', 'Dapatkan kode aktivasi GRATIS di halaman Dukungan OPEN-ISP') %></a> <%= t('admin.sb.or_contact', 'atau hubungi') %> <strong><%= featureContactPhone %></strong>.
<%= t('admin.sb.no_pass', 'Menu bisa ditampilkan/disembunyikan dari halaman ini.') %> <a href="/donate" target="_blank" rel="noopener" style="color:var(--primary);font-weight:700;"><i class="bi bi-heart-fill me-1"></i> <%= t('admin.sb.get_donation', 'Dukung pengembangan OPEN-ISP') %></a>.
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;flex-wrap:wrap">
@@ -138,12 +135,12 @@
</div>
<div class="card-bd">
<div class="alert alert-s" style="margin-bottom:12px">
<i class="bi bi-info-circle"></i> <%= t('admin.sb.note_lock', 'Jika menu berstatus Kunci diklik dari sidebar, sistem menampilkan cara mengambil kode aktivasi GRATIS atau menghubungi') %> <strong><%= featureContactPhone %></strong>.
<i class="bi bi-info-circle"></i> <%= t('admin.sb.note_lock', 'Menu berstatus Kunci tidak dapat dibuka sampai diaktifkan dari halaman ini.') %>
</div>
<ul style="margin:0;padding-left:18px;display:grid;gap:8px">
<li><%= t('admin.sb.note1', 'Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.') %></li>
<li><%= t('admin.sb.note2', 'Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.') %></li>
<li><%= t('admin.sb.note3', 'Perubahan status menu hanya dapat disimpan setelah memasukkan password aktivasi yang benar.') %></li>
</ul>
</div>
</div>

View File

@@ -23,6 +23,18 @@
</div>
<% } %>
<% if (typeof restartPending !== 'undefined' && restartPending) { %>
<div class="alert alert-d" style="display:flex;gap:10px;align-items:flex-start">
<i class="bi bi-exclamation-triangle-fill" style="font-size:18px;margin-top:1px"></i>
<div>
<b>Restart diperlukan.</b>
Update ke versi <b><%= restartPending.version || '-' %></b> sudah diterapkan ke file,
tetapi aplikasi masih menjalankan kode lama. Fitur/fix baru belum aktif sampai aplikasi di-restart.
<a href="#restart-help" style="color:inherit;text-decoration:underline;font-weight:700">Lihat cara restart</a>.
</div>
</div>
<% } %>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-git"></i> <%= t('admin.update.status_title', 'Status Versi') %></h6></div>
<div class="card-bd">
@@ -79,6 +91,26 @@
</div>
</div>
<div class="card mb16" id="restart-help">
<div class="card-hd"><h6><i class="bi bi-arrow-repeat"></i> <%= t('admin.update.restart_title', 'Cara Restart Aplikasi') %></h6></div>
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px;font-size:12px">
<div style="border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:rgba(255,255,255,.02);line-height:1.75">
<div style="font-weight:800;margin-bottom:8px"><i class="bi bi-server"></i> <%= t('admin.update.restart_selfhosted', 'Self-hosted') %></div>
<div><b>PM2</b><br><code>pm2 restart all</code> <span style="color:var(--muted)">(atau <code>pm2 restart open-isp</code>)</span></div>
<div style="margin-top:8px"><b>Manual (tanpa PM2)</b><br><code>pkill -f "node app-customer.js"</code><br><code>nohup node app-customer.js &gt; logs/run.log 2&gt;&amp;1 &amp;</code></div>
<div style="margin-top:8px"><b>Quadlet / systemd</b><br><code>systemctl --user restart open-isp</code> <span style="color:var(--muted)">(user)</span><br><code>sudo systemctl restart open-isp</code> <span style="color:var(--muted)">(system)</span></div>
<div style="margin-top:8px"><b>Docker / Podman Compose</b><br><code>docker compose restart</code><br><code>podman compose restart</code> <span style="color:var(--muted)">(atau <code>up -d</code> bila env berubah)</span></div>
<div style="margin-top:8px;color:var(--muted)">Jalankan dari folder aplikasi, lalu muat ulang halaman ini.</div>
</div>
<div style="border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:rgba(59,130,246,.06);line-height:1.75">
<div style="font-weight:800;margin-bottom:8px"><i class="bi bi-cloud"></i> <%= t('admin.update.restart_cloud', 'Cloud / managed') %></div>
<div><i class="bi bi-check2-circle" style="color:var(--success)"></i> Restart dilakukan <b>otomatis pada update berikutnya</b>.</div>
<div style="margin-top:8px"><i class="bi bi-headset"></i> Butuh restart sekarang? <b>Hubungi admin server</b> — jangan jalankan perintah restart sendiri.</div>
<div style="margin-top:8px;color:var(--muted)">Cocok untuk instance yang dikelola penyedia/hosting bersama.</div>
</div>
</div>
</div>
<% if (log) { %>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-terminal"></i> <%= t('admin.update.log_title', 'Log Update') %></h6></div>

View File

@@ -154,6 +154,25 @@
<!-- 3. GOWA (go-whatsapp-web-multidevice) FIELDS -->
<div id="gowa_fields_container" style="<%= waGatewayType === 'gowa' ? '' : 'display:none;' %> background: rgba(173,255,47,0.05); padding: 16px; border-radius: 10px; border: 1px solid var(--primary-glow); margin-bottom: 16px;">
<div class="fw6 mb12" style="color: var(--primary); font-size: 13px;"><i class="bi bi-lightning-charge-fill"></i> Konfigurasi GOWA (go-whatsapp-web-multidevice) — Ringan / Self-Hosted</div>
<div class="form-row mb12">
<div class="fg" style="flex:1;">
<label class="fl">Mode Akses GOWA</label>
<select class="fs" name="gowa_mode" id="gowa_mode" onchange="toggleGowaMode()">
<option value="cloud" <%= gowaSettings.gowa_mode === 'local' ? '' : 'selected' %>>Cloud (HTTPS) — disarankan</option>
<option value="local" <%= gowaSettings.gowa_mode === 'local' ? 'selected' : '' %>>Local (HTTP) — wajib polyfill</option>
</select>
</div>
<div class="fg" style="flex:2;">
<label class="fl">Keterangan</label>
<div id="gowa_mode_hint" style="font-size:11.5px;color:var(--muted);line-height:1.5">
<% if (gowaSettings.gowa_mode === 'local') { %>
Base URL <code>http://IP:3000</code>. UI GOWA wajib diberi polyfill <code>crypto.randomUUID</code> (atau akses lewat SSH tunnel). Panduan: <a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki/Konfigurasi" target="_blank" rel="noopener" style="color:var(--primary)">Wiki → Konfigurasi → GOWA Local</a>.
<% } else { %>
Base URL <code>https://domain-gowa</code> dengan TLS. UI GOWA langsung normal, tanpa polyfill.
<% } %>
</div>
</div>
</div>
<div class="form-row mb12">
<div class="fg" style="flex:2;">
<label class="fl">Base URL GOWA *</label>
@@ -162,7 +181,8 @@
</div>
<div class="fg" style="flex:1;">
<label class="fl"><%= t('admin.wa.ph_deviceid','Device ID (opsional)') %></label>
<input class="fc" name="gowa_device_id" value="<%= gowaSettings.gowa_device_id %>" placeholder="multi-device (mis. device1)">
<input class="fc" name="gowa_device_id" value="<%= gowaSettings.gowa_device_id %>" placeholder="kosongkan = ikuti device yang terhubung">
<div style="font-size:11px;color:var(--muted);margin-top:4px">Kosongkan untuk otomatis mengikuti device yang login via QR. Isi hanya bila ingin menunjuk device GOWA tertentu.</div>
</div>
</div>
<div class="form-row mb12">
@@ -180,8 +200,10 @@
<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px"><%- t('admin.wa.qr_hint','Klik tombol, lalu buka WhatsApp &rarr; Perangkat Tertaut &rarr; Tautkan perangkat, scan QR. QR berlaku ~30 detik &amp; diperbarui otomatis.') %></div>
<button type="button" class="btn btn-p btn-sm" onclick="showGowaQr()"><i class="bi bi-qr-code-scan"></i> Tampilkan QR</button>
<a href="<%= gowaUiUrl %>" target="_blank" rel="noopener" class="btn btn-g btn-sm" style="margin-left:6px"><i class="bi bi-box-arrow-up-right"></i> Buka UI GOWA</a>
<button type="button" class="btn btn-g btn-sm" style="margin-left:6px" onclick="testGowaConn(this)"><i class="bi bi-plug"></i> Test Koneksi</button>
<div id="gowa_conn_status" style="display:none;margin-top:8px;font-size:12px;line-height:1.5"></div>
<div id="gowa_qr_wrap" style="display:none;margin-top:10px;text-align:center">
<img id="gowa_qr_img" alt="QR GOWA" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
<img id="gowa_qr_img" alt="QR GOWA" style="max-width:240px;background:#fff;padding:10px;border-radius:12px" onerror="var s=document.getElementById('gowa_qr_status');if(s)s.textContent='Gambar QR gagal dimuat — cek log server (base URL/kredensial GOWA).'">
<div id="gowa_qr_status" style="font-size:12px;color:var(--muted);margin-top:6px"></div>
</div>
</div>
@@ -197,8 +219,22 @@
</div>
</div>
<div class="fg" style="flex:1;">
<label class="fl">Mode Webhook Secret</label>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button type="button" class="btn btn-sm <%= gowaSettings.gowa_secret_mode === 'auto' ? 'btn-p' : 'btn-g' %>" id="gsm-btn-auto" onclick="gowaSecretMode('auto')"><i class="bi bi-magic"></i> Auto Generate</button>
<button type="button" class="btn btn-sm <%= gowaSettings.gowa_secret_mode === 'manual' ? 'btn-p' : 'btn-g' %>" id="gsm-btn-manual" onclick="gowaSecretMode('manual')"><i class="bi bi-pencil-square"></i> Manual</button>
</div>
<label class="fl">Webhook Secret (HMAC)</label>
<input class="fc" name="gowa_webhook_secret" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="secret">
<div style="display:flex;gap:8px;">
<input class="fc" id="gowa_webhook_secret" name="gowa_webhook_secret" data-mode="<%= gowaSettings.gowa_secret_mode %>" data-locked="1" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="otomatis dibuat">
<button type="button" class="btn btn-g" onclick="copyGowaSecret(this)" title="Salin webhook secret"><i class="bi bi-copy"></i></button>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
<button type="button" class="btn btn-p btn-sm" id="gsm-btn-save" onclick="gowaSecretSave(this)"><i class="bi bi-save"></i> Simpan Secret</button>
<button type="button" class="btn btn-g btn-sm" id="gsm-btn-edit" onclick="gowaSecretEdit()"><i class="bi bi-unlock"></i> Ubah</button>
</div>
<div id="gowa_secret_status" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Auto Generate: secret dibuat &amp; dikunci otomatis. Manual: isi sendiri lalu klik Simpan Secret. Samakan dengan <code>WHATSAPP_WEBHOOK_SECRET</code> di GOWA.</div>
</div>
</div>
</div>
@@ -218,6 +254,47 @@
</div>
<script>
async function testGowaConn(btn) {
var el = document.getElementById('gowa_conn_status');
var old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menguji...'; }
if (el) { el.style.display = 'block'; el.style.color = 'var(--muted)'; el.textContent = 'Menghubungi ' + (document.querySelector('input[name="gowa_base_url"]') || {}).value + ' ...'; }
try {
var payload = {
gowa_base_url: (document.querySelector('input[name="gowa_base_url"]') || {}).value || '',
gowa_username: (document.querySelector('input[name="gowa_username"]') || {}).value || '',
gowa_password: (document.querySelector('input[name="gowa_password"]') || {}).value || ''
};
var r = await fetch('/admin/whatsapp/test-gowa-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
var d = await r.json();
if (el) {
el.textContent = d.message || (d.ok ? 'GOWA terjangkau.' : 'GOWA tidak terjangkau.');
el.style.color = (d.ok && d.authOk) ? 'var(--success)' : (d.ok ? '#f59e0b' : 'var(--danger)');
}
} catch (e) {
if (el) { el.style.color = 'var(--danger)'; el.textContent = 'Gagal menguji koneksi: ' + e.message; }
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = old; }
}
}
function toggleGowaMode() {
var mode = (document.getElementById('gowa_mode') || {}).value || 'cloud';
var hint = document.getElementById('gowa_mode_hint');
var base = document.querySelector('input[name="gowa_base_url"]');
if (mode === 'local') {
if (hint) hint.innerHTML = 'Base URL <code>http://IP:3000</code>. UI GOWA wajib diberi polyfill <code>crypto.randomUUID</code> (atau akses lewat SSH tunnel). Panduan: <a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki/Konfigurasi" target="_blank" rel="noopener" style="color:var(--primary)">Wiki &rarr; Konfigurasi &rarr; GOWA Local</a>.';
if (base) base.placeholder = 'http://IP:3000';
} else {
if (hint) hint.innerHTML = 'Base URL <code>https://domain-gowa</code> dengan TLS. UI GOWA langsung normal, tanpa polyfill.';
if (base) base.placeholder = 'https://gowa.domainmu.com';
}
}
function toggleGatewayFields() {
const val = document.getElementById('wa_gateway_type').value;
document.getElementById('fonnte_fields_container').style.display = val === 'fonnte' ? 'block' : 'none';
@@ -228,12 +305,38 @@
// Cooldown QR disinkronkan dengan GoWA: interval mengikuti qr_duration dari server.
var _gowaQrRefresh = null; // penjadwal ambil QR berikutnya (timeout)
var _gowaQrTick = null; // interval hitung mundur 1 detik
var _gowaQrPoll = null; // pantau status setelah scan
function showGowaQr() {
var wrap = document.getElementById('gowa_qr_wrap');
if (wrap) wrap.style.display = 'block';
var st = document.getElementById('gowa_qr_status');
if (st) st.textContent = 'Memuat QR...';
if (st) { st.style.color = 'var(--muted)'; st.textContent = 'Memuat QR...'; }
gowaQrCycle();
startGowaPoll();
}
function stopGowaPoll() {
if (_gowaQrPoll) { clearInterval(_gowaQrPoll); _gowaQrPoll = null; }
}
function startGowaPoll() {
stopGowaPoll();
_gowaQrPoll = setInterval(async function () {
try {
var r = await fetch('/admin/api/whatsapp/status', { cache: 'no-store' });
var s = await r.json();
var st = document.getElementById('gowa_qr_status');
if (s && s.connection === 'open') {
if (_gowaQrRefresh) { clearTimeout(_gowaQrRefresh); _gowaQrRefresh = null; }
if (_gowaQrTick) { clearInterval(_gowaQrTick); _gowaQrTick = null; }
stopGowaPoll();
var img = document.getElementById('gowa_qr_img'); if (img) img.src = '';
var wrap = document.getElementById('gowa_qr_wrap'); if (wrap) wrap.style.display = 'none';
if (st) { st.style.color = 'var(--success)'; st.textContent = '✅ Terhubung ke WhatsApp.'; }
if (typeof checkStatus === 'function') checkStatus();
} else if (s && s.connection === 'connecting') {
if (st) { st.style.color = 'var(--warning, #f59e0b)'; st.textContent = 'Terautentikasi, sedang menyinkronkan...'; }
}
} catch (e) {}
}, 3000);
}
function gowaQrCycle() {
fetch('/admin/whatsapp/gowa/qr', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
@@ -257,6 +360,88 @@
render();
_gowaQrTick = setInterval(function () { remaining--; if (remaining < 0) remaining = 0; render(); }, 1000);
}
function gowaSecretRender() {
var input = document.getElementById('gowa_webhook_secret');
if (!input) return;
var mode = input.getAttribute('data-mode') || 'auto';
var locked = (mode === 'auto') ? true : (input.getAttribute('data-locked') === '1');
var save = document.getElementById('gsm-btn-save');
var edit = document.getElementById('gsm-btn-edit');
var auto = document.getElementById('gsm-btn-auto');
var manual = document.getElementById('gsm-btn-manual');
var st = document.getElementById('gowa_secret_status');
input.readOnly = locked;
if (save) save.disabled = (mode === 'auto') || locked;
if (edit) edit.style.display = (mode === 'manual' && locked) ? 'inline-block' : 'none';
if (auto) auto.className = 'btn btn-sm ' + (mode === 'auto' ? 'btn-p' : 'btn-g');
if (manual) manual.className = 'btn btn-sm ' + (mode === 'manual' ? 'btn-p' : 'btn-g');
if (st) {
if (mode === 'auto') st.textContent = 'Mode otomatis: secret dibuat dan dikunci. Salin ke GOWA.';
else if (locked) st.textContent = 'Mode manual: secret tersimpan dan dikunci. Klik Ubah untuk mengganti.';
else st.textContent = 'Mode manual: masukkan secret (min. 16 karakter), lalu Simpan Secret.';
}
}
async function gowaSecretMode(mode) {
var input = document.getElementById('gowa_webhook_secret');
if (!input) return;
try {
var r = await fetch('/admin/whatsapp/gowa-secret', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: mode })
});
var d = await r.json();
if (!d.ok) { alert(d.message || 'Gagal mengubah mode secret.'); return; }
if (d.secret) input.value = d.secret;
input.setAttribute('data-mode', d.mode);
input.setAttribute('data-locked', d.mode === 'manual' ? '0' : '1');
gowaSecretRender();
} catch (e) { alert('Gagal: ' + e.message); }
}
async function gowaSecretSave(btn) {
var input = document.getElementById('gowa_webhook_secret');
if (!input) return;
var secret = String(input.value || '').trim();
if (secret.length < 16) { alert('Secret minimal 16 karakter.'); return; }
var old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan...'; }
try {
var r = await fetch('/admin/whatsapp/gowa-secret', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'manual', secret: secret })
});
var d = await r.json();
if (!d.ok) { alert(d.message || 'Gagal menyimpan secret.'); return; }
input.value = d.secret || secret;
input.setAttribute('data-mode', 'manual');
input.setAttribute('data-locked', '1');
gowaSecretRender();
} catch (e) { alert('Gagal: ' + e.message); }
finally { if (btn) { btn.disabled = false; btn.innerHTML = old; } gowaSecretRender(); }
}
function gowaSecretEdit() {
var input = document.getElementById('gowa_webhook_secret');
if (!input || input.getAttribute('data-mode') !== 'manual') return;
input.setAttribute('data-locked', '0');
gowaSecretRender();
input.focus();
}
document.addEventListener('DOMContentLoaded', gowaSecretRender);
function copyGowaSecret(btn) {
var el = document.getElementById('gowa_webhook_secret');
if (!el) return;
var val = String(el.value || '');
try { el.select(); document.execCommand('copy'); } catch (e) {}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(val).catch(function () {});
}
if (btn) {
var old = btn.innerHTML;
btn.innerHTML = '<i class="bi bi-check2"></i>';
setTimeout(function () { btn.innerHTML = old; }, 1200);
}
}
function copyGowaWebhook() {
var el = document.getElementById('gowa_webhook_url');
if (!el) return;

View File

@@ -36,8 +36,8 @@
<h1 style="font-size:22px"><%= t('donate.title','Dukung OPEN-ISP') %></h1>
<p class="iso-sub"><%= settings.company_header || 'OPEN-ISP' %> • <%= t('donate.tagline','Donasi sukarela untuk pengembangan aplikasi open source (ISC)') %></p>
<div style="margin-top:14px;padding:12px 14px;border-radius:14px;background:rgba(173,255,47,.08);border:1px solid rgba(173,255,47,.25);text-align:left;font-size:12.5px;color:#cfe9a0">
<i class="bi bi-info-circle"></i> <strong><%= t('donate.menu_code_free','Kode aktivasi menu tersedia GRATIS') %></strong> <%= t('donate.free_hint','untuk semua — donasi') %> <strong><%= t('donate.not_required','tidak wajib') %></strong>.
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:#ADFF2F;font-weight:700;"> <%= t('donate.open_wiki','Buka Wiki') %></a>
<i class="bi bi-info-circle"></i> <strong><%= t('donate.menu_code_free','Semua menu sudah terbuka') %></strong> <%= t('donate.free_hint','— donasi bersifat sukarela dan') %> <strong><%= t('donate.not_required','tidak wajib') %></strong>.
<a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:#ADFF2F;font-weight:700;"> <%= t('donate.open_wiki','Buka Wiki') %></a>
</div>
</div>
@@ -70,10 +70,10 @@
</div>
<div class="field">
<label for="inputPhone"><i class="bi bi-whatsapp"></i> <%= t('donate.phone','Nomor WhatsApp * (untuk kode aktivasi)') %></label>
<label for="inputPhone"><i class="bi bi-whatsapp"></i> <%= t('donate.phone','Nomor WhatsApp') %></label>
<div class="wrap"><i class="bi bi-whatsapp lead-ic"></i>
<input type="tel" id="inputPhone" class="inp" placeholder="Contoh: 081234567890" required></div>
<div class="form-note"><%= t('donate.thanks_note','Ucapan terima kasih & kode aktivasi sidebar dikirim ke nomor ini.') %></div>
<div class="form-note"><%= t('donate.thanks_note','Ucapan terima kasih dikirim ke nomor ini.') %></div>
</div>
<div class="field">
@@ -105,7 +105,7 @@
<div id="paymentStatusBox" class="auth-alert info" style="justify-content:center;margin-top:14px">
<div style="text-align:center">
<div style="display:flex;align-items:center;justify-content:center;gap:8px"><div class="spin-pulse"></div><span style="font-weight:700" id="statusBoxText"><%= t('donate.waiting_payment','Menunggu Pembayaran Masuk…') %></span></div>
<div style="font-size:11.5px;margin-top:4px"><%= t('donate.auto_detect','Sistem otomatis mendeteksi transaksi & mengirim kode aktivasi ke WA Anda.') %></div>
<div style="font-size:11.5px;margin-top:4px"><%= t('donate.auto_detect','Sistem otomatis mendeteksi transaksi pembayaran.') %></div>
</div>
</div>
@@ -162,7 +162,7 @@
<span class="badge bp"><i class="bi bi-git"></i> <%= t('donate.code_free','KODE AKTIVASI GRATIS') %></span>
<span style="font-weight:700;font-size:14px"><%= t('donate.status','Status:') %> <span style="color:var(--warning);font-weight:800" id="statusText"><%= t('donate.waiting_donation','Menunggu Donasi Masuk') %></span></span>
</div>
<p class="muted" style="font-size:13.5px;margin:0"><i class="bi bi-info-circle" style="color:var(--info)"></i> <%= t('donate.menu_code','Kode aktivasi menu') %> <strong style="color:var(--primary)"><%= t('donate.free','GRATIS') %></strong> di <a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:var(--primary)"><%= t('donate.wiki','Wiki OPEN-ISP') %></a>. Donasi bersifat sukarela; jika berdonasi, kode juga otomatis dikirim ke WhatsApp Anda begitu pembayaran terdeteksi.</p>
<p class="muted" style="font-size:13.5px;margin:0"><i class="bi bi-info-circle" style="color:var(--info)"></i> <%= t('donate.menu_code','Kode aktivasi menu') %> <strong style="color:var(--primary)"><%= t('donate.free','GRATIS') %></strong> di <a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:var(--primary)"><%= t('donate.wiki','Wiki OPEN-ISP') %></a>. Donasi bersifat sukarela; jika berdonasi, kode juga otomatis dikirim ke WhatsApp Anda begitu pembayaran terdeteksi.</p>
</div>
<!-- AKTIVASI CARD -->
@@ -229,7 +229,7 @@
async function generateQris() {
if (isPaid) return;
var amt = parseInt(document.getElementById('inputAmount').value, 10) || 50000;
var phone = document.getElementById('inputPhone').value.trim() || '081947215703';
var phone = document.getElementById('inputPhone').value.trim();
var name = document.getElementById('inputName').value.trim() || 'Hamba Allah';
var notes = document.getElementById('inputNotes').value.trim();
try {

View File

@@ -315,7 +315,7 @@
</a>
<p class="muted" style="font-size:14px;max-width:40ch"><%= t('landing.footer.tagline','Micro Internet Provider dan Server Platform.') %></p>
<p class="muted" style="font-size:13px;margin-top:8px;max-width:40ch">Internet Freedom — We Are Open-Source.</p>
<p class="muted" style="font-size:12.5px;margin-top:12px"><i class="bi bi-building"></i> PT Artha Mega Data</p>
<p class="muted" style="font-size:12.5px;margin-top:12px"><% if (typeof settings !== 'undefined' && settings && settings.company_subheader) { %><%= settings.company_subheader %><% } else { %>Made with <i class="bi bi-heart-fill" style="color:#e5484d"></i> from Oxpoe in INA.<% } %></p>
</div>
<div>
<h4><%= t('landing.footer.nav','Navigasi') %></h4>

View File

@@ -31,6 +31,21 @@
</script>
</head>
<body>
<%
const _portal = (typeof settings !== 'undefined' && settings) ? settings : {};
const _pbColors = {
info: { bg: 'rgba(59,130,246,.16)', border: '#3b82f6', fg: '#bfdbfe' },
warning: { bg: 'rgba(245,158,11,.16)', border: '#f59e0b', fg: '#fde68a' },
danger: { bg: 'rgba(239,68,68,.16)', border: '#ef4444', fg: '#fecaca' },
success: { bg: 'rgba(34,197,94,.16)', border: '#22c55e', fg: '#bbf7d0' }
};
const _pb = _pbColors[_portal.portal_banner_type] || _pbColors.info;
%>
<% if (_portal.portal_banner_enabled && _portal.portal_banner_text) { %>
<div style="background:<%= _pb.bg %>;border-bottom:1px solid <%= _pb.border %>;color:<%= _pb.fg %>;padding:9px 16px;text-align:center;font-size:13px;line-height:1.45">
<i class="bi bi-megaphone-fill"></i> <strong><%= _portal.portal_banner_text %></strong>
</div>
<% } %>
<!-- ═══ HEADER ═══ -->
<header class="site-header">
@@ -69,9 +84,21 @@
<section class="auth-hero">
<div class="container auth-grid">
<div class="auth-copy reveal">
<span class="eyebrow"><i class="bi bi-shield-lock-fill"></i> <%= t('landing.footer.portal','Portal Pelanggan') %></span>
<span class="eyebrow"><i class="bi bi-shield-lock-fill"></i> <%= _portal.portal_hero_badge || t('landing.footer.portal','Portal Pelanggan') %></span>
<% if (_portal.portal_hero_title) { %>
<h1><%= _portal.portal_hero_title %></h1>
<% } else { %>
<h1><%= t('login.manage','Kelola layanan internet Anda') %> <span class="hl"><%= t('login.manage_hl','dalam satu tempat') %></span></h1>
<p class="lead"><%= t('login.manage_desc','Masuk untuk melihat tagihan, melakukan pembayaran, memantau koneksi, dan mengatur WiFi perangkat Anda.') %></p>
<% } %>
<p class="lead"><%= _portal.portal_hero_subtitle || t('login.manage_desc','Masuk untuk melihat tagihan, melakukan pembayaran, memantau koneksi, dan mengatur WiFi perangkat Anda.') %></p>
<% if (_portal.portal_network_status) {
const _ns = { normal: ['#22c55e', 'Jaringan Normal'], maintenance: ['#f59e0b', 'Pemeliharaan Terjadwal'], issue: ['#ef4444', 'Sedang Ada Kendala Teknis'] }[_portal.portal_network_status] || ['#22c55e', 'Jaringan Normal'];
%>
<div style="display:inline-flex;align-items:center;gap:7px;margin-top:10px;padding:5px 11px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);font-size:12px;color:var(--muted)">
<span style="width:8px;height:8px;border-radius:50%;background:<%= _ns[0] %>;display:inline-block"></span>
<%= _portal.portal_network_status_text || _ns[1] %>
</div>
<% } %>
<ul class="auth-points">
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.chk_desc','Cek & bayar tagihan kapan saja') %></li>
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.mon_desc','Pantau trafik dan status koneksi') %></li>
@@ -205,7 +232,15 @@
</a>
<p class="muted" style="font-size:14px;max-width:40ch">Micro Internet Provider dan Server Platform.</p>
<p class="muted" style="font-size:13px;margin-top:8px">Internet Freedom — We Are Open-Source.</p>
<p class="muted" style="font-size:12.5px;margin-top:12px"><i class="bi bi-building"></i> PT Artha Mega Data</p>
<p class="muted" style="font-size:12.5px;margin-top:12px"><% if (typeof settings !== 'undefined' && settings && settings.company_subheader) { %><%= settings.company_subheader %><% } else { %>Made with <i class="bi bi-heart-fill" style="color:#e5484d"></i> from Oxpoe in INA.<% } %></p>
<% if (_portal.social_maps_url || _portal.social_instagram || _portal.social_facebook || _portal.social_tiktok) { %>
<div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap;font-size:16px">
<% if (_portal.social_maps_url) { %><a href="<%= _portal.social_maps_url %>" target="_blank" rel="noopener" title="Maps"><i class="bi bi-geo-alt-fill"></i></a><% } %>
<% if (_portal.social_instagram) { %><a href="<%= _portal.social_instagram %>" target="_blank" rel="noopener" title="Instagram"><i class="bi bi-instagram"></i></a><% } %>
<% if (_portal.social_facebook) { %><a href="<%= _portal.social_facebook %>" target="_blank" rel="noopener" title="Facebook"><i class="bi bi-facebook"></i></a><% } %>
<% if (_portal.social_tiktok) { %><a href="<%= _portal.social_tiktok %>" target="_blank" rel="noopener" title="TikTok"><i class="bi bi-tiktok"></i></a><% } %>
</div>
<% } %>
</div>
<div>
<h4>Portal</h4>
@@ -230,7 +265,7 @@
</div>
<div class="foot-bottom">
<span>&copy; <span id="year"></span> <%= settings.company_header || 'OPEN-ISP' %>. Seluruh hak cipta dilindungi. <span style="opacity:.6">· v<%= appVersion %> (<%= appVersionName %>)</span></span>
<span class="mono">Fiber Optic Internet Service &middot; <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> &middot; <a href="#" onclick="hardResetApp();return false" style="opacity:.32;color:inherit;text-decoration:none" title="Bersihkan cache aplikasi">muat ulang aplikasi</a></span>
<span class="mono">tiawidianti &amp; irpankomara &middot; <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> &middot; <a href="#" onclick="hardResetApp();return false" style="opacity:.32;color:inherit;text-decoration:none" title="Bersihkan cache aplikasi">muat ulang aplikasi</a></span>
</div>
</div>
</footer>

View File

@@ -936,7 +936,7 @@
<i class="ph ph-user-circle nav-icon"></i><%= t('pwa.nav.account','Akun') %></button>
</nav>
<div class="p-3 mt-auto" style="border-top:1px solid var(--border);">
<div class="flex items-center gap-3 p-3 rounded-[14px] press cursor-pointer">
<button type="button" onclick="switchTab('profile')" class="flex items-center gap-3 p-3 rounded-[14px] press cursor-pointer w-full text-left" style="background:transparent;border:0;" aria-label="<%= t('pwa.nav.account','Akun') %>">
<div class="avatar-ring" style="width:38px;height:38px;padding:2px;">
<span class="avatar-init" style="width:34px;height:34px;font-size:13px;" role="img" aria-label="<%= portal.name %>"><%= portal.initials %></span>
</div>
@@ -945,7 +945,7 @@
<p class="text-[11px] truncate" style="color:var(--muted);"><%= portal.packageName %><%= portal.speedDown !== '-' ? ' · ' + portal.speedDown + ' Mbps' : '' %></p>
</div>
<i class="ph ph-caret-right text-sm" style="color:var(--muted-2);"></i>
</div>
</button>
</div>
</aside>
@@ -976,9 +976,9 @@
<i class="ph-fill ph-bell text-lg" style="color:var(--text);"></i>
<span class="notif-dot"<%= (portal.notifications && portal.notifications.length) ? '' : ' style="display:none"' %>></span>
</button>
<div class="mobile-only md:hidden avatar-ring" style="padding:2px;cursor:pointer;" onclick="switchTab('profile')">
<button type="button" class="mobile-only md:hidden avatar-ring press" style="padding:2px;cursor:pointer;background:transparent;border:0;" onclick="switchTab('profile')" aria-label="<%= t('pwa.nav.account','Akun') %>">
<span class="avatar-init" style="width:32px;height:32px;font-size:12px;" role="img" aria-label="<%= portal.name %>"><%= portal.initials %></span>
</div>
</button>
</div>
</div>
</header>