46 Commits
v0.5.3 ... main

Author SHA1 Message Date
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
82 changed files with 2345 additions and 238 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,184 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [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.8_isolir--url-ADFF2F" alt="Versi 0.6.8 (isolir-url-setting)">
<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
@@ -628,8 +643,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 +669,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 +902,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 +1010,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 +1084,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 +1131,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 +2010,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 +2101,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
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';
// Target dibaca ulang dari settings (bisa diubah dari dashboard tanpa restart).
function currentTarget() {
try {
const { getSetting } = require('./config/settingsManager');
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.8",
"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

@@ -705,7 +705,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');
}

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 fetch = _repo
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
const _base = String(getSetting('update_base_url', 'https://gitea.oxpoe.xyz') || '').trim().replace(/\/+$/, '');
const fetch = _repo
? 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']) {
@@ -4898,6 +5138,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 +6884,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 +6927,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 +6970,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 +6988,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 +7012,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 +7532,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 +7565,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 +7673,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 +7990,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,23 +113,19 @@ 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 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);
}
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);
}

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

@@ -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)) : '');
const res = await fetch(url, { headers: commonHeaders() });
const json = await res.json().catch(function () { return null; });
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 };
}
async function getLastQrImage() {
if (!_lastQr.link) return null;
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 res = await fetch(url, { headers: commonHeaders() });
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 };
}
}
// 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 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

@@ -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.8

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,27 +912,60 @@ 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');
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';
}
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';
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) {
openIsolirScriptModal(routerId);
switchIsolirTab('auto');

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">
@@ -640,9 +660,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 +825,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 +851,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 +1267,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

@@ -205,7 +205,7 @@
</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>
</div>
<div>
<h4>Portal</h4>
@@ -230,7 +230,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>