Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5361c3a1c1 | ||
|
|
094c47c15d | ||
|
|
0f7affc498 | ||
|
|
a2888810ad | ||
|
|
8fcb50e16a | ||
|
|
b72dbf3e55 | ||
|
|
6f6960ce42 | ||
|
|
fd46731a53 | ||
|
|
bf25260d4c | ||
|
|
973d9e3a14 | ||
|
|
848dd49cce | ||
|
|
ad62aab07d | ||
|
|
fadbf0084b | ||
|
|
8a312485a7 | ||
|
|
280a8ac9a6 | ||
|
|
1e3067989a | ||
|
|
7db4cb81a3 | ||
|
|
310e91ed97 | ||
|
|
f8e6e86619 | ||
|
|
b49822b553 | ||
|
|
b98ad7568d | ||
|
|
7a5b488882 | ||
|
|
e28c9debea | ||
|
|
861b8f6549 | ||
|
|
19db67138b | ||
|
|
7bb4ca1066 | ||
|
|
d68cc1d664 | ||
|
|
a78e6b766a | ||
|
|
fa791cd3cb | ||
|
|
34d4563a5f | ||
|
|
b34e5a34d8 | ||
|
|
bc7f2bdb09 | ||
|
|
8231058f79 | ||
|
|
fe7682fa45 | ||
|
|
7d77dd1424 | ||
|
|
eef690b809 | ||
|
|
c8b1335ff4 | ||
|
|
c906bca459 | ||
|
|
205db53d7e | ||
| 64e321a350 | |||
| 55f8368214 | |||
|
|
0ca85544aa | ||
|
|
f6ab9bbfa7 | ||
|
|
1cca67668e | ||
|
|
b612d5d232 | ||
|
|
5be6582c39 |
17
.env.gowa.example
Normal 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
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux-amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
178
CHANGELOG.md
@@ -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
|
||||
|
||||
58
README.md
@@ -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 & manajemen ISP (fiber optic) — open source, self-hosted.</b><br>
|
||||
<b>Sistem billing & manajemen ISP (fiber optic) — open source, self-hosted builtin AI.</b><br>
|
||||
PWA pelanggan · Panel admin · Portal teknisi & 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Inventaris & Stok Keluar | Pengaturan AI & Self-Healing |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
| Login Pelanggan | Dashboard Pelanggan |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
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 & 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 & 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`.
|
||||
|
||||
[](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.
|
||||
|
||||
160
app-customer.js
@@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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);
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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
@@ -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:
|
||||
@@ -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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 414 KiB |
BIN
docs/screenshots/01-landing.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 225 KiB |
BIN
docs/screenshots/02-admin-login.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 213 KiB |
BIN
docs/screenshots/03-admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 216 KiB |
BIN
docs/screenshots/04-admin-customers.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 189 KiB |
BIN
docs/screenshots/05-admin-inventory.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 119 KiB |
BIN
docs/screenshots/06-inventory-stok-keluar.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 158 KiB |
BIN
docs/screenshots/07-admin-settings-ai.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 321 KiB |
BIN
docs/screenshots/08-customer-login.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 181 KiB |
BIN
docs/screenshots/09-customer-dashboard.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 318 KiB |
BIN
docs/screenshots/10-mobile-customer-login.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB |
BIN
docs/screenshots/admin-billing.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB |
BIN
docs/screenshots/admin-customers.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB |
BIN
docs/screenshots/admin-login.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 55 KiB |
BIN
docs/screenshots/admin-olts.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB |
BIN
docs/screenshots/customer-billing.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 99 KiB |
BIN
docs/screenshots/customer-dashboard.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 66 KiB |
BIN
docs/screenshots/customer-login.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 83 KiB |
BIN
docs/screenshots/landing.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
@@ -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)); });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -48,7 +48,7 @@ function testIsAdminNumber(number) {
|
||||
// Test beberapa nomor
|
||||
const testNumbers = [
|
||||
'6281947215703',
|
||||
'081947215703',
|
||||
|
||||
'083807665697',
|
||||
'082218094778',
|
||||
'081234567890' // Nomor test yang bukan admin
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
136
services/serviceNoticeNotifyService.js
Normal 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 };
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.5.3
|
||||
0.6.8
|
||||
@@ -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
@@ -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 & 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > logs/run.log 2>&1 &</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>
|
||||
|
||||
@@ -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 → Perangkat Tertaut → Tautkan perangkat, scan QR. QR berlaku ~30 detik & 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 & 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 → Konfigurasi → 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>© <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 · <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> · <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 & irpankomara · <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> · <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>
|
||||
|
||||
@@ -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>
|
||||
|
||||