From eaf05f28119917ffbe8641a1a70ac358d275f6ae Mon Sep 17 00:00:00 2001 From: oxpoe Date: Fri, 9 Oct 2026 05:16:38 +0700 Subject: [PATCH] =?UTF-8?q?OPEN-ISP=20v0.5=20(pickle-lime)=20=E2=80=94=20r?= =?UTF-8?q?ilis=20open-source=20perdana?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp --- .dockerignore | 28 + .gitignore | 84 + Dockerfile | 34 + LICENSE | 16 + README.md | 108 + app-customer.js | 2240 +++++ config/customerTag.js | 296 + config/database.js | 1076 +++ config/genieacs-commands.js | 1156 +++ config/genieacs.js | 1195 +++ config/i18n.js | 50 + config/logger.js | 68 + config/settingsAudit.js | 245 + config/settingsEncryption.js | 179 + config/settingsManager.js | 354 + config/settingsValidator.js | 278 + config/sqliteSessionStore.js | 48 + docker-compose.yml | 39 + docs/ADMIN_SETTINGS_USAGE.md | 290 + docs/ANALISIS_SISTEM_DAN_UIUX.md | 274 + docs/AUDIT_FINDINGS_SUMMARY.md | 317 + docs/AUDIT_LENGKAP.md | 1374 +++ docs/AUDIT_SECURITY_2026.md | 833 ++ docs/AUDIT_SUMMARY.md | 458 + docs/BACKUP_CHECKLIST.md | 452 + docs/DATABASE_SCHEMA.md | 567 ++ docs/DOKUMENTASI_INDEX.md | 494 + docs/FITUR_APLIKASI_LENGKAP.md | 2269 +++++ docs/GEMINI.md | 19 + docs/IMPLEMENTATION_GUIDE_SECURITY.md | 757 ++ docs/MULTI_ROUTER_VERIFICATION.md | 279 + docs/OLT_OID_REFERENCE.md | 306 + docs/PANDUAN_PLAYSTORE.md | 207 + docs/QUICK_REFERENCE.md | 343 + docs/RADIUS_MIKROTIK_INTEGRATION.md | 359 + docs/README_AUDIT.txt | 346 + docs/RENCANA_DESAIN_DAN_MODERNISASI.md | 202 + docs/RINGKASAN_APLIKASI.md | 729 ++ docs/ROUTER_CONNECTION_FLOW.md | 251 + docs/RUJUKAN_SISTEM.md | 81 + docs/ui-ux.md | 98 + docs/update-code.md | 567 ++ ecosystem.config.js | 15 + env-example.txt | 1 + isolir-redirect-server.js | 44 + locales/en.json | 2801 ++++++ locales/id.json | 2802 ++++++ middleware/attendanceUpload.js | 55 + middleware/errorHandler.js | 277 + middleware/rateLimiter.js | 52 + middleware/settingsMiddleware.js | 155 + package-lock.json | 6880 ++++++++++++++ package.json | 62 + public/css/admin-theme.css | 721 ++ public/css/admin.css | 895 ++ public/css/auth.css | 215 + public/css/dashboard.css | 209 + public/css/isolated.css | 85 + public/css/landing.css | 346 + public/css/page-loader.css | 31 + public/css/pages.css | 28 + public/css/portal-theme.css | 134 + public/css/portal.css | 61 + public/css/service-notice-badge.css | 13 + public/css/theme-toggle.css | 16 + public/favicon.ico | Bin 0 -> 983 bytes public/img/apple-touch-icon.png | Bin 0 -> 3203 bytes public/img/halogen-icon.svg | 27 + public/img/icons/apple-touch-icon.png | Bin 0 -> 3203 bytes public/img/icons/icon-192.png | Bin 0 -> 3512 bytes public/img/icons/icon-512.png | Bin 0 -> 13996 bytes public/img/icons/icon-maskable-192.png | Bin 0 -> 3512 bytes public/img/icons/icon-maskable-512.png | Bin 0 -> 13996 bytes public/img/landing/server-1600.jpg | Bin 0 -> 72498 bytes public/img/landing/server-1600.webp | Bin 0 -> 18470 bytes public/img/logo-invoice.png | Bin 0 -> 13666 bytes public/img/logo.png | Bin 0 -> 13007 bytes public/img/openisp-logo.svg | 12 + public/img/pwa-192-maskable.png | Bin 0 -> 3512 bytes public/img/pwa-192.png | Bin 0 -> 3968 bytes public/img/pwa-512-maskable.png | Bin 0 -> 13996 bytes public/img/pwa-512.png | Bin 0 -> 15675 bytes public/img/pwa-icon.svg | 13 + public/js/admin-search.js | 246 + public/js/admin-sw-cleanup.js | 12 + public/js/admin-theme-sync.js | 104 + public/js/admin-toast.js | 185 + public/js/page-loader.js | 70 + public/js/service-notice-badge.js | 63 + public/js/theme-toggle.js | 61 + public/js/theme.js | 44 + public/manifest-landing.webmanifest | 18 + public/manifest.webmanifest | 17 + public/offline.html | 64 + public/speedtest/LICENSE | 165 + public/speedtest/index.html | 101 + public/speedtest/speedtest.js | 383 + public/speedtest/speedtest_worker.js | 734 ++ public/sw.js | 123 + .../bootstrap-icons/bootstrap-icons.min.css | 5 + .../fonts/bootstrap-icons.woff | Bin 0 -> 176032 bytes .../fonts/bootstrap-icons.woff2 | Bin 0 -> 130396 bytes .../vendor/bootstrap/bootstrap.bundle.min.js | 7 + public/vendor/bootstrap/bootstrap.min.css | 6 + routes/acsPortal.js | 1923 ++++ routes/adminPortal.js | 8423 +++++++++++++++++ routes/collectorPortal.js | 519 + routes/customerAPI.js | 3984 ++++++++ routes/customerPortal.js | 3741 ++++++++ routes/financePortal.js | 152 + routes/settingsAPI.js | 350 + routes/speedtest.js | 72 + routes/techPortal.js | 1287 +++ routes/voucherPaymentAPI.js | 250 + scripts/add-pppoe-manual-fields.sql | 5 + scripts/add-promo-slides-table.js | 65 + scripts/check-mikhmon-data.js | 36 + scripts/fix-database-columns.sql | 33 + scripts/fix-database.sh | 59 + scripts/migrate-add-attendance-photos.js | 49 + scripts/migrate-add-ticket-photos.js | 70 + scripts/olt-probe.js | 345 + scripts/restart-on-error.js | 92 + scripts/seed-demo.js | 129 + scripts/sync-onu-names.js | 56 + scripts/test-admin.js | 94 + scripts/test-message.js | 49 + scripts/test-mikhmon-parser.js | 141 + scripts/test-regex-ref.js | 63 + scripts/verify-database.js | 82 + services/acsServerService.js | 1842 ++++ services/adminService.js | 112 + services/agentService.js | 9 + services/areaService.js | 81 + services/arrearsNoticeService.js | 82 + services/attendanceService.js | 318 + services/auditTrailService.js | 223 + services/backupService.js | 413 + services/billingSendService.js | 148 + services/billingService.js | 814 ++ services/cronService.js | 833 ++ services/customerAuthTokenService.js | 90 + services/customerDeviceService.js | 1331 +++ services/customerIdService.js | 67 + services/customerService.js | 962 ++ services/diagnosticsService.js | 211 + services/discountService.js | 162 + services/financeReportPdfService.js | 137 + services/fonnteWhatsappService.js | 191 + services/gowaWhatsappService.js | 188 + services/iconService.js | 41 + services/imageOptimize.js | 61 + services/inventoryService.js | 171 + services/invoiceQrisService.js | 46 + services/mayarService.js | 77 + services/metaWhatsappService.js | 283 + services/mikrotikService.js | 2184 +++++ services/monitoringService.js | 422 + services/odpService.js | 124 + services/oltService.js | 2816 ++++++ services/onuProvisionService.js | 1393 +++ services/onuSyncService.js | 226 + services/packageProfileService.js | 165 + services/paymentService.js | 64 + services/payrollService.js | 449 + services/payslipPdfService.js | 106 + services/pdfBranding.js | 57 + services/pdfInvoiceService.js | 213 + services/radiusServerService.js | 812 ++ services/registrationService.js | 72 + services/serviceNoticeService.js | 120 + services/sidebarMenuService.js | 375 + services/techService.js | 113 + services/telegramBot.js | 1001 ++ services/ticketService.js | 111 + services/usageService.js | 46 + services/voucherCacheWarmer.js | 256 + services/voucherPaymentService.js | 314 + services/voucherPdfService.js | 73 + services/voucherService.js | 87 + services/waLidStore.js | 59 + services/waNotifyService.js | 148 + services/whatsappBot.mjs | 2668 ++++++ services/whatsappService.js | 432 + utils/mikhmonParser.js | 87 + utils/qrisUtil.js | 156 + utils/radiusPacket.js | 286 + utils/tokenUtil.js | 71 + version.txt | 1 + views/about.ejs | 76 + views/admin/acs.ejs | 1092 +++ views/admin/acs_device.ejs | 106 + views/admin/areas.ejs | 149 + views/admin/attendance.ejs | 442 + views/admin/audit_logs.ejs | 140 + views/admin/automation.ejs | 425 + views/admin/backup.ejs | 242 + views/admin/billing.ejs | 559 ++ views/admin/broadcast.ejs | 277 + views/admin/cashier_attendance.ejs | 742 ++ views/admin/cashier_reports.ejs | 108 + views/admin/cashiers.ejs | 148 + views/admin/collector_payments.ejs | 155 + views/admin/collectors.ejs | 241 + views/admin/customers.ejs | 2058 ++++ views/admin/dashboard.ejs | 1205 +++ views/admin/discounts.ejs | 250 + views/admin/finance/cash_in.ejs | 176 + views/admin/finance/expense_categories.ejs | 121 + views/admin/finance/expenses.ejs | 185 + views/admin/inventory.ejs | 310 + views/admin/login.ejs | 165 + views/admin/map-gis.ejs | 1220 +++ views/admin/map.ejs | 2309 +++++ views/admin/map_mobile.ejs | 569 ++ views/admin/mikrotik.ejs | 1682 ++++ views/admin/mikrotik_display.ejs | 1405 +++ views/admin/monitoring.ejs | 904 ++ views/admin/olts.ejs | 1185 +++ views/admin/onu_provision.ejs | 879 ++ views/admin/onu_stickers.ejs | 423 + views/admin/packages.ejs | 714 ++ views/admin/partials/group-tabs.ejs | 33 + views/admin/partials/sidebar.ejs | 243 + views/admin/partials/topbar.ejs | 93 + views/admin/payroll.ejs | 291 + views/admin/print_invoice.ejs | 748 ++ views/admin/print_payslip.ejs | 137 + views/admin/print_vouchers.ejs | 210 + views/admin/promo_slides.ejs | 432 + views/admin/radius-settings.ejs | 794 ++ views/admin/reports.ejs | 275 + views/admin/reports_print.ejs | 444 + views/admin/routers.ejs | 992 ++ views/admin/service_notice.ejs | 145 + views/admin/settings.ejs | 1178 +++ views/admin/sidebar_settings.ejs | 154 + views/admin/technicians.ejs | 153 + views/admin/tickets.ejs | 370 + views/admin/update.ejs | 86 + views/admin/vouchers.ejs | 634 ++ views/admin/vouchers_discounts.ejs | 158 + views/admin/vouchers_packages.ejs | 373 + views/admin/whatsapp.ejs | 468 + views/admin/whatsapp_live_chat.ejs | 374 + views/admin/whatsapp_preview.ejs | 84 + views/admin/whatsapp_templates.ejs | 280 + views/app_connect.ejs | 66 + views/blackhole.ejs | 43 + views/collector/attendance.ejs | 823 ++ views/collector/dashboard.ejs | 335 + views/collector/login.ejs | 43 + views/collector/print_thermal.ejs | 245 + views/contact.ejs | 81 + views/dashboard.ejs | 766 ++ views/donate.ejs | 328 + views/error.ejs | 80 + views/isolated.ejs | 259 + views/landing.ejs | 442 + views/login.ejs | 307 + views/login_otp.ejs | 114 + views/partials/customer_bottom_nav.ejs | 90 + views/partials/service-notice.ejs | 15 + views/portal.ejs | 2736 ++++++ views/privacy.ejs | 89 + views/public_check_billing.ejs | 226 + views/public_voucher.ejs | 203 + views/qris_static.ejs | 264 + views/register.ejs | 313 + views/tech/attendance.ejs | 657 ++ views/tech/create_customer.ejs | 155 + views/tech/dashboard.ejs | 135 + views/tech/history.ejs | 23 + views/tech/login.ejs | 43 + views/tech/map.ejs | 267 + views/tech/monitoring.ejs | 655 ++ views/tech/partials/bottom_nav.ejs | 12 + views/tech/partials/header.ejs | 41 + views/tech/pool.ejs | 39 + views/tos.ejs | 76 + 280 files changed, 123975 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app-customer.js create mode 100644 config/customerTag.js create mode 100644 config/database.js create mode 100644 config/genieacs-commands.js create mode 100644 config/genieacs.js create mode 100644 config/i18n.js create mode 100644 config/logger.js create mode 100644 config/settingsAudit.js create mode 100644 config/settingsEncryption.js create mode 100644 config/settingsManager.js create mode 100644 config/settingsValidator.js create mode 100644 config/sqliteSessionStore.js create mode 100644 docker-compose.yml create mode 100644 docs/ADMIN_SETTINGS_USAGE.md create mode 100644 docs/ANALISIS_SISTEM_DAN_UIUX.md create mode 100644 docs/AUDIT_FINDINGS_SUMMARY.md create mode 100644 docs/AUDIT_LENGKAP.md create mode 100644 docs/AUDIT_SECURITY_2026.md create mode 100644 docs/AUDIT_SUMMARY.md create mode 100644 docs/BACKUP_CHECKLIST.md create mode 100644 docs/DATABASE_SCHEMA.md create mode 100644 docs/DOKUMENTASI_INDEX.md create mode 100644 docs/FITUR_APLIKASI_LENGKAP.md create mode 100644 docs/GEMINI.md create mode 100644 docs/IMPLEMENTATION_GUIDE_SECURITY.md create mode 100644 docs/MULTI_ROUTER_VERIFICATION.md create mode 100644 docs/OLT_OID_REFERENCE.md create mode 100644 docs/PANDUAN_PLAYSTORE.md create mode 100644 docs/QUICK_REFERENCE.md create mode 100644 docs/RADIUS_MIKROTIK_INTEGRATION.md create mode 100644 docs/README_AUDIT.txt create mode 100644 docs/RENCANA_DESAIN_DAN_MODERNISASI.md create mode 100644 docs/RINGKASAN_APLIKASI.md create mode 100644 docs/ROUTER_CONNECTION_FLOW.md create mode 100644 docs/RUJUKAN_SISTEM.md create mode 100644 docs/ui-ux.md create mode 100644 docs/update-code.md create mode 100644 ecosystem.config.js create mode 100644 env-example.txt create mode 100644 isolir-redirect-server.js create mode 100644 locales/en.json create mode 100644 locales/id.json create mode 100644 middleware/attendanceUpload.js create mode 100644 middleware/errorHandler.js create mode 100644 middleware/rateLimiter.js create mode 100644 middleware/settingsMiddleware.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/css/admin-theme.css create mode 100644 public/css/admin.css create mode 100644 public/css/auth.css create mode 100644 public/css/dashboard.css create mode 100644 public/css/isolated.css create mode 100644 public/css/landing.css create mode 100644 public/css/page-loader.css create mode 100644 public/css/pages.css create mode 100644 public/css/portal-theme.css create mode 100644 public/css/portal.css create mode 100644 public/css/service-notice-badge.css create mode 100644 public/css/theme-toggle.css create mode 100644 public/favicon.ico create mode 100644 public/img/apple-touch-icon.png create mode 100644 public/img/halogen-icon.svg create mode 100644 public/img/icons/apple-touch-icon.png create mode 100644 public/img/icons/icon-192.png create mode 100644 public/img/icons/icon-512.png create mode 100644 public/img/icons/icon-maskable-192.png create mode 100644 public/img/icons/icon-maskable-512.png create mode 100644 public/img/landing/server-1600.jpg create mode 100644 public/img/landing/server-1600.webp create mode 100644 public/img/logo-invoice.png create mode 100644 public/img/logo.png create mode 100644 public/img/openisp-logo.svg create mode 100644 public/img/pwa-192-maskable.png create mode 100644 public/img/pwa-192.png create mode 100644 public/img/pwa-512-maskable.png create mode 100644 public/img/pwa-512.png create mode 100644 public/img/pwa-icon.svg create mode 100644 public/js/admin-search.js create mode 100644 public/js/admin-sw-cleanup.js create mode 100644 public/js/admin-theme-sync.js create mode 100644 public/js/admin-toast.js create mode 100644 public/js/page-loader.js create mode 100644 public/js/service-notice-badge.js create mode 100644 public/js/theme-toggle.js create mode 100644 public/js/theme.js create mode 100644 public/manifest-landing.webmanifest create mode 100644 public/manifest.webmanifest create mode 100644 public/offline.html create mode 100644 public/speedtest/LICENSE create mode 100644 public/speedtest/index.html create mode 100644 public/speedtest/speedtest.js create mode 100644 public/speedtest/speedtest_worker.js create mode 100644 public/sw.js create mode 100644 public/vendor/bootstrap-icons/bootstrap-icons.min.css create mode 100644 public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff create mode 100644 public/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 create mode 100644 public/vendor/bootstrap/bootstrap.bundle.min.js create mode 100644 public/vendor/bootstrap/bootstrap.min.css create mode 100644 routes/acsPortal.js create mode 100644 routes/adminPortal.js create mode 100644 routes/collectorPortal.js create mode 100644 routes/customerAPI.js create mode 100644 routes/customerPortal.js create mode 100644 routes/financePortal.js create mode 100644 routes/settingsAPI.js create mode 100644 routes/speedtest.js create mode 100644 routes/techPortal.js create mode 100644 routes/voucherPaymentAPI.js create mode 100644 scripts/add-pppoe-manual-fields.sql create mode 100644 scripts/add-promo-slides-table.js create mode 100644 scripts/check-mikhmon-data.js create mode 100644 scripts/fix-database-columns.sql create mode 100644 scripts/fix-database.sh create mode 100644 scripts/migrate-add-attendance-photos.js create mode 100644 scripts/migrate-add-ticket-photos.js create mode 100644 scripts/olt-probe.js create mode 100644 scripts/restart-on-error.js create mode 100644 scripts/seed-demo.js create mode 100644 scripts/sync-onu-names.js create mode 100644 scripts/test-admin.js create mode 100644 scripts/test-message.js create mode 100644 scripts/test-mikhmon-parser.js create mode 100644 scripts/test-regex-ref.js create mode 100644 scripts/verify-database.js create mode 100644 services/acsServerService.js create mode 100644 services/adminService.js create mode 100644 services/agentService.js create mode 100644 services/areaService.js create mode 100644 services/arrearsNoticeService.js create mode 100644 services/attendanceService.js create mode 100644 services/auditTrailService.js create mode 100644 services/backupService.js create mode 100644 services/billingSendService.js create mode 100644 services/billingService.js create mode 100644 services/cronService.js create mode 100644 services/customerAuthTokenService.js create mode 100644 services/customerDeviceService.js create mode 100644 services/customerIdService.js create mode 100644 services/customerService.js create mode 100644 services/diagnosticsService.js create mode 100644 services/discountService.js create mode 100644 services/financeReportPdfService.js create mode 100644 services/fonnteWhatsappService.js create mode 100644 services/gowaWhatsappService.js create mode 100644 services/iconService.js create mode 100644 services/imageOptimize.js create mode 100644 services/inventoryService.js create mode 100644 services/invoiceQrisService.js create mode 100644 services/mayarService.js create mode 100644 services/metaWhatsappService.js create mode 100644 services/mikrotikService.js create mode 100644 services/monitoringService.js create mode 100644 services/odpService.js create mode 100644 services/oltService.js create mode 100644 services/onuProvisionService.js create mode 100644 services/onuSyncService.js create mode 100644 services/packageProfileService.js create mode 100644 services/paymentService.js create mode 100644 services/payrollService.js create mode 100644 services/payslipPdfService.js create mode 100644 services/pdfBranding.js create mode 100644 services/pdfInvoiceService.js create mode 100644 services/radiusServerService.js create mode 100644 services/registrationService.js create mode 100644 services/serviceNoticeService.js create mode 100644 services/sidebarMenuService.js create mode 100644 services/techService.js create mode 100644 services/telegramBot.js create mode 100644 services/ticketService.js create mode 100644 services/usageService.js create mode 100644 services/voucherCacheWarmer.js create mode 100644 services/voucherPaymentService.js create mode 100644 services/voucherPdfService.js create mode 100644 services/voucherService.js create mode 100644 services/waLidStore.js create mode 100644 services/waNotifyService.js create mode 100644 services/whatsappBot.mjs create mode 100644 services/whatsappService.js create mode 100644 utils/mikhmonParser.js create mode 100644 utils/qrisUtil.js create mode 100644 utils/radiusPacket.js create mode 100644 utils/tokenUtil.js create mode 100644 version.txt create mode 100644 views/about.ejs create mode 100644 views/admin/acs.ejs create mode 100644 views/admin/acs_device.ejs create mode 100644 views/admin/areas.ejs create mode 100644 views/admin/attendance.ejs create mode 100644 views/admin/audit_logs.ejs create mode 100644 views/admin/automation.ejs create mode 100644 views/admin/backup.ejs create mode 100644 views/admin/billing.ejs create mode 100644 views/admin/broadcast.ejs create mode 100644 views/admin/cashier_attendance.ejs create mode 100644 views/admin/cashier_reports.ejs create mode 100644 views/admin/cashiers.ejs create mode 100644 views/admin/collector_payments.ejs create mode 100644 views/admin/collectors.ejs create mode 100644 views/admin/customers.ejs create mode 100644 views/admin/dashboard.ejs create mode 100644 views/admin/discounts.ejs create mode 100644 views/admin/finance/cash_in.ejs create mode 100644 views/admin/finance/expense_categories.ejs create mode 100644 views/admin/finance/expenses.ejs create mode 100644 views/admin/inventory.ejs create mode 100644 views/admin/login.ejs create mode 100644 views/admin/map-gis.ejs create mode 100644 views/admin/map.ejs create mode 100644 views/admin/map_mobile.ejs create mode 100644 views/admin/mikrotik.ejs create mode 100644 views/admin/mikrotik_display.ejs create mode 100644 views/admin/monitoring.ejs create mode 100644 views/admin/olts.ejs create mode 100644 views/admin/onu_provision.ejs create mode 100644 views/admin/onu_stickers.ejs create mode 100644 views/admin/packages.ejs create mode 100644 views/admin/partials/group-tabs.ejs create mode 100644 views/admin/partials/sidebar.ejs create mode 100644 views/admin/partials/topbar.ejs create mode 100644 views/admin/payroll.ejs create mode 100644 views/admin/print_invoice.ejs create mode 100644 views/admin/print_payslip.ejs create mode 100644 views/admin/print_vouchers.ejs create mode 100644 views/admin/promo_slides.ejs create mode 100644 views/admin/radius-settings.ejs create mode 100644 views/admin/reports.ejs create mode 100644 views/admin/reports_print.ejs create mode 100644 views/admin/routers.ejs create mode 100644 views/admin/service_notice.ejs create mode 100644 views/admin/settings.ejs create mode 100644 views/admin/sidebar_settings.ejs create mode 100644 views/admin/technicians.ejs create mode 100644 views/admin/tickets.ejs create mode 100644 views/admin/update.ejs create mode 100644 views/admin/vouchers.ejs create mode 100644 views/admin/vouchers_discounts.ejs create mode 100644 views/admin/vouchers_packages.ejs create mode 100644 views/admin/whatsapp.ejs create mode 100644 views/admin/whatsapp_live_chat.ejs create mode 100644 views/admin/whatsapp_preview.ejs create mode 100644 views/admin/whatsapp_templates.ejs create mode 100644 views/app_connect.ejs create mode 100644 views/blackhole.ejs create mode 100644 views/collector/attendance.ejs create mode 100644 views/collector/dashboard.ejs create mode 100644 views/collector/login.ejs create mode 100644 views/collector/print_thermal.ejs create mode 100644 views/contact.ejs create mode 100644 views/dashboard.ejs create mode 100644 views/donate.ejs create mode 100644 views/error.ejs create mode 100644 views/isolated.ejs create mode 100644 views/landing.ejs create mode 100644 views/login.ejs create mode 100644 views/login_otp.ejs create mode 100644 views/partials/customer_bottom_nav.ejs create mode 100644 views/partials/service-notice.ejs create mode 100644 views/portal.ejs create mode 100644 views/privacy.ejs create mode 100644 views/public_check_billing.ejs create mode 100644 views/public_voucher.ejs create mode 100644 views/qris_static.ejs create mode 100644 views/register.ejs create mode 100644 views/tech/attendance.ejs create mode 100644 views/tech/create_customer.ejs create mode 100644 views/tech/dashboard.ejs create mode 100644 views/tech/history.ejs create mode 100644 views/tech/login.ejs create mode 100644 views/tech/map.ejs create mode 100644 views/tech/monitoring.ejs create mode 100644 views/tech/partials/bottom_nav.ejs create mode 100644 views/tech/partials/header.ejs create mode 100644 views/tech/pool.ejs create mode 100644 views/tos.ejs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..723daec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# ── Data pribadi & rahasia: JANGAN masuk image ── +node_modules +.git +.gitignore +settings.json +settings.json.* +database/billing.db* +database.sqlite* +*.sqlite* +logs +*.log +public/uploads +uploads +auth_info_baileys +data +scratch +tmp +docker-data +backups +billing-backups +whatsapp_storages +whatsapp_statics +tailadmin-free-tailwind-dashboard-template +*.bak* +*.aab +*.apk +*.zip +*.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3291ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,84 @@ +# Dependencies +node_modules/ + +# Environment and Secrets +.env +*.env +*.jks +*.keystore +local.properties + +# Database files +database/*.db +database/*.db-shm +database/*.db-wal +database/*.sqlite +database.sqlite +*.db +*.db-shm +*.db-wal + +# WhatsApp Sessions & Auth +auth_info_baileys/ +data/wwebjs_auth/ +data/test_wwebjs_auth/ +data/wa-lid-map.json +.wwebjs_auth/ +.wwebjs_cache/ + +# Android Build & Caches +.gradle/ +.kotlin/ +build/ +android-app/.gradle/ +android-app/.kotlin/ +android-app/app/build/ +android-app/build/ +android-app/local.properties + +# Logs +logs/ +*.log +npm-debug.log* + +# Backups and temporary files +backups/ +scratch/ +scratch_tz_scan.json +temp/ +tmp/ + +# OS / IDE +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.pptx + +# Settings & data lokal (JANGAN pernah di-commit) +settings.json +settings.json.* +*.sqlite +*.sqlite-shm +*.sqlite-wal +database/billing.db* +public/uploads/ +billing-backups/ +docker-data/ +.e2e/ +*.aab +*.apk +tailadmin-free-tailwind-dashboard-template/ +ui-preview.html +auth_info_baileys* +*.bak-* +*.old +update_loader_time.js +update_loader_logic.js +update.sh +public/css/tabler-icons/ +public/downloads/*.apk +public/downloads/*.aab +public/downloads/logo-* +.npmrc +.gitconfig diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..622e515 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# ───────────────────────────────────────────────────────────────────────────── +# OPEN-ISP — Dockerfile (produksi) +# Build: docker compose build +# Run: docker compose up -d +# ───────────────────────────────────────────────────────────────────────────── +FROM node:22-bookworm-slim + +ENV NODE_ENV=production \ + TZ=Asia/Jakarta + +# tzdata: zona waktu · curl: healthcheck · ca-certificates: HTTPS keluar (WhatsApp/dll) +RUN apt-get update && apt-get install -y --no-install-recommends \ + tzdata curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Manifest dulu → cache layer dependensi +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund + +# Sisa aplikasi (lihat .dockerignore — data pribadi TIDAK ikut ke image) +COPY . . + +# Folder runtime (di-mount oleh docker-compose) +RUN mkdir -p /app/logs /app/public/uploads /app/data /app/database + +EXPOSE 3001 + +# Healthcheck ringan via /version.txt +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD curl -fsS http://127.0.0.1:3001/version.txt > /dev/null || exit 1 + +CMD ["node", "app-customer.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..290f442 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +ISC License + +Copyright (c) 2026, Alijaya Net +Copyright (c) 2026 oxpoe (OPEN-ISP) + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1082b5 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +

+ OPEN-ISP +

+ +

+ Sistem billing & manajemen ISP (fiber optic) — open source, self-hosted.
+ PWA pelanggan · Panel admin · Portal teknisi & kolektor · Multi-OLT · RADIUS · WhatsApp/Telegram · QRIS +

+ +

+ Lisensi: ISC + Versi 0.5 (pickle-lime) + Node.js >= 20 +

+ +--- + +**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. + +## Fitur + +- **Billing & pembayaran** — tagihan bulanan otomatis (cron tanggal 1), promo & prorata per paket, kode unik/QRIS, konfirmasi bukti transfer, webhook notifikasi pembayaran, cetak invoice, bayar tunggal/massal, void/unpay. +- **Otomatisasi** — isolir otomatis (harian, per pelanggan + hari isolir), pengingat tagihan via WhatsApp, jam kalong (profil malam), FUP (turun profil saat kuota habis), sinkron pemakaian PPPoE berkala. +- **MikroTik** — multi-router, PPPoE & hotspot, voucher hotspot, monitor trafik, tes koneksi, setup firewall, backup konfigurasi, dukungan RouterOS 7 (API + TLS). +- **OLT PON multi-vendor** — Hioso & HSGQ (SNMP), ZTE & Huawei (CLI/Telnet): statistik ONU, reboot/rename/otorisasi, konfigurasi WAN/OMCI. Tersedia `scripts/olt-probe.js` untuk mendeteksi OLT baru. +- **TR-069 (GenieACS/ACS)** — daftar perangkat, ubah SSID/password Wi-Fi, reboot, operasi massal. +- **RADIUS** — server PAP/CHAP untuk PPPoE/hotspot, secret per-NAS. +- **Peta & GIS** — Leaflet + OpenStreetMap/satelit: marker pelanggan & ODP, jalur kabel, trafik PPPoE real-time; peta teknisi + rute Google Maps. +- **Portal & peran** — admin (super admin/admin/kasir), teknisi (tiket, input pelanggan dari lapangan), kolektor (cek tagihan & pengajuan pembayaran), dan **PWA pelanggan** (tagihan, bayar, tiket, SSID/password, speed test, mode offline). +- **Notifikasi** — WhatsApp (gateway GoWA atau bot Baileys bawaan): broadcast, pengingat otomatis, struk pembayaran; bot Telegram admin (opsional). +- **Speed test** — server internal maupun eksternal (preset provider diatur admin), mode embed/tab, tombol Muat Ulang & Bersihkan Cache. +- **PWA** — dapat dipasang di HP, offline, pembaruan otomatis (bar "versi baru tersedia"). +- **Bilingual** — antarmuka Indonesia / English (bisa diganti dari UI). +- **Lain-lain** — inventaris gudang, tiket dukungan, laporan, monitoring sistem (CPU/RAM/disk), ODP, backup/restore database, audit log. + +## Mulai Cepat + +### Docker (disarankan) + +```bash +git clone https://github.com/oxpoe/open-isp.git +cd open-isp +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). + +### Manual (Node.js ≥ 20, disarankan 22 LTS) + +```bash +npm ci --omit=dev +npm start +``` + +Opsional: `cp env-example.txt .env` untuk mengisi rahasia webhook pembayaran. + +### Data demo (opsional) + +```bash +node scripts/seed-demo.js +``` + +Menambahkan paket, area, 12 pelanggan contoh, tagihan, dan tiket. Login pelanggan demo: `081200000001` / `demo-isp123`. + +## Aktivasi Menu Lanjutan + +Beberapa menu lanjutan terkunci secara bawaan dan bisa dibuka dengan memasukkan kode aktivasi. +Aktivasi bersifat **gratis** (tanpa donasi). Demi keamanan, kode aktivasi **tidak dipublikasikan +di repositori ini** — setiap pemilik instalasi dapat mengaturnya di `settings.json` +(kunci `activation_code`) dan membagikannya melalui kanalnya masing-masing. +Dukungan sukarela tetap diterima lewat menu **Dukung OPEN-ISP** di aplikasi. + +## Dokumentasi + +- [`docs/`](docs/) — referensi teknis (skema database, OID OLT, integrasi RADIUS/MikroTik, checklist backup, dll.). +- [Wiki](https://github.com/oxpoe/open-isp/wiki) — panduan instalasi, konfigurasi, tutorial, FAQ (sedang disusun). +- [Issues](https://github.com/oxpoe/open-isp/issues) — laporan bug & permintaan fitur. + +## Teknologi + +Node.js · Express · EJS · better-sqlite3 (SQLite) · Bootstrap 5 + Bootstrap Icons (vendor lokal) · Leaflet · PWA/Service Worker · sharp · net-snmp · MikroTik RouterOS API · Baileys / GoWA · Telegram Bot API. + +## Lisensi & Kredit + +Dirilis dengan lisensi **ISC** — bebas digunakan, dimodifikasi, dan didistribusikan ulang (sertakan lisensi). Lihat [`LICENSE`](LICENSE). + +- **Base kode & repo**: [alijayanet/billing-rtrw](https://github.com/alijayanet/billing-rtrw) — dibuat oleh **Alijaya Net**. Terima kasih banyak atas base kode dan repo yang bermanfaat. +- **Add-on**: GoWA (WhatsApp gateway), LibreSpeed & OpenSpeedTest (speed test), Uptime Kuma (monitoring, opsional), notification-forwarder. +- **Dikembangkan oleh** [oxpoe](https://github.com/oxpoe) — **code by DeepSeek V4.1 Flash — max, on opencode**. +- Terima kasih untuk para kontributor dan komunitas ISP Indonesia. + +## Dukungan & Donasi + +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) + +Dukungan untuk **pengembangan OPEN-ISP** (agar makin canggih): link donasi **menyusul** — sementara ini bisa lewat menu **Dukung OPEN-ISP** di aplikasi. + +--- + +## English (quick) + +**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. + +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. diff --git a/app-customer.js b/app-customer.js new file mode 100644 index 0000000..b20b081 --- /dev/null +++ b/app-customer.js @@ -0,0 +1,2240 @@ +const express = require('express'); +const compression = require('compression'); +const path = require('path'); +const fs = require('fs'); +const dns = require('dns'); +require('dotenv').config({ path: path.join(__dirname, '.env') }); +const crypto = require('crypto'); +const multer = require('multer'); +const QRCode = require('qrcode'); +const _jimpMod = require('jimp'); +const Jimp = _jimpMod.Jimp || _jimpMod; +const qrisUtil = require('./utils/qrisUtil'); +const { logger } = require('./config/logger'); +const db = require('./config/database'); +const customerSvc = require('./services/customerService'); +const noticeSvc = require('./services/serviceNoticeService'); +const billingSvc = require('./services/billingService'); +const whatsappService = require('./services/whatsappService'); +const mikrotikService = require('./services/mikrotikService'); +const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); +const { scheduleAutoBackup } = require('./services/backupService'); + +// Prefer IPv4 to avoid AggregateError (IPv6 timeouts) on some servers +if (dns.setDefaultResultOrder) { + dns.setDefaultResultOrder('ipv4first'); +} + +// Handle unhandled promise rejections to prevent silent crashes +process.on('unhandledRejection', (reason, promise) => { + const errorMsg = reason instanceof Error ? reason.stack : JSON.stringify(reason); + logger.error(`Unhandled Rejection: ${errorMsg}`); +}); + +// Handle uncaught exceptions to prevent server crashes from external service failures +// (e.g. ros-client throws uncaught errors when MikroTik router is unreachable) +process.on('uncaughtException', (err) => { + const errorMsg = err instanceof Error ? err.stack : String(err); + logger.error(`uncaughtException: ${errorMsg}`); + // Don't exit process — keep server running despite transient connection errors +}); + +// Settings Management +const session = require('express-session'); +const createSqliteSessionStore = require('./config/sqliteSessionStore'); +const { getSetting, getSettingsWithCache, ensureDefaultSettings, getNowLocal, formatDateLocal, formatTimeLocal, parseDateInTimezone } = require('./config/settingsManager'); +const { SUPPORTED_LANGS, FALLBACK_LANG, normalizeLang, t } = require('./config/i18n'); + +// Pastikan semua default settings ada (untuk migrasi/update dari GitHub) +if (typeof ensureDefaultSettings === 'function') { + ensureDefaultSettings(); +} + +// Inisialisasi aplikasi Express +const app = express(); + +// Kompresi gzip (performa: respons HTML/CSS/JS lebih kecil) +app.use(compression()); + +const isProduction = process.env.NODE_ENV === 'production'; +const cookieSecure = getSetting('cookie_secure', isProduction); +const trustProxySetting = getSetting('trust_proxy', true); +app.set('trust proxy', trustProxySetting ? 1 : true); + +// Middleware dasar +app.use(express.json({ + limit: '1mb', + verify: (req, res, buf) => { + req.rawBody = buf?.toString('utf8') || ''; + } +})); +app.use(express.urlencoded({ + extended: true, + limit: '1mb', + verify: (req, res, buf) => { + req.rawBody = buf?.toString('utf8') || ''; + } +})); +app.use(express.text({ + type: (req) => { + const contentType = req.headers['content-type'] || ''; + if (contentType.includes('multipart/form-data')) return false; + if (contentType.includes('application/x-www-form-urlencoded')) return false; + if (contentType.includes('application/json')) return false; + if (contentType.includes('application/octet-stream')) return false; // speed test (streaming upload) + return true; + }, + limit: '1mb', + verify: (req, res, buf) => { + req.rawBody = buf?.toString('utf8') || ''; + } +})); +app.use(session({ + store: createSqliteSessionStore(db), + secret: getSetting('session_secret', 'rahasia-portal-pelanggan-default-ganti-ini'), + resave: false, + saveUninitialized: false, + rolling: true, + cookie: { + secure: Boolean(cookieSecure), + httpOnly: true, + sameSite: 'lax', + maxAge: 30 * 24 * 60 * 60 * 1000, + path: '/' + }, + name: 'customer.sid' +})); + +// Middleware Proteksi CSRF berbasis Referer/Origin (Aman untuk production tanpa merubah EJS) +app.use((req, res, next) => { + const method = req.method; + if (['POST', 'PUT', 'DELETE'].includes(method)) { + const origin = req.headers.origin; + const referer = req.headers.referer; + const host = req.headers.host; + + // Kecualikan webhook eksternal, ACS server TR-069, atau payment gateway callback + const isWebhook = req.path.startsWith('/api/webhook') || req.path.startsWith('/webhook') || req.path === '/customer/payment/callback'; + const isAcs = req.path.startsWith('/acs'); + if (isWebhook || isAcs) { + return next(); + } + + try { + if (origin) { + const originHost = new URL(origin).host; + if (originHost !== host) { + logger.warn(`[CSRF] Blocked request from unauthorized origin: ${origin} (host: ${host})`); + return res.status(403).json({ error: 'Forbidden - Invalid Origin (CSRF Protection)' }); + } + } else if (referer) { + const refererHost = new URL(referer).host; + if (refererHost !== host) { + logger.warn(`[CSRF] Blocked request from unauthorized referer: ${referer} (host: ${host})`); + return res.status(403).json({ error: 'Forbidden - Invalid Referer (CSRF Protection)' }); + } + } + } catch (e) { + logger.error(`[CSRF] Parsing referer/origin failed: ${e.message}`); + return res.status(403).json({ error: 'Forbidden - Invalid Referer/Origin Format' }); + } + } + next(); +}); + +// i18n middleware (aman: hanya teks UI, tidak mengubah logic fitur) +app.use((req, res, next) => { + if (req.query && typeof req.query.lang === 'string') { + const requested = normalizeLang(req.query.lang); + req.session.lang = requested; + } + const saved = req.session?.lang || getSetting('default_lang', FALLBACK_LANG); + const lang = normalizeLang(saved); + const tz = getSetting('timezone', 'Asia/Jakarta'); + res.locals.lang = lang; + res.locals.availableLangs = Array.from(SUPPORTED_LANGS); + res.locals.t = (key, fallback = '') => t(lang, key, fallback); + res.locals.tz = tz; + res.locals.brandName = getSetting('company_header', 'OPEN-ISP'); + res.locals.brandLogo = (getSetting('logo_portal', '') || getSetting('company_logo', '') || '/img/logo.png') + (getSetting('logo_version', '') ? ('?v=' + getSetting('logo_version', '')) : ''); + res.locals.formatDateLocal = formatDateLocal; + res.locals.formatTimeLocal = formatTimeLocal; + res.locals.parseDateInTimezone = parseDateInTimezone; + res.locals.getNowLocal = getNowLocal; + 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://github.com/oxpoe/open-isp') || '').replace(/\/+$/, ''); + res.locals.appVersion = String(getSetting('app_version', '0.5') || '0.5'); + res.locals.appVersionName = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime'); + res.locals.pwaVersion = String(getSetting('pwa_version', '2.0') || '2.0'); + next(); +}); + +// ── Page loader (animasi transisi halaman) — injeksi non-invasif ke semua HTML ── +app.use((req, res, next) => { + const _send = res.send.bind(res); + res.send = function (body) { + try { + if (typeof body === 'string' && body.indexOf('') !== -1 && body.indexOf('/css/page-loader.css') === -1) { + body = body.replace('', ' \n \n'); + } + } catch (e) {} + return _send(body); + }; + next(); +}); + +// Hidden staff portal (Blackhole) — pintu akses login staf dari portal pelanggan +app.get('/blackhole', (req, res) => { + res.set('X-Robots-Tag', 'noindex, nofollow'); + res.render('blackhole', { title: 'Blackhole', company: getSetting('company_header', 'OPEN-ISP'), settings: getSettingsWithCache() }); +}); + +app.get('/lang/:lang', (req, res) => { + const targetLang = normalizeLang(req.params.lang); + req.session.lang = targetLang; + // Aman: saat Service Worker aktif, referer bisa berupa /sw.js — jangan redirect mentah. + const host = `${req.protocol}://${req.get('host')}`; + let target = '/'; + try { + const ref = req.get('referer') || ''; + if (ref) { + const u = new URL(ref, host); + if (u.origin === host && !/\/sw\.js$/i.test(u.pathname)) target = u.pathname + u.search; + } + } catch (e) {} + return res.redirect(target); +}); + +// Konstanta +const VERSION = '2.0.0'; + +const insertWebhookPaymentNotif = db.prepare(` + INSERT INTO webhook_payment_notifs (service, content, parsed_amount, parsed_ok, ip, user_agent) + VALUES (?, ?, ?, ?, ?, ?) +`); + +const updateWebhookPaymentNotifMatchInvoice = db.prepare(` + UPDATE webhook_payment_notifs + SET matched_invoice_id = ? + WHERE id = ? +`); + +const updateWebhookPaymentNotifMatchVoucher = db.prepare(` + UPDATE webhook_payment_notifs + SET matched_voucher_order_id = ? + WHERE id = ? +`); + +const selectInvoiceByUniqueAmount = db.prepare(` + SELECT i.id, i.customer_id, i.status, i.amount, i.qris_amount_unique, i.qris_unique_code, i.notes, + c.status as customer_status + FROM invoices i + JOIN customers c ON c.id = i.customer_id + WHERE i.status = 'unpaid' AND i.qris_amount_unique = ? + ORDER BY i.id DESC + LIMIT 2 +`); + +const selectPaidInvoiceByUniqueAmount = db.prepare(` + SELECT i.id, i.customer_id, i.status, i.amount, i.qris_amount_unique, i.paid_at + FROM invoices i + WHERE i.status = 'paid' AND i.qris_amount_unique = ? + ORDER BY i.id DESC + LIMIT 2 +`); + +const selectVoucherOrderByUniqueAmount = db.prepare(` + SELECT id, status, profile_name, validity, buyer_phone + FROM public_voucher_orders + WHERE status = 'pending' AND qris_amount_unique = ? + ORDER BY id DESC + LIMIT 2 +`); + +const markVoucherPaid = db.prepare(` + UPDATE public_voucher_orders + SET status='paid', + paid_at=NOW_LOCAL(), + qris_paid_notif_id=?, + updated_at=NOW_LOCAL() + WHERE id=? +`); + +const selectVoucherOrderById = db.prepare(`SELECT * FROM public_voucher_orders WHERE id = ?`); +const markVoucherFulfilled = db.prepare(` + UPDATE public_voucher_orders + SET status='fulfilled', + fulfilled_at=NOW_LOCAL(), + voucher_code=?, + voucher_password=?, + voucher_comment=?, + updated_at=NOW_LOCAL() + WHERE id=? +`); +const markVoucherWaSentOk = db.prepare(` + UPDATE public_voucher_orders + SET wa_sent=1, wa_sent_at=NOW_LOCAL(), wa_error='', updated_at=NOW_LOCAL() + WHERE id=? +`); +const markVoucherWaSentErr = db.prepare(` + UPDATE public_voucher_orders + SET wa_sent=0, wa_error=?, updated_at=NOW_LOCAL() + WHERE id=? +`); + +const selectDonationOrderByUniqueAmount = db.prepare(` + SELECT id, status, donor_name, donor_phone, amount, qris_amount_unique, qris_unique_code, notes, activation_code + FROM public_donation_orders + WHERE status = 'pending' AND qris_amount_unique = ? + ORDER BY id DESC + LIMIT 2 +`); + +const markDonationPaid = db.prepare(` + UPDATE public_donation_orders + SET status='paid', + paid_at=NOW_LOCAL(), + qris_paid_notif_id=?, + updated_at=NOW_LOCAL() + WHERE id=? +`); + +const selectDonationOrderById = db.prepare(`SELECT * FROM public_donation_orders WHERE id = ?`); +const markDonationWaSentOk = db.prepare(` + UPDATE public_donation_orders + SET wa_sent=1, wa_sent_at=NOW_LOCAL(), wa_error='', updated_at=NOW_LOCAL() + WHERE id=? +`); +const markDonationWaSentErr = db.prepare(` + UPDATE public_donation_orders + SET wa_sent=0, wa_error=?, updated_at=NOW_LOCAL() + WHERE id=? +`); +const updateWebhookPaymentNotifMatchDonation = db.prepare(` + UPDATE webhook_payment_notifs + SET matched_donation_order_id=? + WHERE id=? +`); + +const markInvoicePaidAppendNote = db.prepare(` + UPDATE invoices + SET status='paid', + paid_at=NOW_LOCAL(), + paid_by_name=?, + notes=CASE + WHEN notes IS NULL OR TRIM(notes) = '' THEN ? + ELSE notes || '\n' || ? + END, + qris_paid_notif_id=? + WHERE id=? +`); + +const countUnpaidInvoicesForCustomer = db.prepare(`SELECT COUNT(1) as c FROM invoices WHERE customer_id=? AND status='unpaid'`); + +function getIp(req) { + return String((req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || ''); +} + +function parseRupiahAmountFromNotification(content) { + const text = String(content || '').replace(/\u00A0/g, ' ').trim(); + if (!text) return null; + + const lower = text.toLowerCase(); + + // Deteksi chat/reaksi/kutipan tagihan yang bukan transaksi finansial asli + const chatOrInvoiceHints = [ + 'bereaksi', 'reacted', 'membalas', 'tagihan manual', 'kode bayar qris', + 'rincian tagihan', 'portal pelanggan', 'silakan scan', 'mohon scan', + 'link login', 'pengingat tagihan', 'halo pelanggan', 'yth. pelanggan', + 'paket internet anda', 'sebelum tanggal jatuh tempo' + ]; + if (chatOrInvoiceHints.some((h) => lower.includes(h))) { + return null; + } + + const incomingHints = [ + 'pembayaran masuk', 'pembayaran diterima', 'uang masuk', 'transfer masuk', + 'dana masuk', 'saldo masuk', 'saldo bertambah', 'berhasil top up', + 'top up berhasil', 'topup berhasil', 'terima uang', 'telah diterima dari', + 'diterima dari', 'qris berhasil', 'qris sukses', 'qr berhasil', 'qr sukses', + 'payment received', 'kamu menerima', 'berhasil menerima', 'menambahkan', + 'telah diterima sebesar', 'uang diterima', 'menerima dana', 'dana diterima', 'menerima sebesar', 'baru saja menerima', 'telah menerima', + 'just received', 'you received', 'you have received', 'funds received', 'has been received', 'incoming transfer', 'credited' + ]; + const outgoingHints = [ + 'mengirim', 'terkirim', 'telah dikirim', 'berhasil kirim', 'transfer ke', + 'bayar ke', 'pembayaran berhasil', 'berhasil bayar', 'pembelian', + 'belanja', 'purchase', 'kamu membayar', 'transaksi keluar', 'debit', + 'dikenakan biaya', 'potongan', + 'you just transferred', 'you have transferred', 'you just topped up', 'topped up to', + 'transferred to', 'you sent', 'sent to', 'you just paid', 'you just sent' + ]; + + const hasIncomingHint = incomingHints.some((hint) => lower.includes(hint)); + const hasOutgoingHint = outgoingHints.some((hint) => lower.includes(hint)); + + // Sinyal 'uang masuk' kuat (menang atas sinyal keluar generik seperti 'pembayaran berhasil') + const strongIncoming = [ + 'kamu menerima', 'anda menerima', 'berhasil menerima', 'pembayaran diterima', + 'dana masuk', 'saldo masuk', 'qris berhasil', 'qris sukses', 'payment received', + 'uang masuk', 'telah diterima', 'top up berhasil', 'topup berhasil', 'berhasil top up', + 'menerima pembayaran', 'qris statis diterima', 'qris diterima', 'pembayaran qris', + 'menerima dana', 'dana diterima', 'menerima sebesar', 'telah menerima', + 'just received', 'you received', 'you have received', 'funds received', 'has been received' + ]; + const hasStrongIncoming = strongIncoming.some((hint) => lower.includes(hint)); + + // Sinyal masuk generik: ada kata 'qris' + kata masuk (diterima/masuk/berhasil/sukses/menerima) + const qrisLike = /\bqris\b/i.test(text); + const incomingWord = /(diterima|masuk|berhasil|sukses|received|menerima)/i.test(text); + const hasGenericIncoming = qrisLike && incomingWord; + const strongIn = hasStrongIncoming || hasGenericIncoming; + + // Wajib ada indikator masuk; tolak indikator keluar KECUALI ada sinyal masuk kuat + if ((!hasIncomingHint && !strongIn) || (hasOutgoingHint && !strongIn)) { + return null; + } + + const candidates = [ + /(?:\bRp\.?\s*|IDR\s*)([0-9][0-9\.\,\s]*)/i, + /(?:sebesar|senilai|nominal|masuk|transfer|top\s*up|topup|saldo\s+masuk)\s*(?:saldo\s*)?(?:\bRp\.?\s*)?([0-9][0-9\.\,\s]*)/i, + ]; + + let raw = null; + for (const re of candidates) { + const m = text.match(re); + if (m && m[1]) { + raw = String(m[1]); + break; + } + } + if (!raw) return null; + + let num = raw.replace(/\s+/g, '').replace(/[^\d.,]/g, ''); + if (!num) return null; + + const lastDot = num.lastIndexOf('.'); + const lastComma = num.lastIndexOf(','); + let intPart = num; + if (lastDot > -1 && lastComma > -1) { + const decIdx = Math.max(lastDot, lastComma); + intPart = num.slice(0, decIdx).replace(/[.,]/g, ''); + } else { + const sep = lastDot > -1 ? '.' : (lastComma > -1 ? ',' : ''); + if (sep) { + const idx = num.lastIndexOf(sep); + const after = num.slice(idx + 1); + if (after.length === 3) { + intPart = num.replace(/[.,]/g, ''); // pemisah ribuan + } else { + intPart = num.slice(0, idx).replace(/[.,]/g, ''); + } + } + } + intPart = intPart.replace(/[^\d]/g, ''); + if (!intPart) return null; + + const amount = Number.parseInt(intPart, 10); + return Number.isFinite(amount) ? amount : null; +} + +const recentUnmatchedAlerts = new Map(); + +/** + * Notifikasi WA ke admin saat ada pembayaran ter-parse tapi BELUM cocok ke tagihan. + * Admin mencocokkan manual via jam notif vs jam bukti transfer. + */ +async function notifyAdminUnmatchedPayment(amount, rawText, service) { + try { + const on = getSetting('webhook_unmatched_notify_enabled', true); + if (on === false || on === 'false') return; + const key = String(amount) + '|' + String(rawText || '').slice(0, 120); + const now = Date.now(); + const prev = recentUnmatchedAlerts.get(key) || 0; + if (now - prev < 600000) return; // hindari dobel dalam 10 menit + recentUnmatchedAlerts.set(key, now); + if (recentUnmatchedAlerts.size > 500) { for (const kv of recentUnmatchedAlerts) { if (now - kv[1] > 1200000) recentUnmatchedAlerts.delete(kv[0]); } } + const waNotify = require('./services/waNotifyService'); + let waktu = ''; + try { waktu = new Date().toLocaleString('id-ID', { timeZone: getSetting('timezone', 'Asia/Jakarta') }); } catch (e) { waktu = new Date().toISOString(); } + const lines = [ + '⚠️ *Pembayaran belum cocok*', + '', + '💰 Nominal: *Rp ' + Number(amount || 0).toLocaleString('id-ID') + '*', + '🏦 Sumber: ' + (service || '-'), + '🕒 ' + waktu, + '', + '📩 "' + String(rawText || '').slice(0, 160) + '"', + '', + 'Belum ada tagihan yang cocok. Cocokkan jam notif dengan bukti transfer pelanggan, lalu tandai lunas manual.' + ]; + await waNotify.sendToStaff(lines.join('\n')); + } catch (e) {} +} + +function genRandomCode(len = 6) { + const n = Math.max(1, Math.min(16, Number(len) || 6)); + let out = ''; + for (let i = 0; i < n; i++) { + out += String(Math.floor(Math.random() * 10)); + } + return out; +} + +function genCustomCode(len, charset) { + const n = Math.max(4, Math.min(16, Number(len) || 6)); + let chars = '0123456789'; + if (charset === 'letters') chars = 'abcdefghjkmnpqrstuvwxyz'; + else if (charset === 'mixed') chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let out = ''; + for (let i = 0; i < n; i++) { + out += chars.charAt(Math.floor(Math.random() * chars.length)); + } + if (charset === 'numbers' && out[0] === '0') out = '1' + out.slice(1); + return out; +} + + +function normalizeQrisPayload(raw) { + let s = String(raw || '').replace(/[\r\n\t]+/g, '').trim(); + const idx = s.indexOf('000201'); + if (idx > 0) s = s.slice(idx); + const lastCrc = s.lastIndexOf('6304'); + if (lastCrc >= 0 && s.length >= lastCrc + 8) { + s = s.slice(0, lastCrc + 8); + } + return s; +} + +function crc16CcittFalse(input) { + const s = String(input || ''); + let crc = 0xffff; + for (let i = 0; i < s.length; i++) { + crc ^= (s.charCodeAt(i) & 0xff) << 8; + for (let b = 0; b < 8; b++) { + if (crc & 0x8000) crc = ((crc << 1) ^ 0x1021) & 0xffff; + else crc = (crc << 1) & 0xffff; + } + } + return crc & 0xffff; +} + +function parseEmvTlvString(input) { + const raw = String(input || '').replace(/[\r\n\t]+/g, '').trim(); + if (!raw) throw new Error('QRIS payload kosong'); + if (raw.length < 8) throw new Error('QRIS payload terlalu pendek'); + const items = []; + let i = 0; + while (i < raw.length) { + if (i + 4 > raw.length) throw new Error('QRIS payload TLV tidak valid'); + const tag = raw.slice(i, i + 2); + const lenStr = raw.slice(i + 2, i + 4); + if (!/^\d{2}$/.test(lenStr)) throw new Error('QRIS payload TLV length tidak valid'); + const len = Number(lenStr); + const start = i + 4; + const end = start + len; + if (end > raw.length) throw new Error('QRIS payload TLV length melebihi data'); + const value = raw.slice(start, end); + items.push({ tag, value }); + i = end; + } + return items; +} + +function buildEmvTlvString(items) { + const list = Array.isArray(items) ? items : []; + let out = ''; + for (const it of list) { + const tag = String(it?.tag || ''); + const value = String(it?.value ?? ''); + const len = value.length; + if (!/^\d{2}$/.test(tag)) throw new Error('Tag TLV tidak valid'); + if (len > 99) throw new Error('TLV length > 99 tidak didukung'); + out += tag + String(len).padStart(2, '0') + value; + } + return out; +} + +function convertStaticQrisToDynamic(staticPayload, amount) { + const amt = Math.max(0, Math.floor(Number(amount || 0) || 0)); + if (!amt) throw new Error('Nominal QRIS dinamis tidak valid'); + const source = parseEmvTlvString(staticPayload) + .filter(x => x && x.tag) + .map(x => ({ tag: String(x.tag), value: String(x.value ?? '') })); + const managed = new Set(['54', '55', '56', '57', '63']); + const result = []; + let amountInserted = false; + for (const el of source) { + if (managed.has(el.tag)) continue; + if (el.tag === '01') { + result.push({ tag: '01', value: '12' }); + continue; + } + if (el.tag === '58' && !amountInserted) { + result.push({ tag: '54', value: String(amt) }); + amountInserted = true; + } + result.push(el); + } + if (!amountInserted) result.push({ tag: '54', value: String(amt) }); + const body = buildEmvTlvString(result); + const partial = body + '6304'; + const crc = crc16CcittFalse(partial).toString(16).toUpperCase().padStart(4, '0'); + return partial + crc; +} + +async function buildQrisJpgFromSettings(settings, amount) { + const payloadRaw = String(settings?.qris_static_payload || ''); + const payload = normalizeQrisPayload(payloadRaw); + if (!payload) throw new Error('QRIS payload belum diatur'); + const dynamic = convertStaticQrisToDynamic(payload, amount); + const png = await QRCode.toBuffer(dynamic, { errorCorrectionLevel: 'M', margin: 1, width: 420, type: 'png' }); + return await Jimp.read(png).then(img => img.quality(90).background(0xffffffff).getBufferAsync(Jimp.MIME_JPEG)); +} + +async function trySendWaToBuyer(settings, phone, message, orderId) { + if (!settings || !settings.whatsapp_enabled) return; + const p = String(phone || '').trim(); + if (!p) return; + try { + const { sendWA, whatsappStatus } = await import('./services/whatsappBot.mjs'); + if (whatsappStatus.connection !== 'open') throw new Error('Bot WhatsApp belum terhubung'); + await sendWA(p, message); + markVoucherWaSentOk.run(orderId); + } catch (e) { + markVoucherWaSentErr.run(String(e?.message || e || ''), orderId); + } +} + +async function trySendWaPaymentSuccess(settings, invoiceId, methodLabel) { + if (!settings || !settings.whatsapp_enabled) return; + try { + const inv = billingSvc.getInvoiceById(invoiceId); + 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'); + + const appUrl = (settings.public_base_url || '').replace(/\/$/, ''); + const portalUrl = appUrl ? `${appUrl}/customer` : ''; + const template = db.getAppSetting('whatsapp_payment_success_message', ''); + const metode = String(methodLabel || '').trim() || 'Online Gateway'; + + const msg = whatsappService.formatPaymentSuccessMessage({ + customerName: inv.customer_name || 'Pelanggan', + invoiceId: inv.id, + customerUsername: inv.customer_pppoe || inv.customer_code || (inv.customer_id ? ('ID:' + inv.customer_id) : '-'), + customerCode: inv.customer_code || (inv.customer_id ? ('ID:' + inv.customer_id) : '-'), + packageName: inv.package_name || '-', + periodMonth: inv.period_month, + periodYear: inv.period_year, + amount: inv.amount, + paymentMethod: metode, + paidAt: inv.paid_at || new Date(), + companyName: settings.company_header || 'OPEN-ISP', + companyPhone: settings.company_phone || '', + portalUrl, + customTemplate: template + }); + + logger.info(`[WEBHOOK][payment-notif] Sending WA success notif to ${phone} inv=${invoiceId} method=${metode}`); + await sendWA(phone, msg); + } catch (e) { + logger.error(`[WEBHOOK][payment-notif] WA success notif failed: ${e?.message || e}`); + } +} + +async function fulfillVoucherOrder(settings, orderId) { + const ord = selectVoucherOrderById.get(orderId); + if (!ord) throw new Error('Order tidak ditemukan'); + if (String(ord.status) === 'fulfilled' && ord.voucher_code) return { ok: true, already: true }; + if (String(ord.status) !== 'paid') return { ok: false, reason: 'not_paid' }; + + let prefix = ''; + let codeLength = 6; + let charset = 'mixed'; + try { + const pkg = db.prepare('SELECT * FROM voucher_packages WHERE router_id IS ? AND profile_name = ?').get(ord.router_id ?? null, ord.profile_name); + if (pkg) { + prefix = String(pkg.prefix || '').trim(); + codeLength = Math.max(4, Math.min(16, Number(pkg.code_length) || 6)); + charset = String(pkg.charset || 'mixed'); + } + } catch (e) { + logger.error('[Fulfillment] Gagal query voucher_packages: ' + e.message); + } + + let created = null; + let attempt = 0; + while (attempt < 10) { + attempt++; + const coreLen = Math.max(4, codeLength - prefix.length); + const code = prefix + genCustomCode(coreLen, charset); + const pass = code; + const comment = `vc-${code}-${ord.profile_name}`; + const userData = { + server: 'all', + name: code, + password: pass, + profile: ord.profile_name, + comment + }; + if (ord.validity) userData['limit-uptime'] = ord.validity; + + try { + await mikrotikService.addHotspotUser(userData, ord.router_id ?? null); + created = { code, pass, comment }; + break; + } catch (e) { + const msg = String(e?.message || e || '').toLowerCase(); + const isDup = msg.includes('already') || msg.includes('exist') || msg.includes('duplicate'); + if (isDup) continue; + throw e; + } + } + if (!created) throw new Error('Gagal membuat voucher (kode duplikat terlalu sering)'); + + markVoucherFulfilled.run(created.code, created.pass, created.comment, orderId); + + const msg = + `🎫 *VOUCHER HOTSPOT*\n\n` + + `✅ Pembayaran diterima via *QRIS Statis*\n` + + `📦 Paket: *${ord.profile_name}* (${ord.validity || '-'})\n` + + `💰 Harga: Rp ${Number(ord.price || 0).toLocaleString('id-ID')}\n\n` + + `👤 User: *${created.code}*\n` + + `🔑 Pass: *${created.pass}*\n\n` + + `Terima kasih.`; + + await trySendWaToBuyer(settings, ord.buyer_phone, msg, orderId); + return { ok: true, created }; +} + +async function fulfillDonationOrder(settings, donationOrderId) { + const ord = selectDonationOrderById.get(donationOrderId); + if (!ord) return { ok: false, error: 'Order donasi tidak ditemukan' }; + + const donorName = String(ord.donor_name || 'Hamba Allah').trim(); + const donorPhone = String(ord.donor_phone || '').trim(); + const amount = Number(ord.qris_amount_unique || ord.amount || 0); + const activationCode = String(ord.activation_code || getSetting('activation_code', '')).trim(); + const baseUrl = String(settings.app_url || '').replace(/\/+$/, ''); + const sidebarSettingsLink = `${baseUrl}/admin/sidebar-settings`; + + const msg = +`🙏 *TERIMA KASIH ATAS DONASI ANDA!* + +Halo *${donorName}*, +Alhamdulillah, transaksi donasi Anda sebesar *Rp ${amount.toLocaleString('id-ID')}* telah *BERHASIL DITERIMA* oleh sistem kami. + +🔑 *KODE AKTIVASI SIDEBAR:* +*${activationCode}* + +📌 *Panduan Penggunaan Kode Aktivasi:* +1. Buka menu *Pengaturan Sidebar* di Admin Panel: +${sidebarSettingsLink} +2. Masukkan password aktivasi: *${activationCode}* +3. Ubah status menu yang diinginkan menjadi *Tampil* +4. Klik tombol *Simpan Pengaturan Sidebar* + +Dukungan Anda sangat berarti bagi pengembangan aplikasi OPEN-ISP. Semoga rezeki Anda dilipatgandakan dan berkah selalu. Aamiin! 🤲 + +🏢 *${settings.company_header || 'OPEN-ISP'}*`; + + try { + const whatsappSvc = require('./services/whatsappService'); + await whatsappSvc.sendWhatsAppMessage(donorPhone, msg); + markDonationWaSentOk.run(donationOrderId); + return { ok: true }; + } catch (err) { + markDonationWaSentErr.run(err.message, donationOrderId); + logger.warn(`[Donasi WA] Gagal kirim WA ke ${donorPhone}: ${err.message}`); + return { ok: false, error: err.message }; + } +} + + +// Meta WhatsApp Cloud API Public Webhook Endpoints +const metaWAService = require('./services/metaWhatsappService'); +app.get('/api/meta-webhook', (req, res) => metaWAService.verifyWebhook(req, res)); +app.post('/api/meta-webhook', (req, res) => metaWAService.processWebhookEvent(req, res)); + +// Fonnte WhatsApp Gateway Public Webhook Endpoint +const fonnteWAService = require('./services/fonnteWhatsappService'); +app.post(['/api/webhook/fonnte', '/api/fonnte-webhook'], express.json(), express.urlencoded({ extended: true }), async (req, res) => { + try { + const data = req.body || {}; + logger.info(`[Fonnte Webhook Received] From: ${data.sender || data.from || ''} - Message: "${data.message || data.text || ''}"`); + const result = await fonnteWAService.processFonnteWebhook(data); + res.json({ status: true, message: 'Webhook processed', handled: result?.handled || false }); + } catch (err) { + logger.error('[Fonnte Webhook Error]', err.message); + res.status(500).json({ status: false, error: err.message }); + } +}); + +// ─── Webhook Payment Gateway MAYAR (Mode 1) ────────────────────────────── +app.post('/api/webhook/mayar', express.json({ limit: '2mb' }), async (req, res) => { + try { + const body = req.body || {}; + const event = String(body.event || '').trim(); + const data = body.data || body || {}; + if (event && event !== 'payment.received') { + return res.json({ ok: true, ignored: event }); + } + const txId = String(data.transactionId || data.id || '').trim(); + const extra = data.extraData || {}; + const invId = String(extra.invoice_id || extra.invoiceId || '').trim(); + + const db = require('./config/database'); + const billingSvc = require('./services/billingService'); + const customerSvc = require('./services/customerService'); + const waNotify = require('./services/waNotifyService'); + + let inv = null; + if (txId) inv = db.prepare("SELECT * FROM invoices WHERE payment_order_id = ? OR payment_reference = ? LIMIT 1").get(txId, txId); + if (!inv && invId) inv = billingSvc.getInvoiceById(Number(invId)); + if (!inv) { + logger.warn('[WEBHOOK][mayar] Invoice tidak ditemukan. txId=' + txId + ' invId=' + invId); + return res.json({ ok: true, matched: false }); + } + if (inv.status === 'paid') return res.json({ ok: true, already: true, invoiceId: inv.id }); + + billingSvc.markAsPaid(inv.id, 'Mayar', 'Otomatis via Webhook Mayar'); + + try { + const customer = customerSvc.getCustomerById(inv.customer_id); + if (customer && String(customer.status) === 'suspended' && typeof customerSvc.activateCustomer === 'function') { + customerSvc.activateCustomer(customer.id); + } + } catch (e) { logger.warn('[WEBHOOK][mayar] reactivate gagal: ' + e.message); } + + try { + const settings = getSettingsWithCache(); + const customer = customerSvc.getCustomerById(inv.customer_id); + if (settings.whatsapp_enabled && customer && customer.phone && (await waNotify.isConnected())) { + const full = billingSvc.getInvoiceById(inv.id); + const msg = '✅ *PEMBAYARAN DITERIMA*\n\nTagihan periode *' + full.period_month + '/' + full.period_year + '* sebesar Rp ' + Number(full.amount || 0).toLocaleString('id-ID') + ' telah *LUNAS* via Mayar.\n\nTerima kasih. 🙏'; + await waNotify.sendText(customer.phone, msg); + } + } catch (e) { logger.warn('[WEBHOOK][mayar] WA gagal: ' + e.message); } + + logger.info('[WEBHOOK][mayar] Invoice #' + inv.id + ' ditandai LUNAS.'); + return res.json({ ok: true, paid: true, invoiceId: inv.id }); + } catch (e) { + logger.error('[WEBHOOK][mayar] error: ' + (e && e.message)); + return res.status(500).json({ ok: false, error: e && e.message }); + } +}); + +app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => { + let body = req.body || {}; + try { + if (typeof body === 'string') { + try { + body = JSON.parse(body); + } catch { + body = { content: body }; + } + } else if ((!body || (typeof body === 'object' && Object.keys(body).length === 0)) && req.rawBody) { + try { + body = JSON.parse(String(req.rawBody || '')); + } catch { + body = { content: String(req.rawBody || '') }; + } + } + } catch {} + + const service = + (typeof body === 'object' && body ? (body.service || body.app || body.packageName || body.app_name || body.appname || body.appName || body.package_name || body.package) : '') || + req.query?.service || + req.query?.app || + req.query?.packageName || + req.headers['x-webhook-service'] || + ''; + + const authHeader = req.get('authorization') || ''; + const bearerToken = authHeader.toLowerCase().startsWith('bearer ') ? authHeader.substring(7).trim() : ''; + + const secret_key = + (typeof body === 'object' && body ? (body.secret_key ?? body.secretKey ?? body.secret) : null) ?? + req.query?.secret_key ?? + req.query?.secretKey ?? + req.query?.secret ?? + req.get('x-webhook-token') ?? + req.get('x-webhook-secret') ?? + req.get('x-webhook-key') ?? + bearerToken; + + const serverSecret = getSettingsWithCache().webhook_secret || process.env.MY_WEBHOOK_SECRET || 'billing-rtrw-secret-key'; + const expectedTrim = typeof serverSecret === 'string' ? serverSecret.trim() : 'billing-rtrw-secret-key'; + let gotTrim = String(secret_key || '').trim(); + 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'); + + 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)})`); + return res.status(403).json({ ok: false, error: 'Forbidden', reason: 'secret_key_mismatch', hint: 'Kirim header X-Webhook-Token: (atau query ?secret_key=). Salin nilainya dari Admin > Settings.' }); + } + + // Tolak langsung aplikasi chat / pesan instan (WhatsApp, Telegram, SMS chat pribadi) + const serviceLower = String(service || '').toLowerCase(); + const blockedServices = [ + 'whatsapp', 'com.whatsapp', 'telegram', 'org.telegram', 'facebook', + 'orca', 'instagram', 'messaging', 'mms', 'line', 'wechat', 'viber' + ]; + if (blockedServices.some(b => serviceLower.includes(b))) { + logger.warn(`[WEBHOOK][payment-notif] Ignored: chat/social messaging service detected (${serviceLower})`); + return res.status(200).json({ status: 'ignored', reason: 'chat_service_ignored', service: serviceLower }); + } + + // Safe debugging: log incoming request parameters (secrets masked) + const sanitizeForLog = (obj) => { + if (!obj || typeof obj !== 'object') return {}; + const clean = {}; + for (const key of Object.keys(obj)) { + const kLc = key.toLowerCase(); + if (['secret', 'token', 'key', 'password', 'pass', 'authorization', 'cookie'].some(k => kLc.includes(k))) { + clean[key] = '***'; + } else { + clean[key] = obj[key]; + } + } + return clean; + }; + logger.info(`[WEBHOOK][payment-notif] Debug params: query=${JSON.stringify(sanitizeForLog(req.query))} body=${JSON.stringify(sanitizeForLog(body))} headers=${JSON.stringify(sanitizeForLog(req.headers))}`); + + // Collect all potential text from request + const extractedTexts = []; + if (typeof body === 'string') { + extractedTexts.push(body); + } else if (body && typeof body === 'object') { + for (const key of Object.keys(body)) { + const val = body[key]; + if (typeof val === 'string' || typeof val === 'number') { + const kLc = key.toLowerCase(); + if (['secret', 'token', 'key', 'password', 'pass'].some(k => kLc.includes(k))) continue; + if (['service', 'app', 'packagename', 'app_name', 'appname'].includes(kLc) || kLc.includes('time')) continue; + extractedTexts.push(String(val)); + } + } + } + if (req.query && typeof req.query === 'object') { + for (const key of Object.keys(req.query)) { + const val = req.query[key]; + if (typeof val === 'string' || typeof val === 'number') { + const kLc = key.toLowerCase(); + if (['secret', 'token', 'key', 'password', 'pass'].some(k => kLc.includes(k))) continue; + if (['service', 'app', 'packagename', 'app_name', 'appname'].includes(kLc) || kLc.includes('time')) continue; + extractedTexts.push(String(val)); + } + } + } + if (req.rawBody && typeof req.rawBody === 'string') { + const trimmedRaw = req.rawBody.trim(); + if (!trimmedRaw.startsWith('{') && !trimmedRaw.startsWith('[')) { + extractedTexts.push(trimmedRaw); + } + } + + const rawText = Array.from(new Set(extractedTexts)) + .map((v) => String(v || '').trim()) + .filter(Boolean) + .join(' '); + + logger.info(`[WEBHOOK][payment-notif] IN service=${String(service || '-')} content="${rawText.replace(/\r?\n/g, ' ').slice(0, 500)}"`); + + const rawLower = rawText.toLowerCase(); + const chatOrInvoiceHints = [ + 'bereaksi', 'reacted', 'membalas', 'tagihan manual', 'kode bayar qris', + 'rincian tagihan', 'portal pelanggan', 'silakan scan', 'mohon scan', + 'link login', 'pengingat tagihan', 'halo pelanggan', 'yth. pelanggan', + 'paket internet anda', 'sebelum tanggal jatuh tempo' + ]; + if (chatOrInvoiceHints.some((h) => rawLower.includes(h))) { + logger.warn(`[WEBHOOK][payment-notif] Ignored: chat/invoice quoting content detected ("${rawText.slice(0, 150)}")`); + return res.status(200).json({ status: 'ignored', reason: 'chat_or_invoice_quoted_ignored' }); + } + + try { + const amount = parseRupiahAmountFromNotification(rawText); + const ip = String((req.headers['x-forwarded-for'] || '').toString().split(',')[0].trim() || req.ip || ''); + const ua = String(req.get('user-agent') || ''); + let notifId = null; + try { + const r = insertWebhookPaymentNotif.run( + String(service || ''), + rawText, + amount != null ? amount : null, + amount != null ? 1 : 0, + ip, + ua + ); + notifId = Number(r?.lastInsertRowid || 0) || null; + } catch (e) { + logger.error(`[WEBHOOK][payment-notif] DB log insert failed: ${e && e.message ? e.message : String(e)}`); + } + + let matchedInvoiceId = null; + let matchedVoucherOrderId = null; + let matchedDonationOrderId = null; + if (amount != null) { + try { + const invCandidates = selectInvoiceByUniqueAmount.all(amount); + const vCandidates = selectVoucherOrderByUniqueAmount.all(amount); + const dCandidates = selectDonationOrderByUniqueAmount.all(amount); + const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) + + (Array.isArray(vCandidates) ? vCandidates.length : 0) + + (Array.isArray(dCandidates) ? dCandidates.length : 0); + + if (totalCandidates === 1) { + if (Array.isArray(invCandidates) && invCandidates.length === 1) { + const inv = invCandidates[0]; + const invId = Number(inv.id || 0); + const custId = Number(inv.customer_id || 0); + if (invId > 0) { + const noteLine = `AUTO-QRIS: cocok nominal unik Rp ${amount} (service=${String(service || '-')}, notif=${notifId || '-'})`; + markInvoicePaidAppendNote.run('QRIS', noteLine, noteLine, notifId || null, invId); + matchedInvoiceId = invId; + + if (notifId) { + try { updateWebhookPaymentNotifMatchInvoice.run(invId, notifId); } catch {} + } + + if (custId > 0) { + if (String(inv.customer_status || '') === 'suspended') { + const cnt = countUnpaidInvoicesForCustomer.get(custId); + const unpaid = Number(cnt?.c || 0); + if (unpaid === 0) { + try { await customerSvc.activateCustomer(custId); } catch (e) { + logger.error(`[WEBHOOK][payment-notif] Activate customer failed: ${e && e.message ? e.message : String(e)}`); + } + } + } + } + + const methodLabel = service ? `QRIS (${String(service)})` : 'QRIS'; + try { await trySendWaPaymentSuccess(getSettingsWithCache(), invId, methodLabel); } catch {} + logger.info(`[WEBHOOK][payment-notif] MATCH invoice=${invId} amount=${amount}`); + } + } else if (Array.isArray(vCandidates) && vCandidates.length === 1) { + const ord = vCandidates[0]; + const ordId = Number(ord.id || 0); + if (ordId > 0) { + markVoucherPaid.run(notifId || null, ordId); + matchedVoucherOrderId = ordId; + logger.info(`[WEBHOOK][payment-notif] MATCH voucher_order=${ordId} amount=${amount}`); + if (notifId) { + try { updateWebhookPaymentNotifMatchVoucher.run(ordId, notifId); } catch {} + } + try { + await fulfillVoucherOrder(getSettingsWithCache(), ordId); + } catch (e) { + logger.error(`[WEBHOOK][payment-notif] Voucher fulfill error: ${e?.message || e}`); + } + } + } else if (Array.isArray(dCandidates) && dCandidates.length === 1) { + const don = dCandidates[0]; + const donId = Number(don.id || 0); + if (donId > 0) { + markDonationPaid.run(notifId || null, donId); + matchedDonationOrderId = donId; + logger.info(`[WEBHOOK][payment-notif] MATCH donation_order=${donId} amount=${amount}`); + if (notifId) { + try { updateWebhookPaymentNotifMatchDonation.run(donId, notifId); } catch {} + } + try { + await fulfillDonationOrder(getSettingsWithCache(), donId); + } catch (e) { + logger.error(`[WEBHOOK][payment-notif] Donation fulfill error: ${e?.message || e}`); + } + } + } + } 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}]`); + } + + // 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) { + const paidInv = selectPaidInvoiceByUniqueAmount.all(amount); + if (Array.isArray(paidInv) && paidInv.length === 1) { + matchedInvoiceId = Number(paidInv[0].id || 0) || null; + if (matchedInvoiceId && notifId) { try { updateWebhookPaymentNotifMatchInvoice.run(matchedInvoiceId, notifId); } catch {} } + logger.info(`[WEBHOOK][payment-notif] LINK sudah-lunas invoice=${matchedInvoiceId} amount=${amount} service=${String(service || '-')}`); + } + if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId) { + try { await notifyAdminUnmatchedPayment(amount, rawText, service); } catch (e) {} + } + } + } catch (e) { + logger.error(`[WEBHOOK][payment-notif] MATCH error: ${e && e.message ? e.message : String(e)}`); + } + } + + 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 }); + } + + logger.error(`[WEBHOOK][payment-notif] FAILED parse: "${rawText.replace(/\r?\n/g, ' ').slice(0, 500)}"`); + return res.status(200).json({ status: 'processed', parsed: false, amount: null }); + } catch (err) { + logger.error(`[WEBHOOK][payment-notif] ERROR ${err && err.stack ? err.stack : String(err)}`); + return res.status(200).json({ status: 'processed', parsed: false, amount: null }); + } +}); + +// Variabel global untuk modul lain yang masih membaca konfigurasi (mis. skrip utilitas) +global.appSettings = { + port: getSetting('server_port', 4555), + host: getSetting('server_host', 'localhost'), + genieacsUrl: getSetting('genieacs_url', 'http://localhost:7557'), + genieacsUsername: getSetting('genieacs_username', ''), + genieacsPassword: getSetting('genieacs_password', ''), + companyHeader: getSetting('company_header', 'ISP Monitor'), + footerInfo: getSetting('footer_info', ''), +}; + +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + version: VERSION + }); +}); + +// Halaman HTML dinamis tidak di-cache agar update UI langsung terlihat (termasuk di PWA) +app.use((req, res, next) => { + if (req.method === 'GET' && (req.headers.accept || '').includes('text/html')) { + res.set('Cache-Control', 'no-store'); + } + next(); +}); + +// Halaman depan (Landing Page) +app.get('/', (req, res) => { + // Mode aplikasi/PWA → langsung ke login pelanggan (opsi A) + if (req.query.app === '1' || req.query.source === 'pwa') { + return res.redirect('/customer/login'); + } + try { + const settings = getSettingsWithCache(); + + // Paket aktif dari database + let packages = []; + try { + packages = (customerSvc.getAllPackages() || []) + .filter(p => p && p.is_active !== 0) + .map(p => { + const kbps = Number(p.speed_down || p.speed_down_upto || 0); + const mbps = kbps >= 1000 ? Math.round(kbps / 1000) : kbps; + return { + id: p.id, + name: p.name || 'Paket', + speedText: String(mbps), + priceText: Number(p.price || 0).toLocaleString('id-ID'), + description: p.description || '' + }; + }) + .sort((a, b) => (parseInt(a.speedText, 10) || 0) - (parseInt(b.speedText, 10) || 0)); + } catch (e) { logger.warn('[landing] gagal ambil paket: ' + e.message); } + + // Area layanan dari database + let areas = []; + try { areas = customerSvc.getAllCustomerAreas() || []; } catch (e) { /* abaikan */ } + + // Statistik ringan + let customerCount = 0; + try { customerCount = db.prepare('SELECT COUNT(*) AS c FROM customers').get().c || 0; } catch (e) { /* abaikan */ } + + // Promo slides (sinkron dengan /admin/promo-slides) + let promos = []; + try { + const today = new Date().toISOString().split('T')[0]; + promos = db.prepare(` + SELECT id, title, description, image_path, url, open_in_new_tab + FROM promo_slides + WHERE is_active = 1 + AND (start_date IS NULL OR start_date <= ?) + AND (end_date IS NULL OR end_date >= ?) + ORDER BY sort_order ASC, id ASC + `).all(today, today); + } catch (e) { logger.warn('[landing] promo error: ' + e.message); } + + // Nomor WhatsApp + const phoneRaw = String(settings.company_phone || settings.meta_business_phone || '').replace(/[^0-9]/g, ''); + let waNumber = phoneRaw; + if (waNumber.startsWith('0')) waNumber = '62' + waNumber.slice(1); + const waLink = waNumber + ? 'https://wa.me/' + waNumber + '?text=' + encodeURIComponent('Halo, saya ingin bertanya tentang pemasangan internet.') + : ''; + + const brand = settings.landing_brand || settings.company_header || 'OPEN-ISP'; + + const testimonials = [ + { name: 'Andi Pratama', role: 'Pelanggan Rumah', text: 'Koneksinya stabil, kerja dari rumah dan streaming lancar tanpa buffering. Recommended!' }, + { name: 'Siti Rahayu', role: 'Usaha Kecil', text: 'Harga terjangkau dan upload cepat. Toko online saya jadi lebih lancar melayani pesanan.' }, + { name: 'Budi Santoso', role: 'Pelanggan Gaming', text: 'Ping rendah, main game online nyaman. Kalau ada kendala, tim support cepat merespon.' } + ]; + + const featuredIndex = packages.length ? Math.min(2, packages.length - 1) : 0; + + res.render('landing', { + brand, + settings, + packages, + areas, + waLink, + featuredIndex, + testimonials, + promos, + serviceNotice: noticeSvc.getServiceNotice(), + stats: { packages: packages.length, customers: customerCount, areas: areas.length } + }); + } catch (e) { + logger.error('[landing] error: ' + e.message); + res.redirect('/customer/login'); + } +}); + +// Alias singkat: /login → /customer/login +app.get('/login', (req, res) => { + res.redirect('/customer/login'); +}); + +// Halaman Isolir (Akses langsung dari redirect MikroTik) - dengan integrasi pembayaran otomatis +app.get('/isolated', async (req, res) => { + try { + const settings = getSettingsWithCache(); + + // 1. Identifikasi pelanggan: Session, Query param (?q= / ?cid=), atau IP + let customer = null; + const searchQuery = String(req.query.q || req.query.lookup || req.query.cid || req.query.phone || '').trim(); + + // Priority 1: Session login + if (req.session && req.session.phone) { + customer = customerSvc.findCustomerByAny(req.session.phone); + } + + // Priority 2: Query search param + if (!customer && searchQuery) { + customer = customerSvc.findCustomerByAny(searchQuery); + } + + // Priority 3: IP-based detection + let clientIp = ''; + if (!customer) { + const rawIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() + || req.ip + || req.connection?.remoteAddress + || ''; + clientIp = rawIp.replace(/^::ffff:/, '').trim(); + + if (clientIp) { + const allCustomers = customerSvc.getAllCustomers(); + customer = allCustomers.find(c => + (c.static_ip && c.static_ip === clientIp) || + (c.pppoe_remote_address && c.pppoe_remote_address === clientIp) + ); + + // MikroTik active PPP session lookup if still not found + if (!customer && mikrotikService && typeof mikrotikService.getPppoeActive === 'function') { + try { + const activeUsers = await mikrotikService.getPppoeActive(); + if (Array.isArray(activeUsers)) { + const match = activeUsers.find(u => u.address === clientIp); + if (match && match.name) { + customer = customerSvc.findCustomerByAny(match.name); + } + } + } catch (e) { + // Ignore Mikrotik query error + } + } + } + } + + // Check unpaid invoices + let invoicesWithTokens = []; + if (customer) { + const unpaidInvoices = billingSvc.getUnpaidInvoicesByCustomerId(customer.id); + if (unpaidInvoices && unpaidInvoices.length > 0) { + const tokenUtil = require('./utils/tokenUtil'); + invoicesWithTokens = unpaidInvoices.map(inv => ({ + ...inv, + publicToken: tokenUtil.signPublicToken({ + invoiceId: inv.id, + customerId: inv.customer_id, + lookup: customer.phone || customer.pppoe_username || String(customer.id), + exp: Date.now() + 60 * 60 * 1000 // 60 minutes + }, settings.session_secret) + })); + } + } + + // If customer is found, active, and has no unpaid invoices, redirect to dashboard + if (customer && customer.status === 'active' && invoicesWithTokens.length === 0) { + return res.redirect('/customer/dashboard'); + } + + // Resolve active gateway + const paymentSvc = require('./services/paymentService'); + const sampleAmount = invoicesWithTokens[0] ? invoicesWithTokens[0].amount : 50000; + const activeGateway = paymentSvc.resolveConfiguredGatewayForAmount(settings, sampleAmount) || 'qris_static'; + + res.render('isolated', { + company: settings.company_header || 'My ISP', + adminPhone: settings.company_phone || settings.company_whatsapp || '', + address: settings.company_address || '', + customer: customer || null, + invoices: invoicesWithTokens, + searchQuery, + clientIp, + activeGateway, + settings, + hasUnpaidInvoices: invoicesWithTokens.length > 0 + }); + } catch (err) { + logger.error(`[ISOLATED] Error: ${err.message}`); + const settings = getSettingsWithCache(); + res.render('isolated', { + company: settings.company_header || 'My ISP', + adminPhone: settings.company_phone || '', + address: settings.company_address || '', + customer: null, + invoices: [], + searchQuery: '', + clientIp: '', + activeGateway: 'qris_static', + settings, + hasUnpaidInvoices: false + }); + } +}); + +// GET /isolated/status - untuk polling status pelanggan real-time +app.get('/isolated/status', (req, res) => { + try { + let customer = null; + const queryLookup = String(req.query.q || req.query.cid || req.query.phone || '').trim(); + + // Detection: Query, Session, atau IP + if (queryLookup) { + customer = customerSvc.findCustomerByAny(queryLookup); + } else if (req.session && req.session.phone) { + customer = customerSvc.findCustomerByAny(req.session.phone); + } else { + const rawIp = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() + || req.ip + || ''; + const cleanIp = rawIp.replace(/^::ffff:/, '').trim(); + + if (cleanIp) { + const allCustomers = customerSvc.getAllCustomers(); + customer = allCustomers.find(c => + (c.static_ip && c.static_ip === cleanIp) || + (c.pppoe_remote_address && c.pppoe_remote_address === cleanIp) + ); + } + } + + if (!customer) { + return res.json({ + status: 'unknown', + unpaid_count: 0, + message: 'Pelanggan tidak ditemukan' + }); + } + + const unpaidInvoices = billingSvc.getUnpaidInvoicesByCustomerId(customer.id); + const isClean = customer.status === 'active' || unpaidInvoices.length === 0; + + res.json({ + status: isClean ? 'active' : customer.status, + unpaid_count: unpaidInvoices.length, + customer_id: customer.id, + customer_name: customer.name, + message: isClean + ? 'Layanan aktif, pembayaran telah selesai' + : `Tersisa ${unpaidInvoices.length} tagihan belum dibayar` + }); + } catch (e) { + logger.error(`[ISOLATED-STATUS] Error: ${e.message}`); + res.status(500).json({ error: 'Server error', status: 'error' }); + } +}); + +// POST /isolated/quick-qris/:invoiceId - Generate / fetch Quick QRIS transaction +app.post('/isolated/quick-qris/:invoiceId', async (req, res) => { + try { + const settings = getSettingsWithCache(); + const token = req.body.token || req.query.t; + const tokenUtil = require('./utils/tokenUtil'); + const payload = tokenUtil.verifyPublicToken(token, settings.session_secret); + + if (!payload || String(payload.invoiceId) !== String(req.params.invoiceId)) { + return res.status(401).json({ success: false, message: 'Token pembayaran tidak valid atau sudah kadaluarsa. Silakan refresh halaman.' }); + } + + const inv = billingSvc.getInvoiceById(req.params.invoiceId); + if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' }); + if (inv.status === 'paid') return res.json({ success: false, paid: true, message: 'Tagihan ini sudah lunas!' }); + + const cust = customerSvc.getCustomerById(inv.customer_id); + if (!cust) return res.status(404).json({ success: false, message: 'Pelanggan tidak ditemukan' }); + + const paymentSvc = require('./services/paymentService'); + const gateway = paymentSvc.resolveConfiguredGatewayForAmount(settings, inv.amount); + if (!gateway) { + return res.status(400).json({ success: false, message: 'Metode pembayaran QRIS belum dikonfigurasi di server.' }); + } + + const protocol = req.headers['x-forwarded-proto'] || req.protocol; + const host = req.get('host'); + const appUrl = settings.app_url || `${protocol}://${host}`; + const QRCode = require('qrcode'); + const periodText = `${inv.period_month}/${inv.period_year}`; + + // 1. QRIS STATIS (LOKAL) + if (gateway === 'qris_static') { + const customerPortalRouter = require('./routes/customerPortal'); + let uniqueCode = inv.qris_unique_code || 0; + let amountUnique = inv.qris_amount_unique || inv.amount; + + if (typeof customerPortalRouter.ensureInvoiceQrisUnique === 'function') { + const ensured = customerPortalRouter.ensureInvoiceQrisUnique(inv, false); + if (ensured) { + uniqueCode = ensured.uniqueCode; + amountUnique = ensured.amountUnique; + } + } + + let qrDataUrl = ''; + if (typeof customerPortalRouter.getStaticQrisQrUrlForAmount === 'function') { + qrDataUrl = await customerPortalRouter.getStaticQrisQrUrlForAmount(settings, amountUnique); + } + if (!qrDataUrl && settings.qris_static_qr_url) { + qrDataUrl = settings.qris_static_qr_url; + } + + return res.json({ + success: true, + gateway: 'qris_static', + gatewayName: 'QRIS', + invoiceId: inv.id, + amount: amountUnique, + nominalAsli: inv.amount, + uniqueCode: uniqueCode, + qrDataUrl: qrDataUrl, + periodText, + customerName: cust.name, + helpText: 'Pastikan mentransfer sesuai nominal tepat (termasuk kode unik) agar pembayaran langsung terdeteksi.' + }); + } + + // 2. TRIPAY + if (gateway === 'tripay') { + const result = await paymentSvc.createTripayTransaction(inv, cust, 'QRIS', appUrl, { + callbackPath: '/customer/payment/callback', + returnPath: '/isolated' + }); + + let qrDataUrl = ''; + if (result.payload?.qr_string) { + qrDataUrl = await QRCode.toDataURL(result.payload.qr_string, { errorCorrectionLevel: 'M', margin: 1, width: 340 }); + } else if (result.payload?.qr_url) { + qrDataUrl = result.payload.qr_url; + } + + billingSvc.updatePaymentInfo(inv.id, { + gateway: 'tripay', + order_id: result.order_id, + link: result.link, + reference: result.reference, + payload: result.payload + }); + + return res.json({ + success: true, + gateway: 'tripay', + gatewayName: 'Tripay QRIS', + invoiceId: inv.id, + amount: inv.amount, + checkoutUrl: result.link, + qrDataUrl: qrDataUrl, + reference: result.reference, + periodText, + customerName: cust.name, + helpText: 'Scan kode QRIS di atas dengan m-Banking (BCA, BRI, Mandiri, dll) atau E-Wallet (DANA, OVO, GoPay, ShopeePay).' + }); + } + + // 3. MIDTRANS + if (gateway === 'midtrans') { + const result = await paymentSvc.createMidtransTransaction(inv, cust, 'QRIS', appUrl, { + callbackPath: '/customer/payment/callback', + returnPath: '/isolated' + }); + + billingSvc.updatePaymentInfo(inv.id, { + gateway: 'midtrans', + order_id: result.order_id, + link: result.link, + reference: result.reference, + payload: result.payload + }); + + return res.json({ + success: true, + gateway: 'midtrans', + gatewayName: 'Midtrans QRIS', + invoiceId: inv.id, + amount: inv.amount, + checkoutUrl: result.link, + reference: result.reference, + periodText, + customerName: cust.name, + helpText: 'Buka link checkout Midtrans untuk menyelesaikan pembayaran via QRIS / GoPay.' + }); + } + + // 4. DUITKU + if (gateway === 'duitku') { + const result = await paymentSvc.createDuitkuTransaction(inv, cust, 'QRIS', appUrl, { + callbackPath: '/customer/payment/callback', + returnPath: '/isolated' + }); + + let qrDataUrl = ''; + if (result.payload?.qrCode) { + qrDataUrl = await QRCode.toDataURL(result.payload.qrCode, { errorCorrectionLevel: 'M', margin: 1, width: 340 }); + } + + billingSvc.updatePaymentInfo(inv.id, { + gateway: 'duitku', + order_id: result.order_id, + link: result.link, + reference: result.reference, + payload: result.payload + }); + + return res.json({ + success: true, + gateway: 'duitku', + gatewayName: 'Duitku QRIS', + invoiceId: inv.id, + amount: inv.amount, + checkoutUrl: result.link, + qrDataUrl: qrDataUrl, + periodText, + customerName: cust.name, + helpText: 'Scan QRIS di atas melalui m-Banking atau aplikasi E-Wallet Anda.' + }); + } + + // 5. XENDIT + if (gateway === 'xendit') { + const result = await paymentSvc.createXenditTransaction(inv, cust, 'xendit', appUrl, { + callbackPath: '/customer/payment/callback', + returnPath: '/isolated' + }); + + billingSvc.updatePaymentInfo(inv.id, { + gateway: 'xendit', + order_id: result.order_id, + link: result.link, + reference: result.reference, + payload: result.payload + }); + + return res.json({ + success: true, + gateway: 'xendit', + gatewayName: 'Xendit Invoice', + invoiceId: inv.id, + amount: inv.amount, + checkoutUrl: result.link, + periodText, + customerName: cust.name, + helpText: 'Klik tombol bayar untuk membuka halaman pembayaran resmi Xendit.' + }); + } + + return res.status(400).json({ success: false, message: 'Gateway ' + gateway + ' belum didukung untuk Quick QRIS.' }); + } catch (err) { + logger.error(`[QuickQRIS] Error: ${err.message}`); + res.status(500).json({ success: false, message: err.message || 'Gagal memproses pembayaran QRIS' }); + } +}); + +// HELPER FUNCTION: Get active payment channels +function getActivePaymentChannelsForIsolated(settings) { + const channels = []; + + // QRIS Static + if (settings.qris_static_enabled && settings.qris_static_payload) { + channels.push({ + code: 'QRIS_STATIC', + name: '🟦 QRIS Statis (Instant)', + enabled: true + }); + } + + // Tripay + if (settings.tripay_enabled && settings.tripay_api_key) { + channels.push({ + code: 'TRIPAY', + name: '💳 Transfer Bank / E-Wallet (Tripay)', + enabled: true + }); + } + + // Midtrans + if (settings.midtrans_enabled && settings.midtrans_server_key) { + channels.push({ + code: 'MIDTRANS', + name: '💳 Midtrans Snap', + enabled: true + }); + } + + // Xendit + if (settings.xendit_enabled && settings.xendit_api_key) { + channels.push({ + code: 'XENDIT', + name: '💳 Xendit', + enabled: true + }); + } + + return channels.length > 0 ? channels : [ + { code: 'QRIS_STATIC', name: '🟦 QRIS Statis', enabled: false } + ]; +} + +// Tambahkan view engine dan static +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); +function buildPwaManifest(scope, startUrl, desc, orientation) { + const name = getSetting('company_header', 'OPEN-ISP'); + const _lv = getSetting('logo_version', ''); const q = _lv ? ('?v=' + _lv) : ''; + return { + id: scope, + lang: 'id', + name: name, + short_name: name, + description: desc || ('Portal pelanggan ' + name + ' \u2014 tagihan, pembayaran, dan pengaturan WiFi.'), + start_url: startUrl, + scope: scope, + display: 'standalone', + orientation: orientation, + background_color: '#0A0D14', + theme_color: '#0A0D14', + icons: [ + { src: '/img/icons/icon-192.png' + q, sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/img/icons/icon-512.png' + q, sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/img/icons/icon-maskable-192.png' + q, sizes: '192x192', type: 'image/png', purpose: 'maskable' }, + { src: '/img/icons/icon-maskable-512.png' + q, sizes: '512x512', type: 'image/png', purpose: 'maskable' } + ] + }; +} +// version.txt — mekanisme update PWA (selalu no-store) +app.get('/version.txt', (req, res) => { + res.type('text/plain; charset=utf-8'); + res.set('Cache-Control', 'no-store, no-cache, must-revalidate'); + const billing = String(getSetting('app_version', '0.5') || '0.5'); + const nameV = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime'); + const pwaV = String(getSetting('pwa_version', '2.0') || '2.0'); + const stUrl = String(getSetting('speedtest_url', '') || ''); + const stMode = String(getSetting('speedtest_mode', 'iframe') || 'iframe'); + res.send('billing=' + billing + '\npwa=' + pwaV + '\nname=' + nameV + '\nspeedtest=' + stUrl + '\nmode=' + stMode + '\n'); +}); + +app.get('/manifest.webmanifest', (req, res) => { + res.type('application/manifest+json'); + res.send(buildPwaManifest('/customer/', '/customer/login?source=pwa', null, 'portrait')); +}); +app.get('/manifest-landing.webmanifest', (req, res) => { + // Disamakan dengan /manifest.webmanifest (id & scope = /customer/) agar identitas + // aplikasi terpasang KONSISTEN dari halaman mana pun — mencegah Chrome menganggap + // app "berbeda" dan memunculkan notifikasi "Tap to copy the URL for this app". + res.type('application/manifest+json'); + res.send(buildPwaManifest('/customer/', '/customer/login?source=pwa', 'Internet fiber optic cepat, stabil, dan tanpa batas untuk rumah dan usaha.', 'portrait')); +}); +// Service Worker selalu di-revalidate agar update (ikon/branding) cepat diterapkan +app.get('/sw.js', (req, res) => { + res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.type('application/javascript'); + res.sendFile(path.join(__dirname, 'public', 'sw.js')); +}); +app.get('/admin/manifest.webmanifest', (req, res) => { + res.type('application/manifest+json'); + res.send({ + name: 'Admin Billing', + short_name: 'Admin', + start_url: '/admin/settings?source=pwa', + scope: '/admin/', + display: 'standalone', + orientation: 'portrait', + background_color: '#0f172a', + theme_color: '#0f172a', + icons: [ + { src: '/img/pwa-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }, + { src: '/img/logo.png', sizes: '2000x545', type: 'image/png', purpose: 'any' } + ] + }); +}); +// ── GOWA Inbound Webhook: pesan masuk → bot command / live chat ── +app.post('/api/webhook/gowa', async (req, res) => { + try { + const sm = require('./config/settingsManager'); + const raw = (typeof req.rawBody === 'string' && req.rawBody) ? req.rawBody : (req.body ? JSON.stringify(req.body) : ''); + const secret = String(sm.getSetting('gowa_webhook_secret', 'secret') || ''); + const sig = String(req.headers['x-hub-signature-256'] || ''); + if (secret) { + const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(raw).digest('hex'); + if (sig !== expected) { + logger.warn('[GOWA webhook] signature tidak cocok — ditolak'); + return res.status(401).send('Unauthorized'); + } + } + + let data = {}; + try { data = JSON.parse(raw); } catch (e) { data = (req.body && typeof req.body === 'object') ? req.body : {}; } + if (String(data.event || '') !== 'message') return res.status(200).send('OK'); + + const p = data.payload || {}; + if (p.is_from_me) return res.status(200).send('OK'); + const fromRaw = String(p.from || p.chat_id || ''); + if (!fromRaw || fromRaw.indexOf('@g.us') !== -1) return res.status(200).send('OK'); + const senderPhone = fromRaw.split('@')[0].split(':')[0].replace(/\D/g, ''); + const text = String(p.body || '').trim(); + if (!senderPhone || senderPhone.length < 8 || !text) return res.status(200).send('OK'); + + // balas cepat ke GOWA, proses di latar belakang + res.status(200).send('OK'); + setTimeout(async () => { + try { + try { + const db = require('./config/database'); + let custId = null, custName = null; + const cust = db.prepare('SELECT id, name FROM customers WHERE phone LIKE ? OR phone LIKE ?').get('%' + senderPhone.slice(-8) + '%', '%' + senderPhone + '%'); + if (cust) { custId = cust.id; custName = cust.name; } + db.prepare("INSERT INTO wa_chat_messages (direction, gateway, sender_phone, recipient_phone, customer_id, customer_name, message_text, status, meta_message_id) VALUES ('inbound','gowa',?,?,?,?,?,?,?)") + .run(senderPhone, 'gowa_bot', custId, custName, text, 'received', String(p.id || '')); + } catch (e) {} + + const reply = async (msg) => { + try { + await require('./services/gowaWhatsappService').sendGowaMessage(senderPhone, msg); + try { + const db2 = require('./config/database'); + db2.prepare("INSERT INTO wa_chat_messages (direction, gateway, sender_phone, recipient_phone, customer_name, message_text, status) VALUES ('outbound','gowa',?,?,?,?,?)") + .run('gowa_bot', senderPhone, null, msg, 'sent'); + } catch (e2) {} + } catch (e) { logger.error('[GOWA webhook] gagal balas: ' + e.message); } + }; + const waBot = await import('./services/whatsappBot.mjs'); + await waBot.processIncomingCommand({ senderPhone: senderPhone, text: text, reply: reply, senderName: p.from_name || p.sender_display_name || '' }); + } catch (e) { logger.error('[GOWA webhook] proses error: ' + e.message); } + }, 0); + return; + } catch (e) { + logger.error('[GOWA webhook] error: ' + e.message); + return res.status(200).send('OK'); + } +}); + +// ══ KEAMANAN: file privat (bukti bayar & foto tiket) TIDAK boleh publik ══ +app.use(['/uploads/payment_proofs', '/uploads/tickets'], (req, res) => res.status(404).end()); +const _PRIVATE_MEDIA = { 'payment-proof': 'payment_proofs', 'ticket': 'tickets' }; +app.get('/admin/media/:kind/:file', (req, res) => { + const dir2 = _PRIVATE_MEDIA[req.params.kind]; + if (!dir2) return res.status(404).end(); + const s2 = req.session || {}; + if (!(s2.isAdmin || s2.isTechnician || s2.isCollector || s2.isCashier)) return res.status(401).end(); + const file = path.basename(String(req.params.file || '')); + if (!file || !/^[A-Za-z0-9._-]+$/.test(file)) return res.status(400).end(); + const abs = path.join(__dirname, 'public', 'uploads', dir2, file); + if (!fs.existsSync(abs)) return res.status(404).end(); + res.setHeader('Cache-Control', 'private, no-store'); + res.sendFile(abs); +}); + +app.use(express.static(path.join(__dirname, 'public'))); + + + +// Route: Kebijakan Privasi (Privacy Policy) untuk Google Play Store +app.get(['/privacy-policy', '/privacy', '/kebijakan-privasi', '/privacy.html'], (req, res) => { + const { getSetting } = require('./config/settingsManager'); + const company = getSetting('company_header', 'OPEN-ISP'); + res.render('privacy', { + company, + isLoggedIn: false + }); +}); + +// Route: Syarat & Ketentuan (Terms of Service / TOS) untuk Google Play Store & Pelanggan +app.get(['/terms-of-service', '/terms', '/tos', '/syarat-ketentuan', '/terms.html'], (req, res) => { + const { getSetting, getSettings } = require('./config/settingsManager'); + const company = getSetting('company_header', 'OPEN-ISP'); + const settings = getSettings ? getSettings() : {}; + res.render('tos', { + company, + settings, + isLoggedIn: false + }); +}); + +// Route download APK Android Pelanggan & Admin +app.get(['/download/app', '/download/apk', '/downloads/billing-rtrw.apk', '/download/billing-rtrw.apk', '/downloads/AlijayaCustomer.apk', '/download/AlijayaCustomer.apk'], (req, res) => { + const candidates = [ + path.join(__dirname, 'public', 'downloads', 'billing-rtrw.apk'), + path.join(__dirname, 'billing-rtrw.apk'), + path.join(__dirname, 'public', 'downloads', 'AlijayaCustomer.apk'), + path.join(__dirname, 'AlijayaCustomer.apk') + ]; + + const targetPath = candidates.find(p => fs.existsSync(p)); + + if (targetPath) { + res.setHeader('Content-Type', 'application/vnd.android.package-archive'); + return res.download(targetPath, 'billing-rtrw.apk'); + } + + return res.status(404).json({ + success: false, + message: 'File APK belum di-upload ke server VPS. Silakan upload file billing-rtrw.apk ke folder public/downloads/ di server.' + }); +}); + +app.get('/uploads/qris/:filename', async (req, res) => { + const wantsHtml = () => String(req.get('accept') || '').toLowerCase().includes('text/html'); + const sendPretty = (status, title, detail) => { + if (!wantsHtml()) return res.status(status).send(title); + const baseUrl = String(getSetting('app_url', '') || `${req.headers['x-forwarded-proto'] || req.protocol}://${req.get('host')}`).replace(/\/+$/, ''); + const loginLink = `${baseUrl}/customer/login`; + res.set('Content-Type', 'text/html; charset=utf-8'); + return res.status(status).send(`${title}

${title}

${detail || ''}

Buka Portal Pelanggan
`); + }; + try { + const filename = String(req.params.filename || ''); + const safeName = path.basename(filename); + if (!safeName || safeName !== filename) return sendPretty(404, 'QRIS tidak ditemukan', 'Link QRIS tidak valid.'); + + const filePath = path.join(__dirname, 'public', 'uploads', 'qris', safeName); + try { + await fs.promises.access(filePath, fs.constants.R_OK); + return res.sendFile(filePath); + } catch {} + + const settings = getSettingsWithCache(); + const payload = normalizeQrisPayload(String(settings?.qris_static_payload || '')); + if (payload) { + const png = await QRCode.toBuffer(payload, { errorCorrectionLevel: 'M', margin: 1, width: 420, type: 'png' }); + const img = await Jimp.read(png); + const jpg = await img.getBuffer('image/jpeg'); + res.set('Content-Type', 'image/jpeg'); + res.set('Cache-Control', 'no-store'); + return res.status(200).send(jpg); + } + + const url = String(settings?.qris_static_qr_url || '').trim(); + if (url && !url.endsWith(`/uploads/qris/${safeName}`)) return res.redirect(url); + return sendPretty(404, 'QRIS tidak ditemukan', 'Gambar QRIS upload tidak tersedia. Silakan gunakan link QRIS terbaru dari portal pelanggan.'); + } catch { + return sendPretty(404, 'QRIS tidak ditemukan', 'Gagal memuat QRIS.'); + } +}); + +app.get('/qris/static.jpg', async (req, res) => { + const wantsHtml = () => String(req.get('accept') || '').toLowerCase().includes('text/html'); + const sendPretty = (status, title, detail) => { + if (!wantsHtml()) return res.status(status).send(title); + const baseUrl = String(getSetting('app_url', '') || `${req.headers['x-forwarded-proto'] || req.protocol}://${req.get('host')}`).replace(/\/+$/, ''); + const loginLink = `${baseUrl}/customer/login`; + res.set('Content-Type', 'text/html; charset=utf-8'); + return res.status(status).send(`${title}

${title}

${detail || ''}

Buka Portal Pelanggan
`); + }; + try { + const amount = Math.max(0, Math.floor(Number(req.query.amount || 0) || 0)); + const settings = getSettingsWithCache(); + const qrisUtil = require('./utils/qrisUtil'); + let payload = qrisUtil.normalizeQrisPayload(String(settings?.qris_static_payload || '')); + + // If payload not in settings, try decode from uploaded QR file + if (!payload && settings?.qris_static_qr_url) { + const url = String(settings.qris_static_qr_url); + const match = url.match(/^\/uploads\/qris\/([^/?#]+)$/i); + if (match && match[1]) { + const safeName = path.basename(match[1]); + const filePath = path.join(__dirname, 'public', 'uploads', 'qris', safeName); + try { + const buf = await fs.promises.readFile(filePath); + payload = await qrisUtil.decodeQrisPayloadFromBuffer(buf); + } catch {} + } + } + + if (payload) { + if (amount > 0) { + const jpg = await qrisUtil.buildDynamicQrisJpgBuffer(payload, amount); + res.set('Content-Type', 'image/jpeg'); + res.set('Cache-Control', 'no-store'); + return res.status(200).send(jpg); + } else { + const png = await QRCode.toBuffer(payload, { errorCorrectionLevel: 'M', margin: 1, width: 420, type: 'png' }); + const img = await Jimp.read(png); + const jpg = await img.getBuffer('image/jpeg'); + res.set('Content-Type', 'image/jpeg'); + res.set('Cache-Control', 'no-store'); + return res.status(200).send(jpg); + } + } + + // Fallback if static image file exists + const url = String(settings?.qris_static_qr_url || '').trim(); + if (url) { + const match = url.match(/^\/uploads\/qris\/([^/?#]+)$/i); + if (match && match[1]) { + const safeName = path.basename(match[1]); + const filePath = path.join(__dirname, 'public', 'uploads', 'qris', safeName); + try { + await fs.promises.access(filePath, fs.constants.R_OK); + return res.sendFile(filePath); + } catch {} + } + return res.redirect(url); + } + + return sendPretty(404, 'QRIS tidak ditemukan', 'QRIS belum diatur oleh admin atau payload QRIS tidak valid.'); + } catch (e) { + logger.error(`[QRIS /qris/static.jpg] Error: ${e.message}`); + return sendPretty(404, 'QRIS tidak ditemukan', 'Gagal memproses QRIS: ' + e.message); + } +}); + +app.get('/broadcast', (req, res) => { + res.redirect('/admin/whatsapp/broadcast'); +}); + +// Helper: Ensure unique nominal for donation order +function ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, notes) { + const base = Math.max(1000, Math.floor(Number(baseAmount) || 50000)); + let chosenCode = 0; + let chosenAmount = 0; + + for (let code = 1; code <= 999; code++) { + const candidate = base + code; + const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? LIMIT 1').get('unpaid', candidate); + if (inv && inv.id) continue; + + const ord = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? LIMIT 1').get('pending', candidate); + if (ord && ord.id) continue; + + const don = db.prepare('SELECT id FROM public_donation_orders WHERE status=? AND qris_amount_unique=? LIMIT 1').get('pending', candidate); + if (don && don.id) continue; + + chosenCode = code; + chosenAmount = candidate; + break; + } + + if (!chosenAmount) { + chosenCode = Math.floor(1 + Math.random() * 998); + chosenAmount = base + chosenCode; + } + + const ins = db.prepare(` + INSERT INTO public_donation_orders (donor_name, donor_phone, amount, qris_amount_unique, qris_unique_code, notes, status, activation_code) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?) + `).run(donorName || 'Hamba Allah', donorPhone, base, chosenAmount, chosenCode, notes || '', getSetting('activation_code', '')); + + return { + orderId: ins.lastInsertRowid, + uniqueCode: chosenCode, + amountUnique: chosenAmount, + baseAmount: base + }; +} + +// 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) + }); +}); + +// Endpoint Buat Pesanan Donasi dengan Kode Unik +app.post(['/donasi/create', '/api/donasi/create'], (req, res) => { + try { + const { name, phone, amount, notes } = req.body || {}; + const donorName = String(name || 'Hamba Allah').trim(); + const donorPhone = String(phone || '').trim(); + const baseAmount = Math.max(1000, parseInt(amount, 10) || 50000); + const donorNotes = String(notes || '').trim(); + + if (!donorPhone || donorPhone.length < 8) { + return res.status(400).json({ success: false, message: 'Nomor WhatsApp wajib diisi (minimal 8 digit).' }); + } + + const result = ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, donorNotes); + return res.json({ + success: true, + orderId: result.orderId, + uniqueCode: result.uniqueCode, + amountUnique: result.amountUnique, + baseAmount: result.baseAmount, + qrisUrl: `/qris/static.jpg?amount=${result.amountUnique}` + }); + } catch (e) { + logger.error(`[Donasi Create] Error: ${e.message}`); + return res.status(500).json({ success: false, message: e.message }); + } +}); + +// Endpoint Status Donasi (Real-time Polling) +app.get(['/donasi/status/:orderId', '/api/donasi/status/:orderId'], (req, res) => { + try { + const orderId = Number(req.params.orderId || 0); + const order = selectDonationOrderById.get(orderId); + if (!order) return res.status(404).json({ success: false, message: 'Order donasi tidak ditemukan' }); + + return res.json({ + success: true, + orderId: order.id, + status: order.status, + paid_at: order.paid_at || null, + activationCode: order.status === 'paid' ? (order.activation_code || getSetting('activation_code', '')) : null, + donorName: order.donor_name, + amountUnique: order.qris_amount_unique, + waSent: Boolean(order.wa_sent) + }); + } catch (e) { + return res.status(500).json({ success: false, message: e.message }); + } +}); + +// Endpoint Konfirmasi Donasi & Fallback Kirim Kode Aktivasi +app.post(['/donasi/confirm', '/api/donasi/confirm'], async (req, res) => { + try { + const { orderId, name, phone, amount, notes } = req.body || {}; + let donId = Number(orderId || 0); + let ord = donId > 0 ? selectDonationOrderById.get(donId) : null; + + if (!ord) { + const donorName = String(name || 'Hamba Allah').trim(); + const donorPhone = String(phone || '').trim(); + const baseAmount = Math.max(1000, parseInt(amount, 10) || 50000); + if (!donorPhone || donorPhone.length < 8) { + return res.status(400).json({ success: false, message: 'Nomor WhatsApp tidak valid' }); + } + const created = ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, notes); + donId = created.orderId; + ord = selectDonationOrderById.get(donId); + } + + markDonationPaid.run(null, donId); + const fulfillRes = await fulfillDonationOrder(getSettingsWithCache(), donId); + + return res.json({ + success: true, + orderId: donId, + activationCode: (ord.activation_code || getSetting('activation_code', '')), + donorName: ord.donor_name, + donorAmount: ord.qris_amount_unique || ord.amount, + waSent: fulfillRes.ok, + waError: fulfillRes.error || null + }); + } catch (e) { + logger.error(`[Donasi] Error processing donation confirmation: ${e.message}`); + return res.status(500).json({ success: false, message: e.message }); + } +}); + +// 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); + +// Landing page untuk scan QR stiker modem ONU pelanggan +app.get('/app/connect', (req, res) => { + const settings = getSettingsWithCache(); + const cid = String(req.query.cid || '').trim(); + const serverUrl = `${req.protocol}://${req.get('host')}`; + const companyName = settings.company_header || 'ALIJAYA DIGITAL NETWORK'; + const companyPhone = settings.company_phone || ''; + res.render('app_connect', { + companyName, + companyPhone, + serverUrl, + cid, + settings + }); +}); + +// Mount customer portal +const customerPortal = require('./routes/customerPortal'); +app.use('/customer', customerPortal); + +// Mount customer REST API for Android Mobile App +const customerAPI = require('./routes/customerAPI'); +app.use('/api/customer', customerAPI); + +// ── OPEN-ISP admin theme overlay (non-invasive) ────────────────────────── +// Menyisipkan ke tiap halaman HTML admin TANPA +// mengubah view/partial manapun. Overlay dimuat setelah admin.css. +app.use('/admin', (req, res, next) => { + const originalSend = res.send.bind(res); + res.send = function (body) { + try { + const ct = res.get && res.get('Content-Type'); + if ( + typeof body === 'string' && + body.indexOf('') !== -1 && + body.indexOf('/css/admin-theme.css') === -1 && + body.indexOf('no-admin-theme') === -1 + ) { + body = body.replace('', ' \n \n \n \n \n'); + } + } catch (e) {} + return originalSend(body); + }; + next(); +}); + +// Badge mode status layanan di topbar admin (injeksi non-invasif, tanpa ubah view) +app.use('/admin', (req, res, next) => { + const originalSend = res.send.bind(res); + res.send = function (body) { + try { + if ( + typeof body === 'string' && + body.indexOf('') !== -1 && + body.indexOf('/js/service-notice-badge.js') === -1 + ) { + const noticeJson = JSON.stringify(noticeSvc.getServiceNotice()).split('<').join('\\u003c'); + body = body.replace('', ' \n \n \n'); + } + } catch (e) {} + return originalSend(body); + }; + next(); +}); + +// Mount admin portal + +const adminPortal = require('./routes/adminPortal'); +app.use('/admin', adminPortal); + +// Mount tech portal +const techPortal = require('./routes/techPortal'); +app.use('/tech', techPortal); + +// Mount agent portal + +// Mount collector portal +const collectorPortal = require('./routes/collectorPortal'); +app.use('/collector', collectorPortal); + +// Fungsi untuk memulai server dengan penanganan port yang sudah digunakan +function startServer(portToUse) { + logger.info(`Mencoba memulai server pada port ${portToUse}...`); + + // Coba port alternatif jika port utama tidak tersedia + try { + const server = app.listen(portToUse, () => { + logger.info(`Server berhasil berjalan pada port ${portToUse}`); + logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); + // Update global.appSettings.port dengan port yang berhasil digunakan + global.appSettings.port = portToUse.toString(); + + // Voucher cache warmer dinonaktifkan — halaman voucher sekarang direct query ke MikroTik + // const voucherCacheWarmer = require('./services/voucherCacheWarmer'); + // voucherCacheWarmer.startCacheWarming(); + }).on('error', (err) => { + if (err.code === 'EADDRINUSE') { + logger.warn(`PERINGATAN: Port ${portToUse} sudah digunakan, mencoba port alternatif...`); + // Coba port alternatif (port + 1000) + const alternativePort = portToUse + 1000; + logger.info(`Mencoba port alternatif: ${alternativePort}`); + + // Buat server baru dengan port alternatif + const alternativeServer = app.listen(alternativePort, () => { + logger.info(`Server berhasil berjalan pada port alternatif ${alternativePort}`); + logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); + // Update global.appSettings.port dengan port yang berhasil digunakan + global.appSettings.port = alternativePort.toString(); + + // Voucher cache warmer dinonaktifkan — halaman voucher sekarang direct query ke MikroTik + // const voucherCacheWarmer = require('./services/voucherCacheWarmer'); + // voucherCacheWarmer.startCacheWarming(); + }).on('error', (altErr) => { + logger.error(`ERROR: Gagal memulai server pada port alternatif ${alternativePort}:`, altErr.message); + process.exit(1); + }); + } else { + logger.error('Error starting server:', err); + process.exit(1); + } + }); + } catch (error) { + logger.error(`Terjadi kesalahan saat memulai server:`, error); + process.exit(1); + } +} + +// Mulai server dengan port dari settings.json +const port = global.appSettings.port; +logger.info(`Attempting to start server on configured port: ${port}`); + +// Mulai server dengan port dari konfigurasi +startServer(port); + +if (getSetting('whatsapp_enabled', false)) { + const gwType = getSetting('wa_gateway_type', 'baileys'); + if (gwType === 'baileys') { + import('./services/whatsappBot.mjs') + .then((mod) => mod.startWhatsAppBot()) + .catch((err) => logger.error('Gagal memulai WhatsApp bot (Baileys):', err)); + } +} + +if (getSetting('telegram_enabled', false)) { + const { initTelegram } = require('./services/telegramBot'); + initTelegram(); +} + +// Mulai cron jobs (generate tagihan otomatis, dll) +const { startCronJobs } = require('./services/cronService'); +startCronJobs(); + +// Mulai auto backup +scheduleAutoBackup(); + +// Inisialisasi RADIUS Server jika diaktifkan di settings +const radiusSvc = require('./services/radiusServerService'); +if (getSetting('radius_enabled', '0') === '1') { + radiusSvc.start(); +} + +// Error handling middleware (harus di akhir setelah semua routes) +app.use(notFoundHandler); +app.use(errorHandler); + +// Export app untuk testing +module.exports = app; diff --git a/config/customerTag.js b/config/customerTag.js new file mode 100644 index 0000000..dd047c5 --- /dev/null +++ b/config/customerTag.js @@ -0,0 +1,296 @@ +// Modul untuk menambahkan nomor pelanggan ke tag GenieACS +const axios = require('axios'); +require('dotenv').config(); +const { logger } = require('./logger'); + +let sock = null; + +// Fungsi untuk set instance sock +function setSock(sockInstance) { + sock = sockInstance; +} + +// Fungsi untuk menambahkan tag pelanggan ke perangkat GenieACS +async function addCustomerTag(remoteJid, params) { + try { + // Ekstrak parameter + if (params.length < 2) { + await sock.sendMessage(remoteJid, { + text: `❌ *Format Salah*\n\nFormat yang benar:\naddtag [device_id] [nomor_pelanggan]\n\nContoh:\naddtag 202BC1-BM632w-000000 081234567890` + }); + return; + } + + const [deviceId, customerNumber] = params; + + // Validasi nomor pelanggan + if (!customerNumber || !/^\d{8,}$/.test(customerNumber)) { + await sock.sendMessage(remoteJid, { + text: `❌ *Nomor Pelanggan Tidak Valid*\n\nNomor pelanggan harus berupa angka minimal 8 digit.` + }); + return; + } + + // Dapatkan URL GenieACS + const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL; + if (!genieacsUrl) { + await sock.sendMessage(remoteJid, { + text: `❌ *Konfigurasi tidak lengkap*\n\nURL GenieACS tidak dikonfigurasi` + }); + return; + } + + // Kirim pesan bahwa proses sedang berlangsung + await sock.sendMessage(remoteJid, { + text: `⏳ *Proses penambahan tag*\n\nSedang menambahkan nomor ${customerNumber} ke perangkat ${deviceId}...` + }); + + // Persiapkan URL untuk menambahkan tag + const tagUrl = `${genieacsUrl}/devices/${deviceId}/tags/${customerNumber}`; + + // Kirim request ke GenieACS untuk menambahkan tag + try { + const response = await axios.post( + tagUrl, + {}, + { + auth: { + username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME, + password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD + } + } + ); + + logger.info(`Tag response: ${response.status}`); + + // Kirim pesan sukses + let successMessage = `✅ *Tag berhasil ditambahkan*\n\n`; + successMessage += `📱 *Nomor Pelanggan:* ${customerNumber}\n`; + successMessage += `🖥️ *Device ID:* ${deviceId}\n\n`; + successMessage += `Pelanggan sekarang dapat menggunakan WhatsApp dan Web Portal dengan nomor tersebut.`; + + await sock.sendMessage(remoteJid, { text: successMessage }); + + } catch (error) { + logger.error('Error adding tag to GenieACS:', error); + + let errorMessage = `❌ *Gagal menambahkan tag*\n\n`; + if (error.response) { + errorMessage += `Status: ${error.response.status}\n`; + errorMessage += `Pesan: ${JSON.stringify(error.response.data)}\n`; + } else { + errorMessage += `Error: ${error.message}\n`; + } + + await sock.sendMessage(remoteJid, { text: errorMessage }); + } + + } catch (error) { + logger.error('Error in addCustomerTag:', error); + + await sock.sendMessage(remoteJid, { + text: `❌ *Error*\n\nTerjadi kesalahan saat menambahkan tag: ${error.message}` + }); + } +} + +// Fungsi untuk mencari perangkat berdasarkan ID +async function findDeviceById(deviceId) { + try { + // Dapatkan URL GenieACS + const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL; + if (!genieacsUrl) { + logger.error('GenieACS URL not configured'); + return null; + } + + // Buat query untuk mencari perangkat berdasarkan ID + const queryObj = { "_id": deviceId }; + const queryJson = JSON.stringify(queryObj); + const encodedQuery = encodeURIComponent(queryJson); + + // Ambil perangkat dari GenieACS + const response = await axios.get(`${genieacsUrl}/devices/?query=${encodedQuery}`, { + auth: { + username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME, + password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD + }, + headers: { + 'Accept': 'application/json' + } + }); + + if (response.data && response.data.length > 0) { + return response.data[0]; + } + + return null; + } catch (error) { + logger.error(`Error finding device by ID: ${error.message}`); + return null; + } +} + +// Fungsi untuk menambahkan tag berdasarkan PPPoE Username +async function addTagByPPPoE(remoteJid, params, sock) { + try { + // Ekstrak parameter + if (params.length < 2) { + await sock.sendMessage(remoteJid, { + text: `❌ *Format Salah*\n\nFormat yang benar:\naddpppoe_tag [pppoe_username] [nomor_pelanggan]\n\nContoh:\naddpppoe_tag user123 081234567890` + }); + return; + } + + const [pppoeUsername, customerNumber] = params; + + // Validasi nomor pelanggan + if (!customerNumber || !/^\d{8,}$/.test(customerNumber)) { + await sock.sendMessage(remoteJid, { + text: `❌ *Nomor Pelanggan Tidak Valid*\n\nNomor pelanggan harus berupa angka minimal 8 digit.` + }); + return; + } + + // Dapatkan URL GenieACS + const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL; + if (!genieacsUrl) { + await sock.sendMessage(remoteJid, { + text: `❌ *Konfigurasi tidak lengkap*\n\nURL GenieACS tidak dikonfigurasi` + }); + return; + } + + // Kirim pesan bahwa proses sedang berlangsung + await sock.sendMessage(remoteJid, { + text: `⏳ *Proses pencarian perangkat*\n\nSedang mencari perangkat dengan PPPoE Username ${pppoeUsername}...` + }); + + // Cari perangkat berdasarkan PPPoE Username + const device = await findDeviceByPPPoE(pppoeUsername); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: `❌ *Perangkat Tidak Ditemukan*\n\nTidak dapat menemukan perangkat dengan PPPoE Username ${pppoeUsername}` + }); + return; + } + + // Dapatkan device ID + const deviceId = device._id; + + // Kirim pesan bahwa proses sedang berlangsung + await sock.sendMessage(remoteJid, { + text: `⏳ *Proses penambahan tag*\n\nSedang menambahkan nomor ${customerNumber} ke perangkat ${deviceId}...` + }); + + // Persiapkan URL untuk menambahkan tag + const tagUrl = `${genieacsUrl}/devices/${deviceId}/tags/${customerNumber}`; + + // Kirim request ke GenieACS untuk menambahkan tag + try { + const response = await axios.post( + tagUrl, + {}, + { + auth: { + username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME, + password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD + } + } + ); + + logger.info(`Tag response: ${response.status}`); + + // Kirim pesan sukses + let successMessage = `✅ *Tag berhasil ditambahkan*\n\n`; + successMessage += `📱 *Nomor Pelanggan:* ${customerNumber}\n`; + successMessage += `👤 *PPPoE Username:* ${pppoeUsername}\n`; + successMessage += `🖥️ *Device ID:* ${deviceId}\n\n`; + successMessage += `Pelanggan sekarang dapat menggunakan WhatsApp dan Web Portal dengan nomor tersebut.`; + + await sock.sendMessage(remoteJid, { text: successMessage }); + + } catch (error) { + logger.error('Error adding tag to GenieACS:', error); + + let errorMessage = `❌ *Gagal menambahkan tag*\n\n`; + if (error.response) { + errorMessage += `Status: ${error.response.status}\n`; + errorMessage += `Pesan: ${JSON.stringify(error.response.data)}\n`; + } else { + errorMessage += `Error: ${error.message}\n`; + } + + await sock.sendMessage(remoteJid, { text: errorMessage }); + } + + } catch (error) { + logger.error('Error in addTagByPPPoE:', error); + + await sock.sendMessage(remoteJid, { + text: `❌ *Error*\n\nTerjadi kesalahan saat menambahkan tag: ${error.message}` + }); + } +} + +// Fungsi untuk mencari perangkat berdasarkan PPPoE Username +async function findDeviceByPPPoE(pppoeUsername) { + try { + // Dapatkan URL GenieACS + const genieacsUrl = global.appSettings.genieacsUrl || process.env.GENIEACS_URL; + if (!genieacsUrl) { + logger.error('GenieACS URL not configured'); + return null; + } + + // Parameter paths untuk PPPoE Username + const pppUsernamePaths = [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username._value', + 'VirtualParameters.pppoeUsername', + 'VirtualParameters.pppUsername' + ]; + + // Buat query untuk mencari perangkat berdasarkan PPPoE Username + // Kita perlu membuat query yang mencari di semua kemungkinan path + const queryObj = { $or: [] }; + + // Tambahkan semua kemungkinan path ke query + for (const path of pppUsernamePaths) { + const pathQuery = {}; + pathQuery[path] = pppoeUsername; + queryObj.$or.push(pathQuery); + } + + const queryJson = JSON.stringify(queryObj); + const encodedQuery = encodeURIComponent(queryJson); + + // Ambil perangkat dari GenieACS + const response = await axios.get(`${genieacsUrl}/devices/?query=${encodedQuery}`, { + auth: { + username: global.appSettings.genieacsUsername || process.env.GENIEACS_USERNAME, + password: global.appSettings.genieacsPassword || process.env.GENIEACS_PASSWORD + }, + headers: { + 'Accept': 'application/json' + } + }); + + if (response.data && response.data.length > 0) { + return response.data[0]; + } + + return null; + } catch (error) { + logger.error(`Error finding device by PPPoE Username: ${error.message}`); + return null; + } +} + +module.exports = { + setSock, + addCustomerTag, + findDeviceById, + addTagByPPPoE +}; diff --git a/config/database.js b/config/database.js new file mode 100644 index 0000000..9162252 --- /dev/null +++ b/config/database.js @@ -0,0 +1,1076 @@ +/** + * Inisialisasi database SQLite untuk billing OPEN-ISP + */ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const dbDir = path.join(__dirname, '../database'); +if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }); + +const dbPath = path.join(dbDir, 'billing.db'); + +let db; +try { + db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.pragma('cache_size = -64000'); // 64MB page cache (negatif = KB) + db.pragma('synchronous = NORMAL'); // Lebih cepat dari FULL, masih aman dengan WAL + db.pragma('temp_store = MEMORY'); // Tabel temp di RAM + db.pragma('mmap_size = 134217728'); // Memory-mapped I/O 128MB + db.pragma('busy_timeout = 5000'); // Tunggu 5 detik jika DB locked sebelum error + + // Menambahkan fungsi waktu lokal untuk SQLite sesuai setting timezone + db.function('NOW_LOCAL', () => { + const { getSetting } = require('./settingsManager'); + const tz = getSetting('timezone', 'Asia/Jakarta'); + const now = new Date(); + + // Format: YYYY-MM-DD HH:mm:ss + const options = { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }; + + const formatter = new Intl.DateTimeFormat('en-US', options); + const parts = formatter.formatToParts(now); + const p = {}; + parts.forEach(part => p[part.type] = part.value); + + return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`; + }); +} catch (err) { + console.error('[DB] Gagal membuka database:', err.message); + process.exit(1); +} + +db.exec(` + CREATE TABLE IF NOT EXISTS areas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS expense_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + parent_id INTEGER REFERENCES expense_categories(id), + description TEXT, + icon TEXT DEFAULT 'bi bi-tag', + color TEXT DEFAULT '#6366f1', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + category TEXT NOT NULL, + subcategory TEXT, + amount INTEGER NOT NULL, + description TEXT NOT NULL, + vendor TEXT, + receipt_number TEXT, + payment_method TEXT DEFAULT 'cash', + recorded_by_role TEXT, + recorded_by_name TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS cash_in ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + category TEXT NOT NULL, + amount INTEGER NOT NULL, + description TEXT, + reference_type TEXT, + reference_id INTEGER, + receipt_number TEXT, + payment_method TEXT DEFAULT 'cash', + recorded_by_role TEXT, + recorded_by_name TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL DEFAULT 0, + speed_down INTEGER DEFAULT 0, + speed_up INTEGER DEFAULT 0, + description TEXT DEFAULT '', + billing_type TEXT DEFAULT 'postpaid', + duration_days INTEGER DEFAULT 30, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nik TEXT DEFAULT '', + name TEXT NOT NULL, + phone TEXT DEFAULT '', + address TEXT DEFAULT '', + area TEXT DEFAULT '', + package_id INTEGER REFERENCES packages(id) ON DELETE SET NULL, + genieacs_tag TEXT DEFAULT '', + pppoe_username TEXT DEFAULT '', + isolir_profile TEXT DEFAULT 'isolir', + status TEXT DEFAULT 'active', + install_date DATE, + expired_at DATETIME DEFAULT NULL, + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS technicians ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + name TEXT NOT NULL, + phone TEXT DEFAULT '', + area TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS cashiers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + name TEXT NOT NULL, + phone TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS collectors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + name TEXT NOT NULL, + phone TEXT DEFAULT '', + area TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + auto_approve INTEGER DEFAULT 0, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS collector_payment_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collector_id INTEGER NOT NULL REFERENCES collectors(id) ON DELETE CASCADE, + invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + amount INTEGER NOT NULL DEFAULT 0, + note TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, rejected + decided_by_role TEXT DEFAULT '', -- admin, cashier + decided_by_name TEXT DEFAULT '', + decided_note TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()), + decided_at DATETIME + ); + + CREATE TABLE IF NOT EXISTS invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + period_month INTEGER NOT NULL, + period_year INTEGER NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + status TEXT DEFAULT 'unpaid', + paid_at DATETIME, + paid_by_name TEXT DEFAULT '', + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + message TEXT NOT NULL, + status TEXT DEFAULT 'open', -- open, in_progress, resolved + technician_id INTEGER REFERENCES technicians(id) ON DELETE SET NULL, + technician_notes TEXT DEFAULT '', + photos TEXT DEFAULT '', -- JSON array of photo paths + photo_metadata TEXT DEFAULT '', -- JSON array of metadata (timestamp, gps, etc) + customer_photos TEXT DEFAULT '', -- JSON array of customer uploaded photos + customer_photo_metadata TEXT DEFAULT '', -- JSON array of customer photo metadata + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS routers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER DEFAULT 8728, + user TEXT NOT NULL, + password TEXT NOT NULL, + description TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS promo_slides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT DEFAULT '', + description TEXT DEFAULT '', + image_path TEXT NOT NULL, + url TEXT DEFAULT '', + open_in_new_tab INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + start_date DATE, + end_date DATE, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS olts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + host TEXT NOT NULL, + snmp_community TEXT DEFAULT 'public', + snmp_port INTEGER DEFAULT 161, + brand TEXT DEFAULT 'zte', -- zte, huawei, vsol, hioso, hsqg, etc. + description TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS odps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + olt_id INTEGER REFERENCES olts(id) ON DELETE SET NULL, + pon_port TEXT DEFAULT '', + port_capacity INTEGER NOT NULL DEFAULT 16, + lat TEXT, + lng TEXT, + description TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS voucher_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL, + profile_name TEXT NOT NULL, + qty_total INTEGER NOT NULL DEFAULT 0, + qty_created INTEGER NOT NULL DEFAULT 0, + qty_failed INTEGER NOT NULL DEFAULT 0, + price INTEGER NOT NULL DEFAULT 0, + validity TEXT DEFAULT '', + prefix TEXT DEFAULT '', + code_length INTEGER NOT NULL DEFAULT 4, + status TEXT DEFAULT 'creating', + created_by TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS vouchers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL REFERENCES voucher_batches(id) ON DELETE CASCADE, + router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL, + code TEXT NOT NULL, + password TEXT NOT NULL, + profile_name TEXT NOT NULL, + comment TEXT DEFAULT '', + status TEXT DEFAULT 'pending', + used_at DATETIME, + last_seen_comment TEXT DEFAULT '', + last_seen_uptime TEXT DEFAULT '', + last_seen_at DATETIME, + created_at DATETIME DEFAULT (NOW_LOCAL()), + UNIQUE(router_id, code) + ); + + CREATE TABLE IF NOT EXISTS public_voucher_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL, + profile_name TEXT NOT NULL, + validity TEXT DEFAULT '', + price INTEGER NOT NULL DEFAULT 0, + buyer_phone TEXT NOT NULL, + status TEXT DEFAULT 'pending', + paid_at DATETIME, + fulfilled_at DATETIME, + voucher_code TEXT DEFAULT '', + voucher_password TEXT DEFAULT '', + voucher_comment TEXT DEFAULT '', + wa_sent INTEGER NOT NULL DEFAULT 0, + wa_sent_at DATETIME, + wa_error TEXT DEFAULT '', + payment_gateway TEXT DEFAULT '', + payment_order_id TEXT DEFAULT '', + payment_link TEXT DEFAULT '', + payment_reference TEXT DEFAULT '', + payment_payload TEXT, + payment_expires_at DATETIME, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + + + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + + + + + CREATE TABLE IF NOT EXISTS webhook_payment_notifs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service TEXT DEFAULT '', + content TEXT NOT NULL, + parsed_amount INTEGER, + parsed_ok INTEGER NOT NULL DEFAULT 0, + matched_invoice_id INTEGER, + matched_voucher_order_id INTEGER, + ip TEXT DEFAULT '', + user_agent TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE INDEX IF NOT EXISTS idx_voucher_batches_router ON voucher_batches(router_id); + CREATE INDEX IF NOT EXISTS idx_vouchers_batch ON vouchers(batch_id); + CREATE INDEX IF NOT EXISTS idx_vouchers_code ON vouchers(code); + CREATE INDEX IF NOT EXISTS idx_public_voucher_orders_status ON public_voucher_orders(status); + CREATE INDEX IF NOT EXISTS idx_public_voucher_orders_created ON public_voucher_orders(created_at); + + + CREATE INDEX IF NOT EXISTS idx_collectors_username ON collectors(username); + CREATE INDEX IF NOT EXISTS idx_collector_pay_req_status ON collector_payment_requests(status); + CREATE INDEX IF NOT EXISTS idx_collector_pay_req_invoice ON collector_payment_requests(invoice_id); + CREATE INDEX IF NOT EXISTS idx_collector_pay_req_collector ON collector_payment_requests(collector_id); + CREATE INDEX IF NOT EXISTS idx_collector_pay_req_created ON collector_payment_requests(created_at); + + CREATE INDEX IF NOT EXISTS idx_webhook_payment_notifs_created ON webhook_payment_notifs(created_at); + CREATE INDEX IF NOT EXISTS idx_webhook_payment_notifs_service ON webhook_payment_notifs(service); + + -- ─── INVENTORY / WAREHOUSE ─────────────────────────────────────────── + CREATE TABLE IF NOT EXISTS inventory_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS inventory_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER REFERENCES inventory_categories(id) ON DELETE SET NULL, + name TEXT NOT NULL, + brand TEXT DEFAULT '', + model TEXT DEFAULT '', + unit TEXT DEFAULT 'pcs', -- pcs, meter, roll, etc. + min_stock INTEGER DEFAULT 5, + description TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS inventory_stock ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE, + serial_number TEXT UNIQUE, -- Optional, for items like ONT/Router + quantity INTEGER NOT NULL DEFAULT 0, + condition TEXT DEFAULT 'new', -- new, used, broken + location TEXT DEFAULT 'Gudang Utama', + status TEXT DEFAULT 'available', -- available, assigned, broken, lost + assigned_to_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + note TEXT DEFAULT '', + updated_at DATETIME DEFAULT (NOW_LOCAL()), + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS inventory_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER REFERENCES inventory_items(id) ON DELETE SET NULL, + stock_id INTEGER REFERENCES inventory_stock(id) ON DELETE SET NULL, + type TEXT NOT NULL, -- in (stock masuk), out (stock keluar/dipakai), adjust (penyesuaian), broken, return + quantity INTEGER NOT NULL DEFAULT 0, + actor TEXT DEFAULT 'Admin', + note TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE INDEX IF NOT EXISTS idx_inventory_items_cat ON inventory_items(category_id); + CREATE INDEX IF NOT EXISTS idx_inventory_stock_item ON inventory_stock(item_id); + CREATE INDEX IF NOT EXISTS idx_inventory_stock_sn ON inventory_stock(serial_number); + CREATE INDEX IF NOT EXISTS idx_inventory_logs_item ON inventory_logs(item_id); + CREATE INDEX IF NOT EXISTS idx_inventory_logs_created ON inventory_logs(created_at); + + CREATE TABLE IF NOT EXISTS genieacs_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + cwmp_url TEXT, + username TEXT, + password TEXT, + location TEXT DEFAULT '', + status TEXT DEFAULT 'active', + device_count INTEGER DEFAULT 0, + last_sync DATETIME, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS wa_chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + direction TEXT NOT NULL DEFAULT 'outbound', + gateway TEXT NOT NULL DEFAULT 'baileys', + sender_phone TEXT, + recipient_phone TEXT, + customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL, + customer_name TEXT, + message_text TEXT NOT NULL, + status TEXT DEFAULT 'sent', + meta_message_id TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + CREATE INDEX IF NOT EXISTS idx_wa_chat_messages_phone ON wa_chat_messages(sender_phone, recipient_phone); + CREATE INDEX IF NOT EXISTS idx_wa_chat_messages_created ON wa_chat_messages(created_at); +`); + +// Inisialisasi tabel voucher_packages (Paket Voucher Hotspot Real-time) +db.exec(` + CREATE TABLE IF NOT EXISTS voucher_packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL, + profile_name TEXT NOT NULL, + price INTEGER NOT NULL DEFAULT 0, + validity TEXT DEFAULT '', + prefix TEXT DEFAULT '', + code_length INTEGER NOT NULL DEFAULT 6, + charset TEXT DEFAULT 'mixed', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()), + UNIQUE(router_id, profile_name) + ); + + CREATE TABLE IF NOT EXISTS radius_nas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nasname TEXT NOT NULL UNIQUE, + shortname TEXT DEFAULT '', + secret TEXT NOT NULL, + description TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS radius_accounting ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + nas_ip TEXT DEFAULT '', + framed_ip TEXT DEFAULT '', + session_id TEXT NOT NULL, + status_type INTEGER DEFAULT 1, + input_octets INTEGER DEFAULT 0, + output_octets INTEGER DEFAULT 0, + input_gigawords INTEGER DEFAULT 0, + output_gigawords INTEGER DEFAULT 0, + session_time INTEGER DEFAULT 0, + terminate_cause INTEGER DEFAULT 0, + calling_station_id TEXT DEFAULT '', + called_station_id TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE INDEX IF NOT EXISTS idx_radius_acct_user ON radius_accounting(username); + CREATE INDEX IF NOT EXISTS idx_radius_acct_session ON radius_accounting(session_id); + CREATE INDEX IF NOT EXISTS idx_radius_acct_status ON radius_accounting(status_type); +`); + + +/** + * Memastikan menu-menu utama (WA, Settings, dll) selalu terbuka (Visible) + * meskipun setelah update dari GitHub yang mungkin mengunci menu tersebut secara default. + */ +function forceUnlockCoreMenus() { + try { + const SETTINGS_KEY = 'sidebar_menu_states'; + const KEYS_KEY = 'sidebar_activation_keys'; + + // Ambil status menu saat ini dari DB + const rowStates = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(SETTINGS_KEY); + const rowKeys = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(KEYS_KEY); + + let states = rowStates ? JSON.parse(rowStates.value) : {}; + let keys = rowKeys ? JSON.parse(rowKeys.value) : {}; + + const coreMenus = ['mikrotik', 'radius_server', 'whatsapp', 'broadcast', 'update', 'settings', 'backup', 'monitoring', 'audit_logs']; + const passwordHash = 'af03458614be223016c5bc1c0788eaefc6a6bfd0c11cdcd4bc520f49d0cb474a'; // hash kode aktivasi default — plaintext hanya di settings.json (activation_code) + + const crypto = require('crypto'); + function sha256(input) { + return crypto.createHash('sha256').update(String(input || '')).digest('hex'); + } + + let changed = false; + for (const menu of coreMenus) { + // Paksa status jadi visible + if (states[menu] !== 'visible') { + states[menu] = 'visible'; + changed = true; + } + // Pastikan ada kunci aktivasi yang valid agar kode lama tetap membukanya + const validKey = sha256(menu + passwordHash); + if (keys[menu] !== validKey) { + keys[menu] = validKey; + changed = true; + } + } + + if (changed) { + const now = new Date().toISOString(); + db.prepare('INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)').run(SETTINGS_KEY, JSON.stringify(states), now); + db.prepare('INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)').run(KEYS_KEY, JSON.stringify(keys), now); + console.log('[DB] Core menus have been force-unlocked.'); + } + } catch (e) { + console.error('[DB] Gagal force unlock core menus:', e.message); + } +} + +// Jalankan force unlock setiap kali database diinisialisasi +forceUnlockCoreMenus(); + +// Tabel voucher diskon (dipakai voucherService) — WAJIB utk instalasi baru +try { + db.exec(`CREATE TABLE IF NOT EXISTS discount_vouchers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + type TEXT DEFAULT 'nominal', + value INTEGER DEFAULT 0, + max_discount INTEGER DEFAULT 0, + min_purchase INTEGER DEFAULT 0, + active INTEGER DEFAULT 1, + max_uses INTEGER DEFAULT 0, + used_count INTEGER DEFAULT 0, + note TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()) + )`); +} catch (e) { /* ignore */ } + +// Tambahkan kolom baru jika belum ada +// Kolom pendaftaran (reg_*) — WAJIB utk instalasi baru (fresh DB), mis. "no such column: reg_status" +try { db.exec("ALTER TABLE customers ADD COLUMN reg_voucher_code TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE customers ADD COLUMN reg_install_fee INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE customers ADD COLUMN reg_prorata INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE customers ADD COLUMN reg_discount INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE customers ADD COLUMN reg_total INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE customers ADD COLUMN reg_due_date TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE customers ADD COLUMN reg_status TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ } +// Kolom invoices (pembayaran/QRIS/diskon/void) — WAJIB utk instalasi baru +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_gateway TEXT"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_order_id TEXT"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_link TEXT"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_reference TEXT"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_payload TEXT"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_expires_at DATETIME"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN qris_unique_code INTEGER"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN qris_amount_unique INTEGER"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN qris_assigned_at DATETIME"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN qris_paid_notif_id INTEGER"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN discount_type TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN discount_value REAL DEFAULT 0"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN discount_amount INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN amount_before_discount INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN voided_at DATETIME"); } catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE invoices ADD COLUMN void_reason TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN auto_isolate INTEGER DEFAULT 1"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN isolate_day INTEGER DEFAULT 10"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN email TEXT DEFAULT ''"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN olt_id INTEGER REFERENCES olts(id) ON DELETE SET NULL"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN pon_port TEXT DEFAULT ''"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN odp_id INTEGER REFERENCES odps(id) ON DELETE SET NULL"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN lat TEXT"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN lng TEXT"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN cable_path TEXT"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN connection_type TEXT DEFAULT 'pppoe'"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN static_ip TEXT"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN mac_address TEXT"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN hotspot_username TEXT DEFAULT ''"); +} catch (e) {} +try { + db.exec("ALTER TABLE customers ADD COLUMN hotspot_password TEXT DEFAULT ''"); +} catch (e) {} +try { + db.exec("ALTER TABLE customers ADD COLUMN hotspot_profile TEXT DEFAULT ''"); +} catch (e) {} +try { + db.exec("ALTER TABLE customers ADD COLUMN pppoe_password TEXT DEFAULT ''"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN pppoe_remote_address TEXT DEFAULT ''"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN wifi_ssid TEXT DEFAULT ''"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN password_wifi TEXT DEFAULT ''"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN nik TEXT DEFAULT ''"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE customers ADD COLUMN collector_id INTEGER REFERENCES collectors(id) ON DELETE SET NULL"); +} catch (e) { /* ignore if already exists */ } +try { + db.exec("ALTER TABLE collectors ADD COLUMN auto_approve INTEGER DEFAULT 0"); +} catch (e) { /* ignore if already exists */ } +try { db.exec("ALTER TABLE odps ADD COLUMN port_capacity INTEGER NOT NULL DEFAULT 16"); } catch (e) { /* ignore if already exists */ } + +// Kolom untuk PPN & ULO/USO pada tabel packages +try { db.exec("ALTER TABLE packages ADD COLUMN use_ppn INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN ppn_percentage REAL DEFAULT 11.0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN use_uso INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN uso_percentage REAL DEFAULT 1.75"); } catch (e) {} + +// Kolom untuk Prepaid / Prabayar pada packages dan customers +try { db.exec("ALTER TABLE packages ADD COLUMN billing_type TEXT DEFAULT 'postpaid'"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN duration_days INTEGER DEFAULT 30"); } catch (e) {} +try { db.exec("ALTER TABLE customers ADD COLUMN expired_at DATETIME DEFAULT NULL"); } catch (e) {} + +// Kolom untuk Tiket Bantuan (Foto & Catatan Teknisi) +try { db.exec("ALTER TABLE tickets ADD COLUMN technician_notes TEXT DEFAULT ''"); } catch (e) {} +try { db.exec("ALTER TABLE tickets ADD COLUMN photos TEXT DEFAULT ''"); } catch (e) {} +try { db.exec("ALTER TABLE tickets ADD COLUMN photo_metadata TEXT DEFAULT ''"); } catch (e) {} +try { db.exec("ALTER TABLE tickets ADD COLUMN customer_photos TEXT DEFAULT ''"); } catch (e) {} +try { db.exec("ALTER TABLE tickets ADD COLUMN customer_photo_metadata TEXT DEFAULT ''"); } catch (e) {} + +// Kolom untuk Payment Gateway di tabel invoices +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_gateway TEXT"); } catch (e) {} +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_order_id TEXT"); } catch (e) {} +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_link TEXT"); } catch (e) {} +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_reference TEXT"); } catch (e) {} +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_payload TEXT"); } catch (e) {} +try { db.exec("ALTER TABLE invoices ADD COLUMN payment_expires_at DATETIME"); } catch (e) {} + +// Kolom untuk QRIS statis (semi-otomatis via nominal unik) +try { db.exec("ALTER TABLE invoices ADD COLUMN qris_unique_code INTEGER"); } catch (e) {} +try { db.exec("ALTER TABLE invoices ADD COLUMN qris_amount_unique INTEGER"); } catch (e) {} +try { db.exec("ALTER TABLE invoices ADD COLUMN qris_assigned_at DATETIME"); } catch (e) {} +try { db.exec("ALTER TABLE invoices ADD COLUMN qris_paid_notif_id INTEGER"); } catch (e) {} + +// Kolom untuk QRIS statis pada voucher publik +try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN qris_unique_code INTEGER"); } catch (e) {} +try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN qris_amount_unique INTEGER"); } catch (e) {} +try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN qris_assigned_at DATETIME"); } catch (e) {} +try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN qris_paid_notif_id INTEGER"); } catch (e) {} +try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN proof_url TEXT DEFAULT ''"); } catch (e) {} + +// Kolom untuk Login OLT (Web/API) +try { db.exec("ALTER TABLE olts ADD COLUMN web_user TEXT DEFAULT 'admin'"); } catch (e) {} +try { db.exec("ALTER TABLE olts ADD COLUMN web_password TEXT DEFAULT 'admin'"); } catch (e) {} +try { db.exec("ALTER TABLE olts ADD COLUMN api_base_url TEXT"); } catch (e) {} +try { db.exec("ALTER TABLE olts ADD COLUMN telnet_port INTEGER DEFAULT 23"); } catch (e) {} +try { db.exec("ALTER TABLE olts ADD COLUMN enable_password TEXT"); } catch (e) {} + +try { db.exec("ALTER TABLE voucher_batches ADD COLUMN updated_at DATETIME DEFAULT (NOW_LOCAL())"); } catch (e) {} +try { db.exec("ALTER TABLE vouchers ADD COLUMN last_seen_comment TEXT DEFAULT ''"); } catch (e) {} +try { db.exec("ALTER TABLE vouchers ADD COLUMN last_seen_uptime TEXT DEFAULT ''"); } catch (e) {} +try { db.exec("ALTER TABLE vouchers ADD COLUMN last_seen_at DATETIME"); } catch (e) {} +try { db.exec("ALTER TABLE voucher_batches ADD COLUMN mode TEXT DEFAULT 'voucher'"); } catch (e) {} +try { db.exec("ALTER TABLE voucher_batches ADD COLUMN charset TEXT DEFAULT 'numbers'"); } catch (e) {} + +// Relasi notifikasi webhook → invoice (untuk audit) +try { db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_invoice_id INTEGER"); } catch (e) {} +try { db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_voucher_order_id INTEGER"); } catch (e) {} + + +// Kolom untuk Dynamic Speed & FUP di tabel packages +try { db.exec("ALTER TABLE packages ADD COLUMN night_speed_down INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN night_speed_up INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN fup_limit_gb INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN fup_speed_down INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN use_night_speed INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN night_profile_name TEXT"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN use_fup INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN fup_profile_name TEXT"); } catch (e) {} + +// Promo harga & prorata tagihan pertama (per paket + counter per pelanggan) +try { db.exec("ALTER TABLE packages ADD COLUMN promo_price INTEGER"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN promo_cycles INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN prorate_first_invoice INTEGER DEFAULT 0"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL"); } catch (e) {} +try { db.exec("ALTER TABLE packages ADD COLUMN router_id INTEGER"); } catch (e) {} +try { db.exec("ALTER TABLE customers ADD COLUMN promo_cycles_used INTEGER DEFAULT 0"); } catch (e) {} + +// Tabel untuk Tracking Pemakaian (Usage) Pelanggan +db.exec(` + CREATE TABLE IF NOT EXISTS customer_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE, + period_month INTEGER NOT NULL, + period_year INTEGER NOT NULL, + bytes_in INTEGER DEFAULT 0, + bytes_out INTEGER DEFAULT 0, + last_total_bytes_in INTEGER DEFAULT 0, -- Untuk menghitung delta + last_total_bytes_out INTEGER DEFAULT 0, + updated_at DATETIME DEFAULT (NOW_LOCAL()), + UNIQUE(customer_id, period_month, period_year) + ); + CREATE INDEX IF NOT EXISTS idx_usage_customer ON customer_usage(customer_id); + CREATE INDEX IF NOT EXISTS idx_usage_period ON customer_usage(period_month, period_year); +`); + +db.exec(` + + +`); + +db.exec(` + +`); + +// ─── ATTENDANCE / ABSENSI KARYAWAN ─────────────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS attendance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + employee_type TEXT NOT NULL, -- technician, admin, cashier, collector + employee_id INTEGER NOT NULL, + employee_name TEXT NOT NULL, + check_in_time DATETIME NOT NULL, + check_in_lat TEXT DEFAULT '', + check_in_lng TEXT DEFAULT '', + check_in_note TEXT DEFAULT '', + check_in_photo TEXT DEFAULT '', -- Path to check-in photo + check_out_time DATETIME, + check_out_lat TEXT DEFAULT '', + check_out_lng TEXT DEFAULT '', + check_out_note TEXT DEFAULT '', + check_out_photo TEXT DEFAULT '', -- Path to check-out photo + work_duration_minutes INTEGER DEFAULT 0, + status TEXT DEFAULT 'checked_in', -- checked_in, checked_out + created_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE INDEX IF NOT EXISTS idx_attendance_employee ON attendance(employee_type, employee_id); + CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date(check_in_time)); + CREATE INDEX IF NOT EXISTS idx_attendance_status ON attendance(status); +`); + +try { + const attendanceColumns = db.prepare('PRAGMA table_info(attendance)').all().map(c => c.name); + if (!attendanceColumns.includes('check_in_photo')) { + db.exec("ALTER TABLE attendance ADD COLUMN check_in_photo TEXT DEFAULT ''"); + } + if (!attendanceColumns.includes('check_out_photo')) { + db.exec("ALTER TABLE attendance ADD COLUMN check_out_photo TEXT DEFAULT ''"); + } +} catch (e) {} + +/** + * Helper untuk App Settings (Database) + */ +const getAppSetting = (key, defaultValue = null) => { + try { + const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key); + if (!row) return defaultValue; + try { return JSON.parse(row.value); } catch { return row.value; } + } catch (e) { + return defaultValue; + } +}; + +const saveAppSetting = (key, value) => { + try { + const jsonValue = JSON.stringify(value); + db.prepare('INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, (NOW_LOCAL()))').run(key, jsonValue); + return true; + } catch (e) { + console.error(`[DB] Error saving setting ${key}:`, e.message); + return false; + } +}; + +// ─── PAYROLL / GAJI KARYAWAN ───────────────────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS payroll_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + employee_type TEXT NOT NULL, + employee_id INTEGER NOT NULL, + base_salary INTEGER DEFAULT 0, + transport_allowance INTEGER DEFAULT 0, + meal_allowance INTEGER DEFAULT 0, + phone_allowance INTEGER DEFAULT 0, + other_allowance INTEGER DEFAULT 0, + other_allowance_note TEXT DEFAULT '', + absence_deduction_per_day INTEGER DEFAULT 0, + bonus_per_ticket INTEGER DEFAULT 0, + commission_percentage REAL DEFAULT 0, + working_days_per_month INTEGER DEFAULT 26, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()), + UNIQUE(employee_type, employee_id) + ); + + CREATE TABLE IF NOT EXISTS payroll_slips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + employee_type TEXT NOT NULL, + employee_id INTEGER NOT NULL, + employee_name TEXT NOT NULL, + period_month INTEGER NOT NULL, + period_year INTEGER NOT NULL, + base_salary INTEGER DEFAULT 0, + transport_allowance INTEGER DEFAULT 0, + meal_allowance INTEGER DEFAULT 0, + phone_allowance INTEGER DEFAULT 0, + other_allowance INTEGER DEFAULT 0, + other_allowance_note TEXT DEFAULT '', + working_days INTEGER DEFAULT 0, + absent_days INTEGER DEFAULT 0, + late_days INTEGER DEFAULT 0, + overtime_hours REAL DEFAULT 0, + total_tickets_resolved INTEGER DEFAULT 0, + total_collection_amount INTEGER DEFAULT 0, + ticket_bonus INTEGER DEFAULT 0, + collection_commission INTEGER DEFAULT 0, + overtime_bonus INTEGER DEFAULT 0, + absence_deduction INTEGER DEFAULT 0, + late_deduction INTEGER DEFAULT 0, + other_deduction INTEGER DEFAULT 0, + other_deduction_note TEXT DEFAULT '', + gross_salary INTEGER DEFAULT 0, + total_deductions INTEGER DEFAULT 0, + net_salary INTEGER DEFAULT 0, + status TEXT DEFAULT 'draft', + approved_at DATETIME, + paid_at DATETIME, + notes TEXT DEFAULT '', + created_at DATETIME DEFAULT (NOW_LOCAL()), + UNIQUE(employee_type, employee_id, period_month, period_year) + ); + + CREATE INDEX IF NOT EXISTS idx_payroll_settings_emp ON payroll_settings(employee_type, employee_id); + CREATE INDEX IF NOT EXISTS idx_payroll_slips_emp ON payroll_slips(employee_type, employee_id); + CREATE INDEX IF NOT EXISTS idx_payroll_slips_period ON payroll_slips(period_month, period_year); + CREATE INDEX IF NOT EXISTS idx_payroll_slips_status ON payroll_slips(status); +`); + +// ─── BUILT-IN ACS (TR-069) ────────────────────────────────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS acs_devices ( + id TEXT PRIMARY KEY, + serial_number TEXT, + manufacturer TEXT, + product_class TEXT, + oui TEXT, + software_version TEXT, + hardware_version TEXT, + ip_address TEXT, + connection_request_url TEXT, + connection_request_user TEXT, + connection_request_pass TEXT, + tags TEXT DEFAULT '[]', + params TEXT DEFAULT '{}', + last_inform DATETIME, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE TABLE IF NOT EXISTS acs_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + name TEXT NOT NULL, + payload TEXT DEFAULT '{}', + status TEXT DEFAULT 'pending', + result TEXT, + retry_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + + CREATE INDEX IF NOT EXISTS idx_acs_devices_sn ON acs_devices(serial_number); + CREATE INDEX IF NOT EXISTS idx_acs_devices_inform ON acs_devices(last_inform); + CREATE INDEX IF NOT EXISTS idx_acs_tasks_device ON acs_tasks(device_id); + CREATE INDEX IF NOT EXISTS idx_acs_tasks_status ON acs_tasks(status); +`); + +// Tambahkan kategori pengeluaran default jika belum ada +try { + db.exec(` + INSERT OR IGNORE INTO expense_categories (name, parent_id, description, icon, color) VALUES + ('Perangkat', NULL, 'Pembelian perangkat jaringan', 'bi bi-router', '#3b82f6'), + ('Utilitas', NULL, 'Biaya utilitas (listrik, internet, dll)', 'bi bi-lightning-charge', '#f59e0b'), + ('Gaji & Tunjangan', NULL, 'Gaji karyawan dan tunjangan', 'bi bi-wallet2', '#10b981'), + ('Maintenance', NULL, 'Biaya perawatan dan perbaikan', 'bi bi-tools', '#ef4444'), + ('Operasional', NULL, 'Biaya operasional lainnya', 'bi bi-briefcase', '#8b5cf6'); + `); +} catch (e) {} + + +// Safe migration: tambah kolom balance, area, dan is_radius ke tabel customers, collectors, dan technicians +try { + const custCols = db.prepare("PRAGMA table_info(customers)").all(); + if (!custCols.find(c => c.name === 'balance')) { + db.exec("ALTER TABLE customers ADD COLUMN balance INTEGER NOT NULL DEFAULT 0"); + } + if (!custCols.find(c => c.name === 'area')) { + db.exec("ALTER TABLE customers ADD COLUMN area TEXT DEFAULT ''"); + } + if (!custCols.find(c => c.name === 'is_radius')) { + db.exec("ALTER TABLE customers ADD COLUMN is_radius INTEGER DEFAULT 1"); + } + if (!custCols.find(c => c.name === 'customer_code')) { + db.exec("ALTER TABLE customers ADD COLUMN customer_code TEXT DEFAULT ''"); + } + try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_code ON customers(customer_code) WHERE customer_code IS NOT NULL AND customer_code <> ''"); } catch (e) {} + + const colCols = db.prepare("PRAGMA table_info(collectors)").all(); + if (!colCols.find(c => c.name === 'area')) { + db.exec("ALTER TABLE collectors ADD COLUMN area TEXT DEFAULT ''"); + } + + const techCols = db.prepare("PRAGMA table_info(technicians)").all(); + if (!techCols.find(c => c.name === 'area')) { + db.exec("ALTER TABLE technicians ADD COLUMN area TEXT DEFAULT ''"); + } + + const pkgCols = db.prepare("PRAGMA table_info(packages)").all(); + if (!pkgCols.find(c => c.name === 'speed_down_upto')) { + db.exec("ALTER TABLE packages ADD COLUMN speed_down_upto INTEGER DEFAULT 0"); + } + if (!pkgCols.find(c => c.name === 'speed_up_upto')) { + db.exec("ALTER TABLE packages ADD COLUMN speed_up_upto INTEGER DEFAULT 0"); + } +} catch(e) { + console.error('Failed to migrate customer columns:', e); +} + +// Safe migration: sync area-area yang sudah pernah diinput ke tabel areas +try { + const hasCustArea = db.prepare("PRAGMA table_info(customers)").all().some(c => c.name === 'area'); + const hasColArea = db.prepare("PRAGMA table_info(collectors)").all().some(c => c.name === 'area'); + const hasTechArea = db.prepare("PRAGMA table_info(technicians)").all().some(c => c.name === 'area'); + + const syncQueries = []; + if (hasCustArea) syncQueries.push("SELECT area FROM customers WHERE area IS NOT NULL AND TRIM(area) != ''"); + if (hasColArea) syncQueries.push("SELECT area FROM collectors WHERE area IS NOT NULL AND TRIM(area) != ''"); + if (hasTechArea) syncQueries.push("SELECT area FROM technicians WHERE area IS NOT NULL AND TRIM(area) != ''"); + + if (syncQueries.length > 0) { + db.exec(` + INSERT OR IGNORE INTO areas (name) + SELECT DISTINCT TRIM(area) FROM ( + ` + syncQueries.join(' UNION ') + ` + ); + `); + } +} catch(e) {} + +// Safe migration: public_donation_orders table & indexes +try { + db.exec(` + CREATE TABLE IF NOT EXISTS public_donation_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + donor_name TEXT, + donor_phone TEXT NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + qris_amount_unique INTEGER, + qris_unique_code INTEGER, + notes TEXT, + status TEXT DEFAULT 'pending', + paid_at DATETIME, + qris_paid_notif_id INTEGER, + activation_code TEXT DEFAULT '', + wa_sent INTEGER DEFAULT 0, + wa_sent_at DATETIME, + wa_error TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) + ); + CREATE INDEX IF NOT EXISTS idx_donation_orders_status ON public_donation_orders(status); + CREATE INDEX IF NOT EXISTS idx_donation_orders_unique ON public_donation_orders(qris_amount_unique, status); + `); + + const colNotifs = db.prepare("PRAGMA table_info(webhook_payment_notifs)").all(); + if (colNotifs.length > 0 && !colNotifs.some(c => c.name === 'matched_donation_order_id')) { + db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_donation_order_id INTEGER"); + } +} catch(e) { + console.error('Failed to migrate public_donation_orders:', e); +} + +// Safe migration: automation_runs (riwayat otomatisasi pengiriman tagihan) +try { + db.exec(` + CREATE TABLE IF NOT EXISTS automation_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT, + template_key TEXT, + isolate_day INTEGER DEFAULT 0, + mode TEXT, + interval_label TEXT, + target INTEGER DEFAULT 0, + sent INTEGER DEFAULT 0, + failed INTEGER DEFAULT 0, + status TEXT, + note TEXT, + started_at TEXT DEFAULT (NOW_LOCAL()), + finished_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_automation_runs_started ON automation_runs(started_at); + `); +} catch(e) { console.error('Failed to migrate automation_runs:', e); } + +// Safe migration: kolom IP pool & profil PPP di packages +try { + const _pc = db.prepare("PRAGMA table_info(packages)").all(); + const _have = new Set(_pc.map(c => c.name)); + const _add = [ + ['pool_name', "TEXT DEFAULT ''"], + ['pool_ranges', "TEXT DEFAULT ''"], + ['local_address', "TEXT DEFAULT ''"], + ['only_one', 'INTEGER DEFAULT 1'], + ['parent_queue', "TEXT DEFAULT ''"], + ['queue_type', "TEXT DEFAULT ''"], + ['isolir_profile', "TEXT DEFAULT ''"] + ]; + for (const [n, def] of _add) { if (!_have.has(n)) db.exec('ALTER TABLE packages ADD COLUMN ' + n + ' ' + def); } +} catch (e) { console.error('Failed to migrate packages ip columns:', e); } + +module.exports = db; +module.exports.getAppSetting = getAppSetting; +module.exports.saveAppSetting = saveAppSetting; diff --git a/config/genieacs-commands.js b/config/genieacs-commands.js new file mode 100644 index 0000000..65267e8 --- /dev/null +++ b/config/genieacs-commands.js @@ -0,0 +1,1156 @@ +// genieacs-commands.js - Module for handling GenieACS commands via WhatsApp +const { logger } = require('./logger'); +const genieacsApi = require('./genieacs'); +// const responses = require('./responses'); + +// Store the WhatsApp socket instance +let sock = null; + +// Set the WhatsApp socket instance +function setSock(sockInstance) { + sock = sockInstance; + logger.info('WhatsApp socket set in genieacs-commands module'); +} + +// Helper functions for device status and parameters +function getDeviceStatus(lastInform) { + if (!lastInform) return false; + const now = Date.now(); + const lastInformTime = new Date(lastInform).getTime(); + const timeDiff = now - lastInformTime; + // Consider device online if last inform was within 5 minutes + return timeDiff < 5 * 60 * 1000; +} + +function formatUptime(uptimeValue) { + if (!uptimeValue || uptimeValue === 'N/A') return 'N/A'; + + // If already formatted (like "5d 04:50:18"), return as is + if (typeof uptimeValue === 'string' && uptimeValue.includes('d ')) { + return uptimeValue; + } + + // If it's seconds, convert to formatted string + if (!isNaN(uptimeValue)) { + const seconds = parseInt(uptimeValue); + const days = Math.floor(seconds / (24 * 3600)); + const hours = Math.floor((seconds % (24 * 3600)) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + let result = ''; + if (days > 0) result += `${days}d `; + if (hours > 0) result += `${hours}h `; + if (minutes > 0) result += `${minutes}m `; + if (secs > 0) result += `${secs}s`; + + return result.trim() || '0s'; + } + + return uptimeValue; +} + +function getParameterWithPaths(device, paths) { + if (!device || !paths || !Array.isArray(paths)) return 'N/A'; + + for (const path of paths) { + try { + const value = getParameterValue(device, path); + if (value && value !== 'N/A') { + return value; + } + } catch (error) { + // Continue to next path + } + } + return 'N/A'; +} + +function getParameterValue(device, path) { + if (!device || !path) return 'N/A'; + + try { + const pathParts = path.split('.'); + let current = device; + + for (const part of pathParts) { + if (current && typeof current === 'object') { + current = current[part]; + } else { + return 'N/A'; + } + } + + // Handle GenieACS parameter format + if (current && typeof current === 'object' && current._value !== undefined) { + return current._value; + } + + // Handle direct value + if (current !== null && current !== undefined && current !== '') { + return current; + } + + return 'N/A'; + } catch (error) { + logger.debug(`Error getting parameter ${path}: ${error.message}`); + return 'N/A'; + } +} + +// Parameter paths for different device parameters (updated with confirmed VirtualParameters) +const parameterPaths = { + rxPower: [ + 'VirtualParameters.RXPower', // ✅ CONFIRMED: -19.74 + 'InternetGatewayDevice.WANDevice.1.WANPONInterfaceConfig.RXPower', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.X_ALU-COM_RxPower', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.RxPower', + 'Device.Optical.Interface.1.RxPower' + ], + pppoeIP: [ + 'VirtualParameters.pppoeIP', // ✅ CONFIRMED: 192.168.10.159 + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress', + 'Device.PPP.Interface.1.IPCPExtensions.RemoteIPAddress' + ], + pppUsername: [ + 'VirtualParameters.pppoeUsername', // ✅ CONFIRMED: leha + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username', + 'Device.PPP.Interface.1.Username' + ], + uptime: [ + 'VirtualParameters.getdeviceuptime', // ✅ CONFIRMED: 5d 04:50:18 + 'InternetGatewayDevice.DeviceInfo.UpTime', + 'Device.DeviceInfo.UpTime' + ], + firmware: [ + 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', + 'Device.DeviceInfo.SoftwareVersion' + ], + userConnected: [ + 'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.TotalAssociations', // ✅ SSID 1 (2.4GHz) only + 'VirtualParameters.activedevices', // ✅ Fallback if needed + 'Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries' + ], + temperature: [ + 'VirtualParameters.gettemp', // ✅ CONFIRMED: 48 + 'InternetGatewayDevice.DeviceInfo.TemperatureStatus.TemperatureValue', + 'Device.DeviceInfo.TemperatureStatus.TemperatureValue' + ], + // Additional VirtualParameters yang tersedia + serialNumber: [ + 'VirtualParameters.getSerialNumber', // ✅ AVAILABLE: CIOT12E8C8B8 + 'InternetGatewayDevice.DeviceInfo.SerialNumber' + ], + ponMode: [ + 'VirtualParameters.getponmode' // ✅ AVAILABLE: EPON + ], + pppUptime: [ + 'VirtualParameters.getpppuptime' // ✅ AVAILABLE: 0d 08:46:43 + ], + ponMac: [ + 'VirtualParameters.PonMac', // ✅ AVAILABLE (but might be empty) + 'VirtualParameters.ponMac', // Alternative lowercase + 'VirtualParameters.MacAddress', // Alternative name + 'VirtualParameters.deviceMac', // Alternative name + 'VirtualParameters.ontMac', // Alternative name + 'InternetGatewayDevice.DeviceInfo.X_ALU-COM_MACAddress', + 'InternetGatewayDevice.LANDevice.1.LANEthernetInterfaceConfig.1.MACAddress' + ], + wlanPassword: [ + 'VirtualParameters.WlanPassword' // ✅ AVAILABLE + ] +}; + +// Menggunakan format pesan dari responses.js +function formatResponse(message) { + return message; // Tidak pakai header/footer lagi +} + +// Get device by phone number +async function getDeviceByNumber(phoneNumber) { + try { + return await genieacsApi.findDeviceByPhoneNumber(phoneNumber); + } catch (error) { + logger.error(`Error finding device with phone number ${phoneNumber}: ${error.message}`); + return null; + } +} + +// Handler for WiFi info command +async function handleWifiInfo(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Get SSID information + const ssid = getSSIDValue(device, '1') || 'N/A'; + const ssid5G = getSSIDValue(device, '5') || 'N/A'; + + // Send WiFi information + const wifiInfo = `📱 *INFORMASI WI-FI*\n\n`; + wifiInfo += `• SSID 2.4G: ${ssid}\n`; + wifiInfo += `• SSID 5G: ${ssid5G}\n`; + + await sock.sendMessage(remoteJid, { + text: formatResponse(wifiInfo) + }); + } catch (error) { + logger.error(`Error handling WiFi info: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse('Terjadi kesalahan saat mengambil informasi WiFi.') + }); + } +} + +// Handler for change WiFi SSID command +async function handleChangeWifiSSID(remoteJid, senderNumber, newSSID) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + if (!newSSID || newSSID.length < 3 || newSSID.length > 32) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Format SSID tidak valid. SSID harus antara 3-32 karakter.') + }); + return; + } + + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Send processing message + await sock.sendMessage(remoteJid, { + text: formatResponse(`Sedang mengubah SSID perangkat ke "${newSSID}"...`) + }); + + // Set SSID parameter value (pass server ID from device) + await genieacsApi.setParameterValues(device._id, { + 'SSID': newSSID + }, device._acs_server_id); + + // Send success message + await sock.sendMessage(remoteJid, { + text: formatResponse(`SSID berhasil diubah ke "${newSSID}".`) + }); + + } catch (error) { + logger.error(`Error changing WiFi SSID: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse('Terjadi kesalahan saat mengubah SSID.') + }); + } +} + +// Handler for change WiFi password command +async function handleChangeWifiPassword(remoteJid, senderNumber, newPassword) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + if (!newPassword || newPassword.length < 8 || newPassword.length > 63) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Format password tidak valid. Password harus antara 8-63 karakter.') + }); + return; + } + + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Send processing message + await sock.sendMessage(remoteJid, { + text: formatResponse('Sedang mengubah password WiFi...') + }); + + // Set password parameter value (pass server ID from device) + await genieacsApi.setParameterValues(device._id, { + 'KeyPassphrase': newPassword + }, device._acs_server_id); + + // Send success message + await sock.sendMessage(remoteJid, { + text: formatResponse('Password WiFi berhasil diubah.') + }); + + } catch (error) { + logger.error(`Error changing WiFi password: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse('Terjadi kesalahan saat mengubah password WiFi.') + }); + } +} + +// Handler for device status command +async function handleDeviceStatus(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Get device status information + const lastInform = device._lastInform; + const isOnline = getDeviceStatus(lastInform); + const serialNumber = device.InternetGatewayDevice?.DeviceInfo?.SerialNumber?._value || 'N/A'; + const firmware = getParameterWithPaths(device, parameterPaths.firmware) || 'N/A'; + const uptime = formatUptime(getParameterWithPaths(device, parameterPaths.uptime)) || 'N/A'; + const rxPower = getParameterWithPaths(device, parameterPaths.rxPower) || 'N/A'; + const pppoeIP = getParameterWithPaths(device, parameterPaths.pppoeIP) || 'N/A'; + const pppUsername = getParameterWithPaths(device, parameterPaths.pppUsername) || 'N/A'; + const ssid = getSSIDValue(device, '1') || 'N/A'; + const ssid5G = getSSIDValue(device, '5') || 'N/A'; + const connectedUsers = getParameterWithPaths(device, parameterPaths.userConnected) || '0'; + + // Format device status + const statusMessage = `📱 *STATUS PERANGKAT*\n\n`; + statusMessage += `• Status Online: ${isOnline ? '🟢 Online' : '🔴 Offline'}\n`; + statusMessage += `• Serial Number: ${serialNumber}\n`; + statusMessage += `• Firmware: ${firmware}\n`; + statusMessage += `• Uptime: ${uptime}\n`; + statusMessage += `• RX Power: ${rxPower} dBm\n`; + statusMessage += `• PPPoE IP: ${pppoeIP}\n`; + statusMessage += `• PPP Username: ${pppUsername}\n`; + statusMessage += `• SSID 2.4G: ${ssid}\n`; + statusMessage += `• SSID 5G: ${ssid5G}\n`; + statusMessage += `• Pengguna Terhubung: ${connectedUsers}\n`; + statusMessage += `• Last Inform: ${new Date(lastInform).toLocaleString()}\n`; + + await sock.sendMessage(remoteJid, { + text: formatResponse(statusMessage) + }); + + } catch (error) { + logger.error(`Error handling device status: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse('Terjadi kesalahan saat mengambil status perangkat.') + }); + } +} + +// Handler for restart device command +async function handleRestartDevice(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Send confirmation message + await sock.sendMessage(remoteJid, { + text: formatResponse('Apakah Anda yakin ingin merestart perangkat ini? (ketik "confirm restart" untuk melanjutkan)') + }); + + // Save restart confirmation status + global.pendingRestarts = global.pendingRestarts || {}; + global.pendingRestarts[senderNumber] = { + deviceId: device._id, + timestamp: Date.now() + }; + + } catch (error) { + logger.error(`Error preparing device restart: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse('Terjadi kesalahan saat mempersiapkan restart perangkat.') + }); + } +} + +// Handler for restart confirmation +async function handleRestartConfirmation(remoteJid, senderNumber, confirmed) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + if (!global.pendingRestarts || !global.pendingRestarts[senderNumber]) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Tidak ada permintaan restart yang pending.') + }); + return; + } + + const { deviceId, timestamp } = global.pendingRestarts[senderNumber]; + + // Check if confirmation is still valid (within 5 minutes) + if (Date.now() - timestamp > 5 * 60 * 1000) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Permintaan restart sudah kadaluarsa.') + }); + delete global.pendingRestarts[senderNumber]; + return; + } + + if (confirmed) { + // Send processing message + await sock.sendMessage(remoteJid, { + text: formatResponse('Sedang merestart perangkat...') + }); + + // Restart device (pass server ID from pending restart) + const device = await genieacsApi.getDevice(deviceId); + await genieacsApi.reboot(deviceId, device._acs_server_id); + + // Send success message + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat berhasil direstart.') + }); + } else { + // Send cancellation message + await sock.sendMessage(remoteJid, { + text: formatResponse('Restart perangkat dibatalkan.') + }); + } + + // Delete restart confirmation status + delete global.pendingRestarts[senderNumber]; + + } catch (error) { + logger.error(`Error handling restart confirmation: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse('Terjadi kesalahan saat mengkonfirmasi restart.') + }); + + // Delete restart confirmation status even if error + if (global.pendingRestarts && global.pendingRestarts[senderNumber]) { + delete global.pendingRestarts[senderNumber]; + } + } +} + +// Helper function to get SSID value +function getSSIDValue(device, configIndex) { + try { + // Try method 1: Using bracket notation for WLANConfiguration + if (device.InternetGatewayDevice && + device.InternetGatewayDevice.LANDevice && + device.InternetGatewayDevice.LANDevice['1'] && + device.InternetGatewayDevice.LANDevice['1'].WLANConfiguration && + device.InternetGatewayDevice.LANDevice['1'].WLANConfiguration[configIndex] && + device.InternetGatewayDevice.LANDevice['1'].WLANConfiguration[configIndex].SSID) { + + const ssidObj = device.InternetGatewayDevice.LANDevice['1'].WLANConfiguration[configIndex].SSID; + if (ssidObj._value !== undefined) { + return ssidObj._value; + } + } + + // Try method 2: Using getParameterWithPaths + const ssidPath = `InternetGatewayDevice.LANDevice.1.WLANConfiguration.${configIndex}.SSID`; + const ssidValue = getParameterWithPaths(device, [ssidPath]); + if (ssidValue && ssidValue !== 'N/A') { + return ssidValue; + } + + // Try method 3: Search in entire object + for (const key in device) { + if (device[key]?.LANDevice?.['1']?.WLANConfiguration?.[configIndex]?.SSID?._value) { + return device[key].LANDevice['1'].WLANConfiguration[configIndex].SSID._value; + } + } + + // Try method 4: Check in virtual parameters + if (device.VirtualParameters?.SSID?._value) { + return device.VirtualParameters.SSID._value; + } + + if (configIndex === '5' && device.InternetGatewayDevice?.LANDevice?.['1']?.WLANConfiguration?.['2']?.SSID?._value) { + return device.InternetGatewayDevice.LANDevice['1'].WLANConfiguration['2'].SSID._value; + } + + return 'N/A'; + } catch (error) { + logger.error(`Error getting SSID for config ${configIndex}: ${error.message}`); + return 'N/A'; + } +} + +// Handler untuk factory reset perangkat +async function handleFactoryReset(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Send confirmation message + await sock.sendMessage(remoteJid, { + text: formatResponse(`⚠️ *PERINGATAN FACTORY RESET*\n\nAnda akan melakukan factory reset pada perangkat Anda.\nSemua pengaturan akan kembali ke default pabrik.\n\nKetik "confirm factory reset" untuk melanjutkan.`) + }); + + // Save factory reset confirmation status + global.pendingFactoryResets = global.pendingFactoryResets || {}; + global.pendingFactoryResets[senderNumber] = { + deviceId: device._id, + timestamp: Date.now() + }; + + } catch (error) { + logger.error(`Error preparing factory reset: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + } +} + +// Handler untuk konfirmasi factory reset +async function handleFactoryResetConfirmation(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + if (!global.pendingFactoryResets || !global.pendingFactoryResets[senderNumber]) { + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Tidak ada permintaan factory reset yang pending.`) + }); + return; + } + + const { deviceId, timestamp } = global.pendingFactoryResets[senderNumber]; + + // Check if confirmation is still valid (within 5 minutes) + if (Date.now() - timestamp > 5 * 60 * 1000) { + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Permintaan factory reset sudah expired. Silakan ulangi.`) + }); + delete global.pendingFactoryResets[senderNumber]; + return; + } + + // Send processing message + await sock.sendMessage(remoteJid, { + text: formatResponse(`🔄 Melakukan factory reset perangkat...\nMohon tunggu beberapa menit.`) + }); + + // Factory reset device (pass server ID) + const device = await genieacsApi.getDevice(deviceId); + await genieacsApi.factoryReset(deviceId, device._acs_server_id); + + // Send success message + await sock.sendMessage(remoteJid, { + text: formatResponse(`✅ Factory reset berhasil dilakukan.\nPerangkat akan restart dan kembali ke pengaturan default pabrik.`) + }); + + // Delete factory reset confirmation status + delete global.pendingFactoryResets[senderNumber]; + + } catch (error) { + logger.error(`Error handling factory reset confirmation: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + + // Delete factory reset confirmation status even if error + if (global.pendingFactoryResets && global.pendingFactoryResets[senderNumber]) { + delete global.pendingFactoryResets[senderNumber]; + } + } +} + +// Handler untuk melihat perangkat yang terhubung ke WiFi +async function handleConnectedDevices(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Get connected devices information + // Get number of connected users + const connectedUsers = getParameterWithPaths(device, parameterPaths.userConnected) || '0'; + + // Try to get detailed host information + let hostInfo = ''; + try { + // Check for host entries in LANDevice + if (device.InternetGatewayDevice?.LANDevice?.['1']?.Hosts?.Host) { + const hosts = device.InternetGatewayDevice.LANDevice['1'].Hosts.Host; + let hostCount = 0; + + for (const hostKey in hosts) { + const host = hosts[hostKey]; + if (host.Active?._value === 'true' || host.Active?._value === true) { + hostCount++; + if (hostCount <= 10) { // Limit to 10 devices + const hostname = host.HostName?._value || 'Unknown'; + const ip = host.IPAddress?._value || 'N/A'; + const mac = host.MACAddress?._value || 'N/A'; + hostInfo += `${hostCount}. ${hostname}\n IP: ${ip}\n MAC: ${mac}\n\n`; + } + } + } + + if (hostCount > 10) { + hostInfo += `... dan ${hostCount - 10} perangkat lainnya\n`; + } + } + } catch (error) { + logger.error(`Error getting detailed host info: ${error.message}`); + } + + let message = `📱 *PERANGKAT TERHUBUNG*\n\n`; + message += `Jumlah perangkat aktif: ${connectedUsers}\n\n`; + + if (hostInfo) { + message += `Detail perangkat:\n${hostInfo}`; + } else { + message += `Detail perangkat tidak tersedia.`; + } + + await sock.sendMessage(remoteJid, { + text: formatResponse(message) + }); + + } catch (error) { + logger.error(`Error handling connected devices: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + } +} + +// Handler untuk admin - melihat detail lengkap perangkat +async function handleAdminDeviceDetail(remoteJid, phoneNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by phone number + const device = await getDeviceByNumber(phoneNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Perangkat dengan nomor ${phoneNumber} tidak ditemukan.`) + }); + return; + } + + // Get device information + const lastInform = device._lastInform; + const isOnline = getDeviceStatus(lastInform); + const serialNumber = device.InternetGatewayDevice?.DeviceInfo?.SerialNumber?._value || 'N/A'; + const firmware = getParameterWithPaths(device, parameterPaths.firmware) || 'N/A'; + + const uptime = formatUptime(getParameterWithPaths(device, parameterPaths.uptime)) || 'N/A'; + + const rxPower = getParameterWithPaths(device, parameterPaths.rxPower) || 'N/A'; + const pppoeIP = getParameterWithPaths(device, parameterPaths.pppoeIP) || 'N/A'; + const pppUsername = getParameterWithPaths(device, parameterPaths.pppUsername) || 'N/A'; + const ssid = getSSIDValue(device, '1') || 'N/A'; + const ssid5G = getSSIDValue(device, '5') || 'N/A'; + const connectedUsers = getParameterWithPaths(device, parameterPaths.userConnected) || '0'; + const temperature = getParameterWithPaths(device, parameterPaths.temperature) || 'N/A'; + + // Additional VirtualParameters + const ponMode = getParameterWithPaths(device, parameterPaths.ponMode) || 'N/A'; + const pppUptime = getParameterWithPaths(device, parameterPaths.pppUptime) || 'N/A'; + + // Get device model and manufacturer + const manufacturer = device.InternetGatewayDevice?.DeviceInfo?.Manufacturer?._value || 'N/A'; + const model = device.InternetGatewayDevice?.DeviceInfo?.ModelName?._value || 'N/A'; + const hardwareVersion = device.InternetGatewayDevice?.DeviceInfo?.HardwareVersion?._value || 'N/A'; + + let message = `🔍 *DETAIL PERANGKAT ADMIN*\n\n`; + message += `📱 *Nomor:* ${phoneNumber}\n`; + message += `🆔 *Device ID:* ${device._id}\n`; + message += `📟 *Serial Number:* ${serialNumber}\n`; + message += `🏭 *Manufacturer:* ${manufacturer}\n`; + message += `📦 *Model:* ${model}\n`; + message += `🔧 *Hardware Version:* ${hardwareVersion}\n`; + message += `💾 *Firmware:* ${firmware}\n\n`; + + message += `🌐 *Status Koneksi:*\n`; + message += `• Status: ${isOnline ? '🟢 Online' : '🔴 Offline'}\n`; + message += `• Last Inform: ${new Date(lastInform).toLocaleString()}\n`; + message += `• Device Uptime: ${uptime}\n`; + message += `• PPP Uptime: ${pppUptime}\n`; + message += `• PPPoE IP: ${pppoeIP}\n`; + message += `• PPP Username: ${pppUsername}\n\n`; + + message += `📡 *Network Information:*\n`; + message += `• PON Mode: ${ponMode}\n`; + message += `• RX Power: ${rxPower} dBm\n`; + message += `• Temperature: ${temperature}°C\n\n`; + + message += `📶 *WiFi Information:*\n`; + message += `• SSID 2.4G: ${ssid}\n`; + message += `• SSID 5G: ${ssid5G}\n`; + message += `• Connected Devices: ${connectedUsers}\n\n`; + + // Get tags + if (device._tags && device._tags.length > 0) { + message += `🏷️ *Tags:* ${device._tags.join(', ')}\n`; + } + + await sock.sendMessage(remoteJid, { + text: formatResponse(message) + }); + + } catch (error) { + logger.error(`Error handling admin device detail: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + } +} + +// Handler untuk admin - restart perangkat pelanggan +async function handleAdminRestartDevice(remoteJid, phoneNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by phone number + const device = await getDeviceByNumber(phoneNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Perangkat dengan nomor ${phoneNumber} tidak ditemukan.`) + }); + return; + } + + // Send processing message + await sock.sendMessage(remoteJid, { + text: formatResponse(`🔄 Melakukan restart perangkat pelanggan ${phoneNumber}...\nMohon tunggu beberapa menit.`) + }); + + // Restart device (pass server ID from device) + await genieacsApi.reboot(device._id, device._acs_server_id); + + // Send success message + await sock.sendMessage(remoteJid, { + text: formatResponse(`✅ Perintah restart berhasil dikirim ke perangkat pelanggan ${phoneNumber}.\nPerangkat akan restart dalam beberapa menit.`) + }); + + } catch (error) { + logger.error(`Error handling admin restart device: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + } +} + +// Handler untuk admin - factory reset perangkat pelanggan +async function handleAdminFactoryReset(remoteJid, phoneNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by phone number + const device = await getDeviceByNumber(phoneNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Perangkat dengan nomor ${phoneNumber} tidak ditemukan.`) + }); + return; + } + + // Send confirmation message + await sock.sendMessage(remoteJid, { + text: formatResponse(`⚠️ *PERINGATAN FACTORY RESET ADMIN*\n\nAnda akan melakukan factory reset pada perangkat pelanggan ${phoneNumber}.\nSemua pengaturan akan kembali ke default pabrik.\n\nKetik "confirm admin factory reset ${phoneNumber}" untuk melanjutkan.`) + }); + + // Save factory reset confirmation status + global.pendingAdminFactoryResets = global.pendingAdminFactoryResets || {}; + global.pendingAdminFactoryResets[phoneNumber] = { + deviceId: device._id, + timestamp: Date.now() + }; + + } catch (error) { + logger.error(`Error preparing admin factory reset: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + } +} + +// Handler untuk konfirmasi admin factory reset +async function handleAdminFactoryResetConfirmation(remoteJid, phoneNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + if (!global.pendingAdminFactoryResets || !global.pendingAdminFactoryResets[phoneNumber]) { + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Tidak ada permintaan factory reset yang pending untuk nomor ${phoneNumber}.`) + }); + return; + } + + const { deviceId, timestamp } = global.pendingAdminFactoryResets[phoneNumber]; + + // Check if confirmation is still valid (within 5 minutes) + if (Date.now() - timestamp > 5 * 60 * 1000) { + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Permintaan factory reset untuk ${phoneNumber} sudah expired. Silakan ulangi.`) + }); + delete global.pendingAdminFactoryResets[phoneNumber]; + return; + } + + // Send processing message + await sock.sendMessage(remoteJid, { + text: formatResponse(`🔄 Melakukan factory reset perangkat pelanggan ${phoneNumber}...\nMohon tunggu beberapa menit.`) + }); + + // Factory reset device + await genieacsApi.factoryReset(deviceId); + + // Send success message + await sock.sendMessage(remoteJid, { + text: formatResponse(`✅ Factory reset berhasil dilakukan pada perangkat pelanggan ${phoneNumber}.\nPerangkat akan restart dan kembali ke pengaturan default pabrik.`) + }); + + // Delete factory reset confirmation status + delete global.pendingAdminFactoryResets[phoneNumber]; + + } catch (error) { + logger.error(`Error handling admin factory reset confirmation: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + + // Delete factory reset confirmation status even if error + if (global.pendingAdminFactoryResets && global.pendingAdminFactoryResets[phoneNumber]) { + delete global.pendingAdminFactoryResets[phoneNumber]; + } + } +} + +// Handler untuk melihat speed test / bandwidth +async function handleSpeedTest(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Get bandwidth/speed information + const { getParameterWithPaths } = require('./whatsapp'); + + // Try to get bandwidth information from various paths + const downloadSpeed = getParameterWithPaths(device, [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.DownstreamMaxBitRate', + 'Device.IP.Interface.1.Stats.BytesReceived' + ]) || 'N/A'; + + const uploadSpeed = getParameterWithPaths(device, [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.UpstreamMaxBitRate', + 'Device.IP.Interface.1.Stats.BytesSent' + ]) || 'N/A'; + + // Get interface statistics + const bytesReceived = getParameterWithPaths(device, [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.Stats.BytesReceived', + 'Device.IP.Interface.1.Stats.BytesReceived' + ]) || 'N/A'; + + const bytesSent = getParameterWithPaths(device, [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.Stats.BytesSent', + 'Device.IP.Interface.1.Stats.BytesSent' + ]) || 'N/A'; + + let message = `📊 *INFORMASI BANDWIDTH*\n\n`; + message += `📥 *Download Speed:* ${downloadSpeed}\n`; + message += `📤 *Upload Speed:* ${uploadSpeed}\n\n`; + message += `📈 *Statistik Interface:*\n`; + message += `• Bytes Received: ${formatBytes(bytesReceived)}\n`; + message += `• Bytes Sent: ${formatBytes(bytesSent)}\n\n`; + message += `💡 *Tips:* Untuk speed test yang akurat, gunakan aplikasi speed test di perangkat Anda.`; + + await sock.sendMessage(remoteJid, { + text: formatResponse(message) + }); + + } catch (error) { + logger.error(`Error handling speed test: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + } +} + +// Handler untuk diagnostik jaringan +async function handleNetworkDiagnostic(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Get diagnostic information + const { getDeviceStatus, getParameterWithPaths, parameterPaths } = require('./whatsapp'); + + const lastInform = device._lastInform; + const isOnline = getDeviceStatus(lastInform); + const rxPower = getParameterWithPaths(device, parameterPaths.rxPower) || 'N/A'; + const pppoeIP = getParameterWithPaths(device, parameterPaths.pppoeIP) || 'N/A'; + const temperature = getParameterWithPaths(device, parameterPaths.temperature) || 'N/A'; + + // Get connection status + const wanStatus = getParameterWithPaths(device, [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ConnectionStatus', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ConnectionStatus' + ]) || 'N/A'; + + // Get DNS servers + const dnsServers = getParameterWithPaths(device, [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.DNSServers', + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DNSServers' + ]) || 'N/A'; + + let message = `🔧 *DIAGNOSTIK JARINGAN*\n\n`; + + // Status koneksi + message += `🌐 *Status Koneksi:*\n`; + message += `• Device: ${isOnline ? '🟢 Online' : '🔴 Offline'}\n`; + message += `• WAN Status: ${wanStatus}\n`; + message += `• PPPoE IP: ${pppoeIP}\n`; + message += `• Last Inform: ${new Date(lastInform).toLocaleString()}\n\n`; + + // Signal quality + message += `📶 *Kualitas Signal:*\n`; + message += `• RX Power: ${rxPower} dBm\n`; + const rxPowerNum = parseFloat(rxPower); + if (!isNaN(rxPowerNum)) { + if (rxPowerNum >= -25) { + message += `• Status: 🟢 Excellent\n`; + } else if (rxPowerNum >= -27) { + message += `• Status: 🟡 Good\n`; + } else if (rxPowerNum >= -30) { + message += `• Status: 🟠 Fair\n`; + } else { + message += `• Status: 🔴 Poor\n`; + } + } + message += `• Temperature: ${temperature}°C\n\n`; + + // Network settings + message += `⚙️ *Pengaturan Jaringan:*\n`; + message += `• DNS Servers: ${dnsServers}\n\n`; + + // Recommendations + message += `💡 *Rekomendasi:*\n`; + if (!isOnline) { + message += `• Perangkat offline, coba restart perangkat\n`; + } + if (!isNaN(rxPowerNum) && rxPowerNum < -27) { + message += `• Signal lemah, hubungi teknisi\n`; + } + if (!isNaN(parseFloat(temperature)) && parseFloat(temperature) > 70) { + message += `• Temperature tinggi, pastikan ventilasi baik\n`; + } + + await sock.sendMessage(remoteJid, { + text: formatResponse(message) + }); + + } catch (error) { + logger.error(`Error handling network diagnostic: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + } +} + +// Handler untuk melihat riwayat koneksi +async function handleConnectionHistory(remoteJid, senderNumber) { + if (!sock) { + logger.error('Sock instance not set'); + return; + } + + try { + // Find device by sender's phone number + const device = await getDeviceByNumber(senderNumber); + + if (!device) { + await sock.sendMessage(remoteJid, { + text: formatResponse('Perangkat tidak ditemukan.') + }); + return; + } + + // Get connection history information + const { formatUptime, getParameterWithPaths, parameterPaths } = require('./whatsapp'); + + const deviceUptime = formatUptime(getParameterWithPaths(device, parameterPaths.uptime)) || 'N/A'; + const pppUptime = formatUptime(getParameterWithPaths(device, [ + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.UpTime', + 'Device.PPP.Interface.1.UpTime' + ])) || 'N/A'; + + const lastInform = device._lastInform; + const firstInform = device._registered || device._created || 'N/A'; + + let message = `📊 *RIWAYAT KONEKSI*\n\n`; + message += `⏰ *Waktu Aktif:*\n`; + message += `• Device Uptime: ${deviceUptime}\n`; + message += `• PPPoE Uptime: ${pppUptime}\n\n`; + + message += `📅 *Riwayat:*\n`; + message += `• First Registered: ${firstInform !== 'N/A' ? new Date(firstInform).toLocaleString() : 'N/A'}\n`; + message += `• Last Inform: ${new Date(lastInform).toLocaleString()}\n\n`; + + // Calculate connection stability + const now = Date.now(); + const lastInformTime = new Date(lastInform).getTime(); + const timeDiff = now - lastInformTime; + + message += `🔄 *Status Koneksi:*\n`; + if (timeDiff < 5 * 60 * 1000) { // 5 minutes + message += `• Status: 🟢 Stabil (Last inform ${Math.round(timeDiff / 1000)} detik lalu)\n`; + } else if (timeDiff < 30 * 60 * 1000) { // 30 minutes + message += `• Status: 🟡 Normal (Last inform ${Math.round(timeDiff / 60000)} menit lalu)\n`; + } else { + message += `• Status: 🔴 Bermasalah (Last inform ${Math.round(timeDiff / 60000)} menit lalu)\n`; + } + + await sock.sendMessage(remoteJid, { + text: formatResponse(message) + }); + + } catch (error) { + logger.error(`Error handling connection history: ${error.message}`); + await sock.sendMessage(remoteJid, { + text: formatResponse(`❌ Terjadi kesalahan: ${error.message}`) + }); + } +} + +// Helper function untuk format bytes +function formatBytes(bytes) { + if (bytes === 'N/A' || !bytes) return 'N/A'; + + const num = parseInt(bytes); + if (isNaN(num)) return bytes; + + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (num === 0) return '0 Bytes'; + + const i = Math.floor(Math.log(num) / Math.log(1024)); + return Math.round(num / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; +} + +module.exports = { + setSock, + handleWifiInfo, + handleChangeWifiSSID, + handleChangeWifiPassword, + handleDeviceStatus, + handleRestartDevice, + handleRestartConfirmation, + handleFactoryReset, + handleFactoryResetConfirmation, + handleConnectedDevices, + handleAdminDeviceDetail, + handleAdminRestartDevice, + handleAdminFactoryReset, + handleAdminFactoryResetConfirmation, + handleSpeedTest, + handleNetworkDiagnostic, + handleConnectionHistory +}; diff --git a/config/genieacs.js b/config/genieacs.js new file mode 100644 index 0000000..601f90b --- /dev/null +++ b/config/genieacs.js @@ -0,0 +1,1195 @@ +const axios = require('axios'); +require('dotenv').config(); +const { logger } = require('./logger'); +const db = require('./database'); +const { getSetting } = require('./settingsManager'); + +// ─── Built-in ACS Helpers ──────────────────────────────────────────────────── + +/** Check if built-in ACS mode is active */ +function isBuiltinAcsEnabled() { + return getSetting('use_builtin_acs', false) === true || getSetting('use_builtin_acs', false) === 'true'; +} + +/** + * Inflate flat params object into nested GenieACS-style structure. + * e.g. { "InternetGatewayDevice.DeviceInfo.SerialNumber": "ABC" } + * becomes { InternetGatewayDevice: { DeviceInfo: { SerialNumber: { _value: "ABC" } } } } + */ +function inflateParams(flatObj) { + const result = {}; + for (const [key, value] of Object.entries(flatObj)) { + const parts = key.split('.'); + let current = result; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1) { + current[part] = { _value: value, _type: typeof value === 'number' ? 'xsd:unsignedInt' : 'xsd:string', _timestamp: new Date().toISOString() }; + } else { + if (!current[part] || typeof current[part] !== 'object' || current[part]._value !== undefined) { + current[part] = {}; + } + current = current[part]; + } + } + } + return result; +} + +/** Convert a builtin acs_devices row into GenieACS-compatible device object */ +function builtinRowToDevice(row) { + if (!row) return null; + let params = {}; + try { params = JSON.parse(row.params || '{}'); } catch (e) { /* ignore */ } + let tags = []; + try { tags = JSON.parse(row.tags || '[]'); } catch (e) { /* ignore */ } + tags = tags.filter(t => t !== 'bootstrapped'); + + const device = inflateParams(params); + device._id = row.id; + device._tags = tags; + device._lastInform = row.last_inform; + device._updatedAt = row.updated_at; + device._registered = row.created_at; + device._acs_server_id = 'builtin'; + device._acs_server_name = 'Built-in ACS'; + + // Set GenieACS special _deviceId root property + device._deviceId = { + _SerialNumber: row.serial_number || '', + _Manufacturer: row.manufacturer || '', + _ProductClass: row.product_class || '', + _OUI: row.oui || '' + }; + + // Set DeviceID structure as strings (GenieACS standard) + device.DeviceID = { + SerialNumber: row.serial_number || '', + Manufacturer: row.manufacturer || '', + ProductClass: row.product_class || '', + OUI: row.oui || '' + }; + + return device; +} + +/** Parse GenieACS-style query filter for SQLite */ +function matchesQuery(device, query) { + if (!query || Object.keys(query).length === 0) return true; + for (const [key, condition] of Object.entries(query)) { + if (key === '$or') { + if (!Array.isArray(condition)) return false; + const anyMatch = condition.some(sub => matchesQuery(device, sub)); + if (!anyMatch) return false; + continue; + } + if (key === '$and') { + if (!Array.isArray(condition)) return false; + const allMatch = condition.every(sub => matchesQuery(device, sub)); + if (!allMatch) return false; + continue; + } + // Get value from nested device object + const parts = key.split('.'); + let val = device; + for (const p of parts) { + if (val && typeof val === 'object') { + val = val[p]; + } else { + val = undefined; + break; + } + } + if (val && val._value !== undefined) val = val._value; + + // Handle condition types + if (condition && typeof condition === 'object' && !Array.isArray(condition)) { + if ('$exists' in condition) { + const exists = val !== undefined && val !== null; + if (condition.$exists !== exists) return false; + } + if ('$ne' in condition) { + if (Array.isArray(condition.$ne) && condition.$ne.length === 0) { + if (!Array.isArray(val) || val.length === 0) return false; + } else if (val === condition.$ne) return false; + } + if ('$not' in condition) { + if (condition.$not && typeof condition.$not === 'object' && '$size' in condition.$not) { + if (Array.isArray(val) && val.length === condition.$not.$size) return false; + } + } + } else { + // Direct equality + if (key === '_tags') { + if (!Array.isArray(device._tags) || !device._tags.includes(condition)) return false; + } else if (key === '_id') { + if (device._id !== condition) return false; + } else { + if (String(val) !== String(condition)) return false; + } + } + } + return true; +} + +/** + * Create a proxy object that mimics axios interface but operates on local SQLite. + * This is the core of the adapter pattern - all downstream code using + * createAxiosInstance() will transparently use local DB when builtin ACS is active. + */ +function createBuiltinAxiosProxy() { + return { + get: async (url, config = {}) => { + const params = config.params || {}; + + // GET /devices + if (url === '/devices' || url === '/devices/') { + let rows = []; + try { + const limit = params.limit || 999999; + rows = db.prepare('SELECT * FROM acs_devices ORDER BY last_inform DESC LIMIT ?').all(limit); + } catch (e) { + logger.error(`[BuiltinACS] Error querying devices: ${e.message}`); + } + + let devices = rows.map(builtinRowToDevice); + + // Apply query filter + if (params.query) { + try { + const queryObj = typeof params.query === 'string' ? JSON.parse(params.query) : params.query; + devices = devices.filter(d => matchesQuery(d, queryObj)); + } catch (e) { + logger.debug(`[BuiltinACS] Query parse error: ${e.message}`); + } + } + + return { data: devices, status: 200 }; + } + + // GET /devices/:id + const deviceMatch = url.match(/^\/devices\/(.+)$/); + if (deviceMatch) { + const deviceId = decodeURIComponent(deviceMatch[1]); + const row = db.prepare('SELECT * FROM acs_devices WHERE id = ?').get(deviceId); + if (!row) { + const err = new Error(`Device ${deviceId} not found`); + err.response = { status: 404, data: 'Not found' }; + throw err; + } + return { data: builtinRowToDevice(row), status: 200 }; + } + + return { data: [], status: 200 }; + }, + + post: async (url, data = {}, config = {}) => { + // POST /devices/:id/tasks + const taskMatch = url.match(/^\/devices\/(.+)\/tasks$/); + if (taskMatch) { + const deviceId = decodeURIComponent(taskMatch[1]); + const taskName = data.name || 'unknown'; + const payload = JSON.stringify(data); + const now = new Date().toISOString(); + + try { + // Task 'inform' tidak perlu disimpan sebagai task; cukup memicu Connection Request. + if (taskName === 'inform') { + try { + const acsService = require('../services/acsServerService'); + acsService.triggerConnectionRequest(deviceId).catch(() => {}); + } catch (e) { /* service not loaded yet */ } + return { data: { _id: 'inform', name: 'inform', device: deviceId }, status: 202 }; + } + const result = db.prepare( + 'INSERT INTO acs_tasks (device_id, name, payload, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(deviceId, taskName, payload, 'pending', now, now); + + logger.info(`[BuiltinACS] Task '${taskName}' created for device ${deviceId}, id=${result.lastInsertRowid}`); + + // Trigger connection request asynchronously + try { + const acsService = require('../services/acsServerService'); + acsService.triggerConnectionRequest(deviceId).catch(() => {}); + } catch (e) { /* service not loaded yet */ } + + return { data: { _id: String(result.lastInsertRowid), name: taskName, device: deviceId }, status: 202 }; + } catch (e) { + logger.error(`[BuiltinACS] Error creating task: ${e.message}`); + return { data: {}, status: 500 }; + } + } + + return { data: {}, status: 200 }; + }, + + put: async (url, data = {}, config = {}) => { + // PUT /devices/:id — update tags + const deviceMatch = url.match(/^\/devices\/(.+)$/); + if (deviceMatch) { + const deviceId = decodeURIComponent(deviceMatch[1]); + const now = new Date().toISOString(); + + if (data._tags) { + try { + db.prepare('UPDATE acs_devices SET tags = ?, updated_at = ? WHERE id = ?') + .run(JSON.stringify(data._tags), now, deviceId); + return { data: {}, status: 200 }; + } catch (e) { + logger.error(`[BuiltinACS] Error updating tags: ${e.message}`); + } + } + } + return { data: {}, status: 200 }; + }, + + delete: async (url, config = {}) => { + const deviceMatch = url.match(/^\/devices\/(.+)$/); + if (deviceMatch) { + const deviceId = decodeURIComponent(deviceMatch[1]); + try { + db.prepare('DELETE FROM acs_devices WHERE id = ?').run(deviceId); + db.prepare('DELETE FROM acs_tasks WHERE device_id = ?').run(deviceId); + return { data: {}, status: 200 }; + } catch (e) { + logger.error(`[BuiltinACS] Error deleting device: ${e.message}`); + } + } + return { data: {}, status: 200 }; + } + }; +} + +// Import WhatsApp notification function +let sendMonitoringAlert = null; +(async () => { + try { + const whatsappBot = await import('../services/whatsappBot.mjs'); + sendMonitoringAlert = whatsappBot.sendMonitoringAlert; + logger.info('[GenieACS] WhatsApp monitoring alert integration loaded'); + } catch (error) { + logger.warn('[GenieACS] WhatsApp bot not available for monitoring alerts'); + } +})(); + +// Konfigurasi GenieACS API (Legacy - untuk backward compatibility) +const GENIEACS_URL = process.env.GENIEACS_URL || 'http://localhost:7557'; +const GENIEACS_USERNAME = process.env.GENIEACS_USERNAME; +const GENIEACS_PASSWORD = process.env.GENIEACS_PASSWORD; + +// Helper: Get all ACS servers from database +function getAllACSServers() { + try { + if (isBuiltinAcsEnabled()) { + return [{ + id: 'builtin', + name: 'Built-in ACS', + url: 'local', + status: 'active' + }]; + } + + const legacyUrl = getSetting('genieacs_url', GENIEACS_URL); + const legacyUser = getSetting('genieacs_username', GENIEACS_USERNAME); + const legacyPass = getSetting('genieacs_password', GENIEACS_PASSWORD); + + const servers = []; + + // Add legacy server if configured + if (legacyUrl) { + servers.push({ + id: 'legacy', + name: 'Default ACS', + url: legacyUrl, + username: legacyUser, + password: legacyPass, + status: 'active' + }); + } + + // Add servers from database + const dbServers = db.prepare('SELECT * FROM genieacs_servers WHERE status = ?').all('active'); + servers.push(...dbServers); + + return servers; + } catch (error) { + logger.error(`[GenieACS] Error getting ACS servers: ${error.message}`); + return []; + } +} + +// Helper: Get specific ACS server by ID +function getACSServer(serverId) { + if (!serverId) { + if (isBuiltinAcsEnabled()) { + return { + id: 'builtin', + name: 'Built-in ACS', + url: 'local', + status: 'active' + }; + } + const legacyUrl = getSetting('genieacs_url', GENIEACS_URL); + const legacyUser = getSetting('genieacs_username', GENIEACS_USERNAME); + const legacyPass = getSetting('genieacs_password', GENIEACS_PASSWORD); + if (legacyUrl) { + return { + id: 'legacy', + name: 'Default ACS', + url: legacyUrl, + username: legacyUser, + password: legacyPass, + status: 'active' + }; + } + return null; + } + + if (serverId === 'builtin') { + return { + id: 'builtin', + name: 'Built-in ACS', + url: 'local', + status: 'active' + }; + } + + if (serverId === 'legacy') { + const legacyUrl = getSetting('genieacs_url', GENIEACS_URL); + const legacyUser = getSetting('genieacs_username', GENIEACS_USERNAME); + const legacyPass = getSetting('genieacs_password', GENIEACS_PASSWORD); + + if (legacyUrl) { + return { + id: 'legacy', + name: 'Default ACS', + url: legacyUrl, + username: legacyUser, + password: legacyPass, + status: 'active' + }; + } + return null; + } + + try { + const server = db.prepare('SELECT * FROM genieacs_servers WHERE id = ?').get(serverId); + return server || null; + } catch (error) { + logger.error(`[GenieACS] Error getting ACS server ${serverId}: ${error.message}`); + return null; + } +} + +// Helper: Create axios instance for specific server +function createAxiosInstance(server) { + // Built-in ACS: return proxy object that mimics axios but uses SQLite + if (server && (server.id === 'builtin' || server.url === 'local')) { + return createBuiltinAxiosProxy(); + } + + const config = { + baseURL: server.url.endsWith('/') ? server.url.slice(0, -1) : server.url, + timeout: 30000, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }; + + if (server.username && server.password) { + config.auth = { + username: server.username, + password: server.password + }; + } + + return axios.create(config); +} + +// Legacy axios instance (untuk backward compatibility) +const axiosInstance = axios.create({ + baseURL: GENIEACS_URL, + timeout: 30000, + auth: { + username: GENIEACS_USERNAME, + password: GENIEACS_PASSWORD + }, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } +}); + +// GenieACS API wrapper +const genieacsApi = { + // Get devices from all servers or specific server + async getDevices(serverId = null) { + try { + logger.debug('[GenieACS] Getting all devices...'); + + const servers = serverId ? [getACSServer(serverId)].filter(Boolean) : getAllACSServers(); + const allDevices = []; + + for (const server of servers) { + try { + const instance = createAxiosInstance(server); + let response; + try { + response = await instance.get('/devices', { + timeout: 45000 + }); + } catch (e) { + response = await instance.get('/api/devices', { + timeout: 45000 + }); + } + + const devices = response.data || []; + // Add server info to each device + devices.forEach(device => { + device._acs_server_id = server.id; + device._acs_server_name = server.name; + }); + + allDevices.push(...devices); + logger.debug(`[GenieACS] Found ${devices.length} devices from ${server.name}`); + } catch (error) { + logger.error(`[GenieACS] Error getting devices from ${server.name}: ${error.message}`); + } + } + + logger.debug(`[GenieACS] Total devices found: ${allDevices.length}`); + return allDevices; + } catch (error) { + logger.error(`[GenieACS] Error getting devices: ${error.message}`); + throw error; + } + }, + + // Find device by phone number across all servers (returns full device data) + async findDeviceByPhoneNumber(phoneNumber, serverId = null) { + try { + logger.debug(`[GenieACS] Finding device with phone number: ${phoneNumber}`); + + const servers = serverId ? [getACSServer(serverId)].filter(Boolean) : getAllACSServers(); + + for (const server of servers) { + try { + const instance = createAxiosInstance(server); + // Don't use projection - get full device data + let response; + try { + response = await instance.get('/devices', { + params: { + 'query': JSON.stringify({ + '_tags': phoneNumber + }) + }, + timeout: 15000 + }); + } catch (e) { + response = await instance.get('/api/devices', { + params: { + 'query': JSON.stringify({ + '_tags': phoneNumber + }) + }, + timeout: 15000 + }); + } + + if (response.data && response.data.length > 0) { + const device = response.data[0]; + device._acs_server_id = server.id; + device._acs_server_name = server.name; + logger.debug(`[GenieACS] Device found on ${server.name} with full data`); + return device; + } + } catch (error) { + logger.debug(`[GenieACS] Device not found on ${server.name}: ${error.message}`); + } + } + + throw new Error(`No device found with phone number: ${phoneNumber}`); + } catch (error) { + logger.error(`[GenieACS] Error finding device with phone number ${phoneNumber}: ${error.message}`); + throw error; + } + }, + + async getDeviceByPhoneNumber(phoneNumber, serverId = null) { + try { + // findDeviceByPhoneNumber already returns full device data + return await this.findDeviceByPhoneNumber(phoneNumber, serverId); + } catch (error) { + logger.error(`[GenieACS] Error getting device by phone number ${phoneNumber}: ${error.message}`); + throw error; + } + }, + + async getDevice(deviceId, serverId = null) { + try { + // If serverId provided, use that server + if (serverId) { + const server = getACSServer(serverId); + if (!server) { + throw new Error(`ACS Server ${serverId} not found`); + } + const instance = createAxiosInstance(server); + const response = await instance.get(`/devices/${encodeURIComponent(deviceId)}`); + const device = response.data; + device._acs_server_id = server.id; + device._acs_server_name = server.name; + return device; + } + + // Otherwise, search all servers + const servers = getAllACSServers(); + for (const server of servers) { + try { + const instance = createAxiosInstance(server); + const response = await instance.get(`/devices/${encodeURIComponent(deviceId)}`); + const device = response.data; + device._acs_server_id = server.id; + device._acs_server_name = server.name; + return device; + } catch (error) { + // Continue to next server + } + } + + throw new Error(`Device ${deviceId} not found on any server`); + } catch (error) { + logger.error(`[GenieACS] Error getting device ${deviceId}: ${error.message}`); + throw error; + } + }, + + async setParameterValues(deviceId, parameters, serverId = null) { + try { + logger.debug(`[GenieACS] Setting parameters for device: ${deviceId}`); + + // Format parameter values untuk GenieACS + const parameterValues = []; + for (const [path, value] of Object.entries(parameters)) { + // Handle SSID update + if (path.includes('SSID')) { + parameterValues.push( + ["InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID", value], + ["Device.WiFi.SSID.1.SSID", value] + ); + } + // Handle WiFi password update + else if (path.includes('Password') || path.includes('KeyPassphrase')) { + parameterValues.push( + ["InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase", value], + ["InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase", value], + ["InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey", value] + ); + } + // Handle other parameters + else { + parameterValues.push([path, value]); + } + } + + logger.debug(`[GenieACS] Formatted parameter values count: ${parameterValues.length}`); + + // Get server instance + const server = serverId ? getACSServer(serverId) : (isBuiltinAcsEnabled() ? getACSServer('builtin') : null); + const instance = server ? createAxiosInstance(server) : axiosInstance; + + // Kirim task ke GenieACS + const task = { + name: "setParameterValues", + parameterValues: parameterValues + }; + + const response = await instance.post( + `/devices/${encodeURIComponent(deviceId)}/tasks`, + task + ); + + logger.debug('[GenieACS] Parameter update task queued'); + + // Kirim refresh task + const refreshTask = { + name: "refreshObject", + objectName: "InternetGatewayDevice.LANDevice.1.WLANConfiguration.1" + }; + + const refreshResponse = await instance.post( + `/devices/${encodeURIComponent(deviceId)}/tasks`, + refreshTask + ); + + logger.debug('[GenieACS] Refresh task queued'); + + return response.data; + } catch (error) { + logger.error(`[GenieACS] Error setting parameters for device ${deviceId}: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`); + throw error; + } + }, + + async reboot(deviceId, serverId = null) { + try { + const server = serverId ? getACSServer(serverId) : (isBuiltinAcsEnabled() ? getACSServer('builtin') : null); + const instance = server ? createAxiosInstance(server) : axiosInstance; + + const task = { + name: "reboot", + timestamp: new Date().toISOString() + }; + const response = await instance.post( + `/devices/${encodeURIComponent(deviceId)}/tasks`, + task + ); + return response.data; + } catch (error) { + logger.error(`[GenieACS] Error rebooting device ${deviceId}: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`); + throw error; + } + }, + + async factoryReset(deviceId, serverId = null) { + try { + const server = serverId ? getACSServer(serverId) : (isBuiltinAcsEnabled() ? getACSServer('builtin') : null); + const instance = server ? createAxiosInstance(server) : axiosInstance; + + const task = { + name: "factoryReset", + timestamp: new Date().toISOString() + }; + const response = await instance.post( + `/devices/${encodeURIComponent(deviceId)}/tasks`, + task + ); + return response.data; + } catch (error) { + logger.error(`[GenieACS] Error factory resetting device ${deviceId}: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`); + throw error; + } + }, + + async getDeviceParameters(deviceId, parameterNames) { + try { + if (isBuiltinAcsEnabled()) { + const instance = createBuiltinAxiosProxy(); + const response = await instance.get(`/devices/${encodeURIComponent(deviceId)}`); + return response.data; + } + const queryString = parameterNames.map(name => `query=${encodeURIComponent(name)}`).join('&'); + const response = await axiosInstance.get(`/devices/${encodeURIComponent(deviceId)}?${queryString}`); + return response.data; + } catch (error) { + logger.error(`[GenieACS] Error getting parameters for device ${deviceId}: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`); + throw error; + } + }, + + async getDeviceInfo(deviceId) { + try { + logger.debug(`[GenieACS] Getting device info for device ID: ${deviceId}`); + + // Built-in ACS mode + if (isBuiltinAcsEnabled()) { + const instance = createBuiltinAxiosProxy(); + const response = await instance.get(`/devices/${encodeURIComponent(deviceId)}`); + return response.data; + } + + // Mendapatkan device detail + const deviceResponse = await axios.get(`${GENIEACS_URL}/devices/${encodeURIComponent(deviceId)}`, { + auth: { + username: GENIEACS_USERNAME, + password: GENIEACS_PASSWORD + } + }); + + if (!deviceResponse.data) { + logger.warn('[GenieACS] No device data found'); + return null; + } + + logger.debug('[GenieACS] Device data retrieved successfully'); + return deviceResponse.data; + } catch (error) { + logger.error(`[GenieACS] Error getting device info: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`); + return null; + } + }, + + async getVirtualParameters(deviceId) { + try { + logger.debug(`[GenieACS] Getting virtual parameters for device ID: ${deviceId}`); + + // Built-in ACS mode: return device data directly (params already stored) + if (isBuiltinAcsEnabled()) { + const instance = createBuiltinAxiosProxy(); + try { + const response = await instance.get(`/devices/${encodeURIComponent(deviceId)}`); + return response.data; + } catch (e) { + return null; + } + } + + const virtualParams = [ + // Serial Number + 'InternetGatewayDevice.DeviceInfo.SerialNumber', + 'Device.DeviceInfo.SerialNumber', + 'VirtualParameters.getSerialNumber', + + // Device Uptime + 'InternetGatewayDevice.DeviceInfo.UpTime', + 'Device.DeviceInfo.UpTime', + 'VirtualParameters.getdeviceuptime', + + // PPPoE Uptime + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.UpTime', + 'Device.PPP.Interface.1.UpTime', + 'VirtualParameters.getpppuptime', + + // Active Devices + 'InternetGatewayDevice.LANDevice.1.Hosts.HostNumberOfEntries', + 'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.TotalAssociations', + 'Device.Hosts.HostNumberOfEntries', + 'VirtualParameters.activedevices', + 'VirtualParameters.getactivedevices', + + // RX Power + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.XPON.RxPower', + 'Device.XPON.Interface.1.RxPower', + 'VirtualParameters.RXPower', + 'VirtualParameters.redaman', + + // PON MAC + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.MACAddress', + 'Device.Ethernet.Interface.1.MACAddress', + 'VirtualParameters.PonMac', + + // WAN IP + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress', + 'Device.IP.Interface.1.IPv4Address.1.IPAddress', + 'VirtualParameters.WanIP', + + // PPP IP + 'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress', + 'Device.PPP.Interface.1.IPCP.LocalIPAddress', + 'VirtualParameters.pppIP', + 'VirtualParameters.pppoeIP', + + // Temperature + 'InternetGatewayDevice.DeviceInfo.Temperature', + 'Device.DeviceInfo.Temperature', + 'VirtualParameters.gettemp' + ]; + + // Menggunakan tasks endpoint untuk mendapatkan parameter values + const response = await axios.post(`${GENIEACS_URL}/tasks`, [{ + name: "getParameterValues", + parameterNames: virtualParams, + device: deviceId + }], { + auth: { + username: GENIEACS_USERNAME, + password: GENIEACS_PASSWORD + } + }); + + logger.debug('[GenieACS] Virtual parameters retrieved successfully'); + return response.data; + } catch (error) { + logger.error(`[GenieACS] Error getting virtual parameters: ${error.response?.data ? JSON.stringify(error.response.data) : error.message}`); + return null; + } + }, +}; + +// Fungsi untuk memeriksa nilai RXPower dari semua perangkat +async function monitorRXPower(threshold = -27) { + try { + logger.info(`[RXPower] Memulai pemantauan RXPower dengan threshold ${threshold} dBm`); + + // Ambil semua perangkat + const devices = await genieacsApi.getDevices(); + logger.info(`[RXPower] Memeriksa RXPower untuk ${devices.length} perangkat...`); + + // Ambil data PPPoE dari Mikrotik + logger.debug('[RXPower] Mengambil data PPPoE dari Mikrotik...'); + // const conn = await getMikrotikConnection(); // Removed Mikrotik connection + let pppoeSecrets = []; + + // if (conn) { // Removed Mikrotik connection + // try { + // // Dapatkan semua PPPoE secret dari Mikrotik + // pppoeSecrets = await conn.write('/ppp/secret/print'); + // console.log(`Ditemukan ${pppoeSecrets.length} PPPoE secret`); + // } catch (error) { + // console.error('Error mendapatkan PPPoE secret:', error.message); + // } + // } + + const criticalDevices = []; + + // Periksa setiap perangkat + for (const device of devices) { + try { + // Dapatkan nilai RXPower + const rxPowerPaths = [ + 'VirtualParameters.RXPower', + 'VirtualParameters.redaman', + 'InternetGatewayDevice.WANDevice.1.WANPONInterfaceConfig.RXPower', + 'Device.XPON.Interface.1.Stats.RXPower' + ]; + + let rxPower = null; + + // Periksa setiap jalur yang mungkin berisi nilai RXPower + for (const path of rxPowerPaths) { + // Ekstrak nilai menggunakan path yang ada di device + if (getRXPowerValue(device, path)) { + rxPower = getRXPowerValue(device, path); + break; + } + } + + // Jika rxPower ditemukan dan di bawah threshold + if (rxPower !== null && parseFloat(rxPower) < threshold) { + // Cari PPPoE username dari parameter perangkat (seperti di handleAdminCheckONU) + let pppoeUsername = "Unknown"; + const serialNumber = getDeviceSerialNumber(device); + const deviceId = device._id; + const shortDeviceId = deviceId.split('-')[2] || deviceId; + + // Ambil PPPoE username dari parameter perangkat + pppoeUsername = + device.InternetGatewayDevice?.WANDevice?.['1']?.WANConnectionDevice?.['1']?.WANPPPConnection?.['1']?.Username?._value || + device.InternetGatewayDevice?.WANDevice?.['0']?.WANConnectionDevice?.['0']?.WANPPPConnection?.['0']?.Username?._value || + device.VirtualParameters?.pppoeUsername?._value || + "Unknown"; + + // Jika tidak ditemukan dari parameter perangkat, coba cari dari PPPoE secret di Mikrotik + if (pppoeUsername === "Unknown") { + // Coba cari PPPoE secret yang terkait dengan perangkat ini berdasarkan comment + const matchingSecret = pppoeSecrets.find(secret => { + if (!secret.comment) return false; + + // Cek apakah serial number atau device ID ada di kolom comment + return ( + secret.comment.includes(serialNumber) || + secret.comment.includes(shortDeviceId) + ); + }); + + if (matchingSecret) { + // Jika ditemukan secret yang cocok, gunakan nama secret sebagai username + pppoeUsername = matchingSecret.name; + logger.debug(`[RXPower] Menemukan PPPoE username ${pppoeUsername} untuk perangkat ${shortDeviceId} dari PPPoE secret`); + } + } else { + logger.debug(`[RXPower] Menemukan PPPoE username ${pppoeUsername} untuk perangkat ${shortDeviceId} dari parameter perangkat`); + } + + // Jika masih tidak ditemukan, coba cari dari tag perangkat + if (pppoeUsername === "Unknown" && device._tags && Array.isArray(device._tags)) { + // Cek apakah ada tag yang dimulai dengan "pppoe:" yang berisi username + const pppoeTag = device._tags.find(tag => tag.startsWith('pppoe:')); + if (pppoeTag) { + pppoeUsername = pppoeTag.replace('pppoe:', ''); + logger.debug(`[RXPower] Menemukan PPPoE username ${pppoeUsername} untuk perangkat ${shortDeviceId} dari tag`); + } else { + logger.debug(`[RXPower] Tidak menemukan PPPoE username untuk perangkat ${shortDeviceId}, tags: ${JSON.stringify(device._tags)}`); + } + } + + const deviceInfo = { + id: device._id, + rxPower, + serialNumber: getDeviceSerialNumber(device), + lastInform: device._lastInform, + pppoeUsername: pppoeUsername + }; + + criticalDevices.push(deviceInfo); + logger.info(`[RXPower] Perangkat redaman tinggi: ${deviceInfo.id}, RXPower: ${rxPower} dBm, PPPoE: ${pppoeUsername}`); + } + } catch (deviceError) { + logger.error(`[RXPower] Error memeriksa RXPower untuk perangkat ${device._id}: ${deviceError.message || String(deviceError)}`); + } + } + + // Jika ada perangkat dengan RXPower di bawah threshold + if (criticalDevices.length > 0) { + // Buat pesan peringatan + let message = `*PERINGATAN: REDAMAN TINGGI*\n\n`; + message += `${criticalDevices.length} perangkat memiliki nilai RX Power lebih buruk dari ${threshold} dBm (semakin negatif = semakin buruk):\n\n`; + + criticalDevices.forEach((device, index) => { + message += `${index + 1}. *Device ID:* ${device.id.split('-')[2] || device.id}\n`; + message += ` 📱 *S/N:* ${device.serialNumber}\n`; + message += ` 👤 *PPPoE:* ${device.pppoeUsername}\n`; + message += ` 📡 *RX Power:* ${device.rxPower} dBm\n`; + message += ` 🕒 *Last Inform:* ${new Date(device.lastInform).toLocaleString('id-ID')}\n\n`; + }); + + message += `⚠️ *Mohon segera dicek untuk menghindari koneksi terputus.*`; + + // Kirim notifikasi WhatsApp jika tersedia + if (sendMonitoringAlert) { + try { + const result = await sendMonitoringAlert(message, 'high'); + if (result.success) { + logger.info(`[RXPower] Notifikasi WhatsApp terkirim: ${result.message}`); + } else { + logger.warn(`[RXPower] Gagal mengirim notifikasi WhatsApp: ${result.message}`); + } + } catch (error) { + logger.error(`[RXPower] Error mengirim notifikasi WhatsApp: ${error.message}`); + } + } else { + logger.warn('[RXPower] WhatsApp bot tidak tersedia untuk mengirim notifikasi'); + } + + logger.info(`[RXPower] Pesan peringatan dibuat untuk ${criticalDevices.length} perangkat`); + } else { + logger.info('[RXPower] Tidak ada perangkat dengan nilai RXPower di bawah threshold'); + } + + return { + success: true, + criticalDevices, + message: `${criticalDevices.length} perangkat memiliki RXPower di atas threshold` + }; + } catch (error) { + logger.error(`[RXPower] Error memantau RXPower: ${error.message || String(error)}`); + return { + success: false, + message: `Error memantau RXPower: ${error.message}`, + error + }; + } +} + +// Helper function untuk mendapatkan nilai RXPower +function getRXPowerValue(device, path) { + try { + // Split path menjadi parts + const parts = path.split('.'); + let current = device; + + // Navigate through nested properties + for (const part of parts) { + if (!current) return null; + current = current[part]; + } + + // Check if it's a GenieACS parameter object + if (current && current._value !== undefined) { + return current._value; + } + + return null; + } catch (error) { + logger.debug(`[RXPower] Error getting RXPower from path ${path}: ${error.message || String(error)}`); + return null; + } +} + +// Helper function untuk mendapatkan serial number +function getDeviceSerialNumber(device) { + try { + const serialPaths = [ + 'DeviceID.SerialNumber', + 'InternetGatewayDevice.DeviceInfo.SerialNumber', + 'Device.DeviceInfo.SerialNumber' + ]; + + for (const path of serialPaths) { + const parts = path.split('.'); + let current = device; + + for (const part of parts) { + if (!current) break; + current = current[part]; + } + + if (current && current._value !== undefined) { + return current._value; + } + } + + // Fallback ke ID perangkat jika serial number tidak ditemukan + if (device._id) { + const parts = device._id.split('-'); + if (parts.length >= 3) { + return parts[2]; + } + return device._id; + } + + return 'Unknown'; + } catch (error) { + logger.error('Error getting device serial number:', error); + return 'Unknown'; + } +} + +// Fungsi untuk memantau perangkat yang tidak aktif (offline) +async function monitorOfflineDevices(thresholdHours = 24) { + try { + logger.info(`[Offline] Memulai pemantauan perangkat offline dengan threshold ${thresholdHours} jam`); + + // Ambil semua perangkat + const devices = await genieacsApi.getDevices(); + logger.info(`[Offline] Memeriksa status untuk ${devices.length} perangkat...`); + + const offlineDevices = []; + const now = new Date(); + const thresholdMs = thresholdHours * 60 * 60 * 1000; // Convert jam ke ms + + // Periksa setiap perangkat + for (const device of devices) { + try { + if (!device._lastInform) { + logger.info(`[Offline] Perangkat ${device._id} tidak memiliki lastInform`); + continue; + } + + const lastInformTime = new Date(device._lastInform).getTime(); + const timeDiff = now.getTime() - lastInformTime; + + // Jika perangkat belum melakukan inform dalam waktu yang melebihi threshold + if (timeDiff > thresholdMs) { + const deviceInfo = { + id: device._id, + serialNumber: getDeviceSerialNumber(device), + lastInform: device._lastInform, + offlineHours: Math.round(timeDiff / (60 * 60 * 1000) * 10) / 10 // Jam dengan 1 desimal + }; + + offlineDevices.push(deviceInfo); + logger.info(`[Offline] Perangkat offline: ${deviceInfo.id}, Offline selama: ${deviceInfo.offlineHours} jam`); + } + } catch (deviceError) { + logger.error(`[Offline] Error memeriksa status untuk perangkat ${device._id}: ${deviceError.message || String(deviceError)}`); + } + } + + // Jika ada perangkat yang offline + if (offlineDevices.length > 0) { + // Buat pesan peringatan + let message = `*PERINGATAN: PERANGKAT OFFLINE*\n\n`; + message += `${offlineDevices.length} perangkat offline lebih dari ${thresholdHours} jam:\n\n`; + + offlineDevices.forEach((device, index) => { + message += `${index + 1}. *Device ID:* ${device.id.split('-')[2] || device.id}\n`; + message += ` 📱 *S/N:* ${device.serialNumber}\n`; + message += ` ⏱️ *Offline:* ${device.offlineHours} jam\n`; + message += ` 🕒 *Last Inform:* ${new Date(device.lastInform).toLocaleString('id-ID')}\n\n`; + }); + + message += `⚠️ *Mohon segera ditindaklanjuti.*`; + + // Kirim notifikasi WhatsApp jika tersedia + if (sendMonitoringAlert) { + try { + const result = await sendMonitoringAlert(message, 'medium'); + if (result.success) { + logger.info(`[Offline] Notifikasi WhatsApp terkirim: ${result.message}`); + } else { + logger.warn(`[Offline] Gagal mengirim notifikasi WhatsApp: ${result.message}`); + } + } catch (error) { + logger.error(`[Offline] Error mengirim notifikasi WhatsApp: ${error.message}`); + } + } else { + logger.warn('[Offline] WhatsApp bot tidak tersedia untuk mengirim notifikasi'); + } + + logger.info(`[Offline] Pesan peringatan perangkat offline terkirim untuk ${offlineDevices.length} perangkat`); + } else { + logger.info('[Offline] Tidak ada perangkat yang offline lebih dari threshold'); + } + + return { + success: true, + offlineDevices, + message: `${offlineDevices.length} perangkat offline lebih dari ${thresholdHours} jam` + }; + } catch (error) { + logger.error(`[Offline] Error memantau perangkat offline: ${error.message || String(error)}`); + return { + success: false, + message: `Error memantau perangkat offline: ${error.message}`, + error + }; + } +} + +// Jadwalkan monitoring setiap 6 jam +function scheduleMonitoring() { + // Jalankan sekali saat startup (delay lebih lama untuk stabilitas) + setTimeout(async () => { + logger.info('[Monitoring] Menjalankan pemantauan RXPower awal...'); + try { + await monitorRXPower(); + } catch (error) { + logger.error('[Monitoring] Error pada pemantauan RXPower awal:', error.message); + } + + logger.info('[Monitoring] Menjalankan pemantauan perangkat offline awal...'); + try { + await monitorOfflineDevices(); + } catch (error) { + logger.error('[Monitoring] Error pada pemantauan offline awal:', error.message); + } + + // Jadwalkan secara berkala dengan error handling + setInterval(async () => { + logger.info('[Monitoring] Menjalankan pemantauan RXPower terjadwal...'); + try { + await monitorRXPower(); + } catch (error) { + logger.error('[Monitoring] Error pada pemantauan RXPower:', error.message); + } + }, 6 * 60 * 60 * 1000); // Setiap 6 jam + + setInterval(async () => { + logger.info('[Monitoring] Menjalankan pemantauan perangkat offline terjadwal...'); + try { + await monitorOfflineDevices(); + } catch (error) { + logger.error('[Monitoring] Error pada pemantauan offline:', error.message); + } + }, 12 * 60 * 60 * 1000); // Setiap 12 jam + }, 10 * 60 * 1000); // Mulai 10 menit setelah server berjalan (lebih stabil) +} + +// Jalankan penjadwalan monitoring +scheduleMonitoring(); + +module.exports = { + // Multi-server helpers + getAllACSServers, + getACSServer, + createAxiosInstance, + isBuiltinAcsEnabled, + + // GenieACS API methods (now support multi-server) + getDevices: genieacsApi.getDevices, + getDevice: genieacsApi.getDevice, + getDeviceInfo: genieacsApi.getDeviceInfo, + findDeviceByPhoneNumber: genieacsApi.findDeviceByPhoneNumber, + getDeviceByPhoneNumber: genieacsApi.getDeviceByPhoneNumber, + setParameterValues: genieacsApi.setParameterValues, + reboot: genieacsApi.reboot, + factoryReset: genieacsApi.factoryReset, + getVirtualParameters: genieacsApi.getVirtualParameters, + + // Monitoring functions (automatically support multi-server) + monitorRXPower, + monitorOfflineDevices +}; diff --git a/config/i18n.js b/config/i18n.js new file mode 100644 index 0000000..1b0ae2c --- /dev/null +++ b/config/i18n.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const path = require('path'); + +const SUPPORTED_LANGS = new Set(['id', 'en']); +const FALLBACK_LANG = 'id'; + +function loadLocale(lang) { + try { + const filePath = path.join(__dirname, `../locales/${lang}.json`); + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return {}; + } +} + +const dictionaries = { + id: loadLocale('id'), + en: loadLocale('en') +}; + +function normalizeLang(input) { + const lang = String(input || '').trim().toLowerCase(); + return SUPPORTED_LANGS.has(lang) ? lang : FALLBACK_LANG; +} + +function getNestedValue(obj, keyPath) { + const keys = String(keyPath || '').split('.'); + let cur = obj; + for (const k of keys) { + if (!cur || typeof cur !== 'object' || !(k in cur)) return undefined; + cur = cur[k]; + } + return cur; +} + +function t(lang, key, fallback = '') { + const useLang = normalizeLang(lang); + const fromLang = getNestedValue(dictionaries[useLang], key); + if (fromLang !== undefined) return fromLang; + const fromFallback = getNestedValue(dictionaries[FALLBACK_LANG], key); + if (fromFallback !== undefined) return fromFallback; + return fallback || key; +} + +module.exports = { + SUPPORTED_LANGS, + FALLBACK_LANG, + normalizeLang, + t +}; diff --git a/config/logger.js b/config/logger.js new file mode 100644 index 0000000..5362d4e --- /dev/null +++ b/config/logger.js @@ -0,0 +1,68 @@ +// Modul logger untuk aplikasi +const winston = require('winston'); +const path = require('path'); +const fs = require('fs'); + +// Buat direktori logs jika belum ada +const logsDir = path.join(process.cwd(), 'logs'); +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +// Format untuk log +const logFormat = winston.format.printf(({ level, message, timestamp }) => { + return `${timestamp} ${level}: ${message}`; +}); + +// Konfigurasi logger +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + exitOnError: false, // Jangan exit saat uncaught exception (e.g. koneksi MikroTik gagal) + format: winston.format.combine( + winston.format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss' + }), + logFormat + ), + transports: [ + // Log ke file + new winston.transports.File({ + filename: path.join(logsDir, 'error.log'), + level: 'error' + }), + new winston.transports.File({ + filename: path.join(logsDir, 'combined.log') + }), + // Log ke console + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss' + }), + logFormat + ) + }) + ], + exceptionHandlers: [ + new winston.transports.File({ + filename: path.join(logsDir, 'exceptions.log') + }), + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss' + }), + logFormat + ) + }) + ], + rejectionHandlers: [ + new winston.transports.File({ + filename: path.join(logsDir, 'rejections.log') + }) + ] +}); + +module.exports = { logger }; diff --git a/config/settingsAudit.js b/config/settingsAudit.js new file mode 100644 index 0000000..85c9c46 --- /dev/null +++ b/config/settingsAudit.js @@ -0,0 +1,245 @@ +/** + * Settings Audit Trail + * Log semua perubahan settings untuk compliance & debugging + */ +const fs = require('fs'); +const path = require('path'); +const { logger } = require('./logger'); +const { isSensitiveField, maskValue } = require('./settingsEncryption'); + +const AUDIT_LOG_DIR = path.join(__dirname, '../logs/settings-audit'); +const AUDIT_LOG_FILE = path.join(AUDIT_LOG_DIR, 'settings-changes.jsonl'); + +// Ensure audit log directory exists +if (!fs.existsSync(AUDIT_LOG_DIR)) { + fs.mkdirSync(AUDIT_LOG_DIR, { recursive: true }); +} + +/** + * Log settings change + */ +function logSettingsChange(actor, changes, metadata = {}) { + try { + const timestamp = new Date().toISOString(); + + // Mask sensitive values + const maskedChanges = {}; + Object.keys(changes).forEach(field => { + if (isSensitiveField(field)) { + maskedChanges[field] = { + oldValue: maskValue(changes[field].oldValue), + newValue: maskValue(changes[field].newValue) + }; + } else { + maskedChanges[field] = changes[field]; + } + }); + + const logEntry = { + timestamp, + actor: actor || 'system', + changes: maskedChanges, + ip: metadata.ip || 'unknown', + userAgent: metadata.userAgent || 'unknown', + metadata + }; + + // Write to JSONL file + fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(logEntry) + '\n', 'utf-8'); + + logger.info(`[settings-audit] Settings changed by ${actor}`, { + changes: Object.keys(maskedChanges), + timestamp + }); + + return true; + } catch (error) { + logger.error(`[settings-audit] Error logging settings change: ${error.message}`); + return false; + } +} + +/** + * Get settings change history + */ +function getChangeHistory(limit = 100) { + try { + if (!fs.existsSync(AUDIT_LOG_FILE)) return []; + + const lines = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8').split('\n').filter(l => l.trim()); + const entries = lines.map(line => JSON.parse(line)); + + // Return latest entries + return entries.slice(-limit).reverse(); + } catch (error) { + logger.error(`[settings-audit] Error reading change history: ${error.message}`); + return []; + } +} + +/** + * Get changes by actor + */ +function getChangesByActor(actor, limit = 50) { + try { + const history = getChangeHistory(1000); + return history.filter(entry => entry.actor === actor).slice(0, limit); + } catch (error) { + logger.error(`[settings-audit] Error filtering by actor: ${error.message}`); + return []; + } +} + +/** + * Get changes by field + */ +function getChangesByField(field, limit = 50) { + try { + const history = getChangeHistory(1000); + return history.filter(entry => entry.changes[field]).slice(0, limit); + } catch (error) { + logger.error(`[settings-audit] Error filtering by field: ${error.message}`); + return []; + } +} + +/** + * Get changes in date range + */ +function getChangesByDateRange(startDate, endDate, limit = 100) { + try { + const history = getChangeHistory(1000); + const start = new Date(startDate).getTime(); + const end = new Date(endDate).getTime(); + + return history.filter(entry => { + const entryTime = new Date(entry.timestamp).getTime(); + return entryTime >= start && entryTime <= end; + }).slice(0, limit); + } catch (error) { + logger.error(`[settings-audit] Error filtering by date range: ${error.message}`); + return []; + } +} + +/** + * Export audit log + */ +function exportAuditLog(format = 'json') { + try { + const history = getChangeHistory(10000); + + if (format === 'csv') { + // Convert to CSV + const headers = ['Timestamp', 'Actor', 'Field', 'Old Value', 'New Value', 'IP']; + const rows = []; + + history.forEach(entry => { + Object.keys(entry.changes).forEach(field => { + rows.push([ + entry.timestamp, + entry.actor, + field, + entry.changes[field].oldValue, + entry.changes[field].newValue, + entry.ip + ]); + }); + }); + + return { + headers, + rows, + data: [headers, ...rows].map(row => row.join(',')).join('\n') + }; + } + + // Default JSON format + return { + format: 'json', + count: history.length, + data: history + }; + } catch (error) { + logger.error(`[settings-audit] Error exporting audit log: ${error.message}`); + return null; + } +} + +/** + * Clear old audit logs (retention policy) + */ +function clearOldLogs(daysToKeep = 90) { + try { + if (!fs.existsSync(AUDIT_LOG_FILE)) return; + + const lines = fs.readFileSync(AUDIT_LOG_FILE, 'utf-8').split('\n').filter(l => l.trim()); + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const recentLines = lines.filter(line => { + try { + const entry = JSON.parse(line); + return new Date(entry.timestamp) > cutoffDate; + } catch { + return false; + } + }); + + fs.writeFileSync(AUDIT_LOG_FILE, recentLines.join('\n') + '\n', 'utf-8'); + + logger.info(`[settings-audit] Cleared old logs, kept ${recentLines.length} entries`); + return true; + } catch (error) { + logger.error(`[settings-audit] Error clearing old logs: ${error.message}`); + return false; + } +} + +/** + * Get audit statistics + */ +function getAuditStats() { + try { + const history = getChangeHistory(10000); + + const stats = { + totalChanges: history.length, + uniqueActors: new Set(history.map(e => e.actor)).size, + changedFields: new Set(), + changesByActor: {}, + changesByField: {}, + lastChange: history[0] || null + }; + + history.forEach(entry => { + // Count by actor + stats.changesByActor[entry.actor] = (stats.changesByActor[entry.actor] || 0) + 1; + + // Count by field + Object.keys(entry.changes).forEach(field => { + stats.changedFields.add(field); + stats.changesByField[field] = (stats.changesByField[field] || 0) + 1; + }); + }); + + stats.changedFields = Array.from(stats.changedFields); + + return stats; + } catch (error) { + logger.error(`[settings-audit] Error getting audit stats: ${error.message}`); + return null; + } +} + +module.exports = { + logSettingsChange, + getChangeHistory, + getChangesByActor, + getChangesByField, + getChangesByDateRange, + exportAuditLog, + clearOldLogs, + getAuditStats, + AUDIT_LOG_FILE +}; diff --git a/config/settingsEncryption.js b/config/settingsEncryption.js new file mode 100644 index 0000000..32fd90a --- /dev/null +++ b/config/settingsEncryption.js @@ -0,0 +1,179 @@ +/** + * Settings Encryption & Decryption + * Untuk encrypt/decrypt sensitive fields di settings.json + */ +const crypto = require('crypto'); +const { logger } = require('./logger'); + +// Master key untuk encryption (bisa dari environment variable) +// PENTING: Ganti dengan key yang aman di production +const MASTER_KEY = process.env.SETTINGS_MASTER_KEY || 'default-master-key-change-this-in-production'; + +function getMasterKeyForString(keyStr) { + const hash = crypto.createHash('sha256'); + hash.update(keyStr || ''); + return hash.digest(); +} + +// Normalize master key ke 32 bytes untuk AES-256 +function getMasterKey() { + return getMasterKeyForString(MASTER_KEY); +} + +// List field yang harus di-encrypt +const SENSITIVE_FIELDS = [ + 'genieacs_password', + 'admin_password', + 'admin_api_key', + 'mikrotik_password', + 'telegram_bot_token', + 'session_secret' +]; + +/** + * Encrypt value menggunakan AES-256-GCM + */ +function encryptValue(value) { + if (!value || typeof value !== 'string') return value; + + try { + const masterKey = getMasterKey(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', masterKey, iv); + + let encrypted = cipher.update(value, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + // Format: iv:authTag:encrypted + return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; + } catch (error) { + logger.error(`[encryption] Error encrypting value: ${error.message}`); + return value; + } +} + +function decryptWithKey(encryptedValue, key) { + const parts = encryptedValue.split(':'); + if (parts.length !== 4) { + throw new Error('Invalid encrypted value format'); + } + + const [prefix, ivHex, authTagHex, encrypted] = parts; + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +/** + * Decrypt value + */ +function decryptValue(encryptedValue) { + if (!encryptedValue || typeof encryptedValue !== 'string') return encryptedValue; + if (!encryptedValue.startsWith('enc:')) return encryptedValue; // Belum di-encrypt + + const primaryKey = getMasterKey(); + try { + return decryptWithKey(encryptedValue, primaryKey); + } catch (error) { + // If decryption fails, and primaryKey is not the default fallback key, try the fallback key + const defaultSecret = 'default-master-key-change-this-in-production'; + if (MASTER_KEY !== defaultSecret) { + try { + const fallbackKey = getMasterKeyForString(defaultSecret); + const decrypted = decryptWithKey(encryptedValue, fallbackKey); + logger.info('[encryption] Successfully decrypted value using default fallback key. It will be re-encrypted using the new key upon next save.'); + return decrypted; + } catch (fallbackError) { + // Both primary key and fallback key failed + logger.error(`[encryption] Error decrypting value (both primary and fallback keys failed): ${fallbackError.message}`); + } + } else { + logger.error(`[encryption] Error decrypting value: ${error.message}`); + } + return encryptedValue; + } +} + +/** + * Encrypt sensitive fields dalam settings object + */ +function encryptSettings(settings) { + const encrypted = { ...settings }; + + SENSITIVE_FIELDS.forEach(field => { + if (encrypted[field]) { + encrypted[field] = encryptValue(encrypted[field]); + } + }); + + return encrypted; +} + +/** + * Decrypt sensitive fields dalam settings object + */ +function decryptSettings(settings) { + const decrypted = { ...settings }; + + SENSITIVE_FIELDS.forEach(field => { + if (decrypted[field]) { + decrypted[field] = decryptValue(decrypted[field]); + } + }); + + return decrypted; +} + +/** + * Mask sensitive values untuk display (show first 4 & last 4 chars) + */ +function maskValue(value) { + if (!value || typeof value !== 'string') return value; + if (value.length <= 8) return '****'; + + const first = value.substring(0, 4); + const last = value.substring(value.length - 4); + return `${first}****${last}`; +} + +/** + * Get masked settings untuk display di UI + */ +function getMaskedSettings(settings) { + const masked = { ...settings }; + + SENSITIVE_FIELDS.forEach(field => { + if (masked[field]) { + masked[field] = maskValue(masked[field]); + } + }); + + return masked; +} + +/** + * Check apakah field adalah sensitive + */ +function isSensitiveField(field) { + return SENSITIVE_FIELDS.includes(field); +} + +module.exports = { + encryptValue, + decryptValue, + encryptSettings, + decryptSettings, + maskValue, + getMaskedSettings, + isSensitiveField, + SENSITIVE_FIELDS +}; diff --git a/config/settingsManager.js b/config/settingsManager.js new file mode 100644 index 0000000..84ce5ad --- /dev/null +++ b/config/settingsManager.js @@ -0,0 +1,354 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { logger } = require('./logger'); + +let runtimeSessionSecret = null; +function getSecureSessionSecretFallback() { + if (!runtimeSessionSecret) { + runtimeSessionSecret = crypto.randomBytes(32).toString('hex'); + } + return runtimeSessionSecret; +} + +// Cache untuk settings dengan timestamp +let settingsCache = null; +let settingsCacheTime = 0; +const CACHE_DURATION = 2000; // 2 detik + +// File system watcher untuk auto-reload settings +const settingsPath = path.join(__dirname, '../settings.json'); +let watcher = null; + +// Helper untuk baca settings.json secara dinamis +function getSettings() { + try { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) || {}; + + // Secure fallback for session_secret + const defaultSecret = 'rahasia-portal-pelanggan-default-ganti-ini'; + if (!settings.session_secret || settings.session_secret === defaultSecret) { + settings.session_secret = getSecureSessionSecretFallback(); + } + + const fallbackTz = 'Asia/Jakarta'; + const tz = typeof settings.timezone === 'string' ? settings.timezone.trim() : ''; + + if (!tz) { + settings.timezone = fallbackTz; + return settings; + } + + try { + new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); + settings.timezone = tz; + } catch (e) { + settings.timezone = fallbackTz; + } + + return settings; + } catch (error) { + logger.error(`[settings] Error reading settings.json: ${error.message}`); + // Kembalikan cache terakhir yang valid (hindari kehilangan setting karena file setengah tertulis) + return settingsCache || {}; + } +} + +// Helper untuk baca settings.json dengan cache +function getSettingsWithCache() { + const now = Date.now(); + if (!settingsCache || (now - settingsCacheTime) > CACHE_DURATION) { + settingsCache = getSettings(); + settingsCacheTime = now; + } + return settingsCache; +} + +// Helper untuk mendapatkan nilai setting dengan fallback +function getSetting(key, defaultValue = null) { + const settings = getSettingsWithCache(); + return settings[key] !== undefined ? settings[key] : defaultValue; +} + +// Helper untuk mendapatkan multiple settings +function getSettingsByKeys(keys) { + const settings = getSettingsWithCache(); + const result = {}; + keys.forEach(key => { + result[key] = settings[key]; + }); + return result; +} + +// File system watcher untuk auto-reload settings +function startSettingsWatcher() { + try { + // Hapus watcher lama jika ada + if (watcher) { + watcher.close(); + } + + // Buat watcher baru + watcher = fs.watch(settingsPath, (eventType, filename) => { + if (eventType !== 'change') return; + // Di Windows `filename` sering null; hanya abaikan jika jelas bukan settings.json + if (filename != null && filename !== 'settings.json') return; + + settingsCache = null; + settingsCacheTime = 0; + + try { + const s = getSettingsWithCache(); + const port = s.server_port ?? 4555; + const host = s.server_host || 'localhost'; + const gurl = s.genieacs_url || '(tidak diatur)'; + const company = s.company_header || '(default)'; + logger.info(`[settings] settings.json dimuat ulang — port ${port}, host ${host}, company: ${company}, GenieACS: ${gurl}`); + } catch (error) { + logger.error(`[settings] Gagal memuat ulang settings.json: ${error.message}`); + } + }); + + logger.info('[settings] Memantau perubahan settings.json'); + } catch (error) { + logger.error(`[settings] Error starting settings watcher: ${error.message}`); + } +} + +// Mulai watcher saat modul dimuat +startSettingsWatcher(); + +// Menyimpan pengaturan ke settings.json +function saveSettings(newSettings) { + try { + const currentSettings = getSettings(); + const updatedSettings = { ...currentSettings, ...newSettings }; + // Cadangan rolling versi sebelumnya (mencegah kehilangan setting bila tertimpa salah) + try { + if (fs.existsSync(settingsPath)) { + fs.copyFileSync(settingsPath, settingsPath + '.prev'); + } + } catch (e) { /* abaikan */ } + const tmpPath = settingsPath + '.tmp-' + process.pid; + fs.writeFileSync(tmpPath, JSON.stringify(updatedSettings, null, 2), 'utf-8'); + fs.renameSync(tmpPath, settingsPath); // atomik: pembaca tidak pernah melihat file setengah jadi + settingsCache = updatedSettings; + settingsCacheTime = Date.now(); + return true; + } catch (error) { + logger.error(`[settings] Error saving settings.json: ${error.message}`); + return false; + } +} + +/** + * Helper untuk mendapatkan waktu sekarang dalam format lokal + * sesuai timezone yang diatur di settings.json + */ +function getNowLocal() { + const tz = getSetting('timezone', 'Asia/Jakarta'); + const now = new Date(); + const options = { + timeZone: tz, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam) + }; + const formatter = new Intl.DateTimeFormat('en-US', options); + const parts = formatter.formatToParts(now); + const p = {}; + parts.forEach(part => p[part.type] = part.value); + return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`; +} + +/** + * Helper untuk mendapatkan objek Date yang sudah disesuaikan dengan timezone di settings. + * Mengembalikan objek Date yang "angkanya" sudah sesuai dengan waktu lokal. + */ +function getCurrentDateInTimezone() { + const tz = getSetting('timezone', 'Asia/Jakarta'); + const now = new Date(); + + // Ambil string format ISO lokal + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam) + }); + + const parts = formatter.formatToParts(now); + const p = {}; + parts.forEach(part => p[part.type] = part.value); + + // Buat objek Date baru dengan nilai lokal tersebut + return new Date(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}`); +} + +/** + * Mendapatkan info waktu sekarang (year, month, day, dll) dalam timezone yang diatur. + */ +function getCurrentTimeInfo() { + const tz = getSetting('timezone', 'Asia/Jakarta'); + const now = new Date(); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + year: 'numeric', month: 'numeric', day: 'numeric', + hour: 'numeric', minute: 'numeric', second: 'numeric', + hourCycle: 'h23' // 💡 Paksa format jam 00-23 (mencegah bug '24' di tengah malam) + }); + + + const parts = formatter.formatToParts(now); + const p = {}; + parts.forEach(part => p[part.type] = part.value); + + return { + year: parseInt(p.year), + month: parseInt(p.month), + day: parseInt(p.day), + hour: parseInt(p.hour), + minute: parseInt(p.minute), + second: parseInt(p.second) + }; +} + +/** + * Mendapatkan string ISO-like tapi dalam waktu lokal (bukan UTC). + * Berguna untuk timestamp log/backup. + */ +function getNowLocalISO() { + const info = getCurrentTimeInfo(); + const pad = (n) => String(n).padStart(2, '0'); + return `${info.year}-${pad(info.month)}-${pad(info.day)}T${pad(info.hour)}:${pad(info.minute)}:${pad(info.second)}`; +} + +/** + * Memparse string tanggal (YYYY-MM-DD HH:mm:ss) menjadi objek Date + * dengan asumsi string tersebut adalah waktu lokal sesuai setting timezone. + */ +function parseDateInTimezone(dateStr) { + if (!dateStr) return null; + const tz = getSetting('timezone', 'Asia/Jakarta'); + + const date = new Date(dateStr.replace(' ', 'T')); + if (isNaN(date.getTime())) return null; + + const localDateStr = date.toLocaleString('en-US', { timeZone: tz, hourCycle: 'h23' }); + const localDate = new Date(localDateStr); + const diff = localDate.getTime() - date.getTime(); + + return new Date(date.getTime() - diff); +} + +function formatDateLocal(date) { + if (!date) return '-'; + const tz = getSetting('timezone', 'Asia/Jakarta'); + let d; + if (typeof date === 'string') { + d = parseDateInTimezone(date); + } else { + d = typeof date === 'number' ? new Date(date) : date; + } + if (!d || isNaN(d.getTime())) return '-'; + return d.toLocaleString('id-ID', { timeZone: tz }); +} + +/** + * Helper untuk memformat objek Date menjadi string waktu lokal (hanya Jam:Menit) + */ +function formatTimeLocal(date) { + if (!date) return '-'; + const tz = getSetting('timezone', 'Asia/Jakarta'); + let d; + if (typeof date === 'string') { + d = parseDateInTimezone(date); + } else { + d = typeof date === 'number' ? new Date(date) : date; + } + if (!d || isNaN(d.getTime())) return '-'; + return d.toLocaleTimeString('id-ID', { timeZone: tz, hour: '2-digit', minute: '2-digit' }); +} + +/** + * Memastikan semua setting wajib ada di settings.json dengan default value. + * Dijalankan saat startup aplikasi untuk migrasi/inisialisasi setting baru. + */ +function ensureDefaultSettings() { + try { + const currentSettings = getSettings(); + let needsSave = false; + + // Default settings yang wajib ada + const defaultSettings = { + // RADIUS Server settings (ditambahkan untuk update dari GitHub) + radius_enabled: '0', + radius_secret: 'secret123', + radius_auth_port: '1812', + radius_acct_port: '1813', + radius_isolir_action: 'pool', + radius_isolir_pool: 'isolir', + radius_limit_simultaneous: '1', + radius_default_rate_limit: '5M/10M', + radius_isolir_rate_limit: '512k/512k', + radius_isolir_ip_pool_enabled: '1', + radius_isolir_ip_pool_start: '10.10.99.2', + radius_isolir_ip_pool_end: '10.10.99.254', + radius_ip_pool_enabled: '1', + radius_ip_pool_start: '10.10.10.2', + radius_ip_pool_end: '10.10.10.254', + radius_framed_pool: 'pool-pppoe', + radius_send_group: '0', + webhook_notif_retention_enabled: '1', + webhook_notif_retention_days: '30', + finance_ppn_pct: '11', + finance_pph_pct: '22', + finance_uso_pct: '1.75', + finance_bhp_pct: '0.5', + finance_revenue_includes_ppn: '0' + }; + + // Cek dan tambahkan setting yang belum ada + for (const [key, defaultValue] of Object.entries(defaultSettings)) { + if (currentSettings[key] === undefined) { + currentSettings[key] = defaultValue; + needsSave = true; + logger.info(`[settings] Added missing setting: ${key} = ${defaultValue}`); + } + } + + // Simpan jika ada perubahan + if (needsSave) { + fs.writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), 'utf-8'); + settingsCache = currentSettings; + settingsCacheTime = Date.now(); + logger.info('[settings] settings.json updated with default RADIUS settings'); + } + + return needsSave; + } catch (error) { + logger.error(`[settings] Error ensuring default settings: ${error.message}`); + return false; + } +} + +module.exports = { + getSettings, + getSettingsWithCache, + getSetting, + getSettingsByKeys, + saveSettings, + getNowLocal, + formatDateLocal, + formatTimeLocal, + getCurrentDateInTimezone, + getCurrentTimeInfo, + getNowLocalISO, + parseDateInTimezone, + startSettingsWatcher, + ensureDefaultSettings +}; diff --git a/config/settingsValidator.js b/config/settingsValidator.js new file mode 100644 index 0000000..d12f6f5 --- /dev/null +++ b/config/settingsValidator.js @@ -0,0 +1,278 @@ +/** + * Settings Validation Schema + * Validate setiap field sebelum di-save ke settings.json + */ +const { logger } = require('./logger'); + +// Validation rules untuk setiap field +const VALIDATION_RULES = { + // Server Configuration + server_port: { + type: 'number', + min: 1024, + max: 65535, + required: true, + description: 'Port server (1024-65535)' + }, + server_host: { + type: 'string', + required: true, + description: 'Host server' + }, + session_secret: { + type: 'string', + minLength: 32, + required: true, + description: 'Session secret (min 32 karakter)' + }, + + // Company Information + company_header: { + type: 'string', + maxLength: 100, + required: true, + description: 'Nama perusahaan' + }, + public_base_url: { + type: 'string', + pattern: /^https?:\/\/.+/, + description: 'URL publik aplikasi (http://... atau https://...)' + }, + company_manager: { + type: 'string', + maxLength: 100, + description: 'Nama manager' + }, + company_phone: { + type: 'string', + pattern: /^[0-9\-\+\s]+$/, + description: 'Nomor telepon perusahaan' + }, + company_email: { + type: 'string', + pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + description: 'Email perusahaan' + }, + company_address: { + type: 'string', + maxLength: 500, + description: 'Alamat perusahaan' + }, + footer_info: { + type: 'string', + maxLength: 200, + description: 'Footer info' + }, + operational_hours: { + type: 'string', + maxLength: 200, + description: 'Jam operasional' + }, + + // Admin Credentials + admin_username: { + type: 'string', + minLength: 3, + maxLength: 50, + required: true, + description: 'Username admin' + }, + admin_password: { + type: 'string', + minLength: 6, + required: true, + description: 'Password admin (min 6 karakter)' + }, + admin_api_key: { + type: 'string', + minLength: 10, + description: 'API key admin' + }, + + // GenieACS Configuration + genieacs_url: { + type: 'string', + pattern: /^https?:\/\/.+/, + description: 'URL GenieACS (http://...)' + }, + genieacs_username: { + type: 'string', + description: 'Username GenieACS' + }, + genieacs_password: { + type: 'string', + description: 'Password GenieACS' + }, + + // MikroTik Configuration + mikrotik_host: { + type: 'string', + pattern: /^(\d{1,3}\.){3}\d{1,3}$|^[a-zA-Z0-9\-\.]+$/, + description: 'Host MikroTik (IP atau hostname)' + }, + mikrotik_user: { + type: 'string', + minLength: 1, + description: 'Username MikroTik' + }, + mikrotik_password: { + type: 'string', + minLength: 1, + description: 'Password MikroTik' + }, + mikrotik_port: { + type: 'number', + min: 1024, + max: 65535, + description: 'Port MikroTik' + }, + isolir_day: { + type: 'number', + min: 1, + max: 365, + description: 'Hari isolir (1-365)' + }, + + // WhatsApp Configuration + whatsapp_enabled: { + type: 'boolean', + description: 'Enable WhatsApp' + }, + whatsapp_auth_folder: { + type: 'string', + description: 'Folder auth WhatsApp' + }, + whatsapp_broadcast_delay: { + type: 'number', + min: 10, + max: 5000, + description: 'Delay broadcast WhatsApp (ms)' + }, + + // Telegram Configuration + telegram_enabled: { + type: 'boolean', + description: 'Enable Telegram' + }, + telegram_bot_token: { + type: 'string', + description: 'Telegram bot token' + }, + telegram_admin_id: { + type: 'string', + pattern: /^\d+$/, + description: 'Telegram admin ID (numeric)' + }, + + // Tripay Configuration + office_lat: { + type: 'string', + pattern: /^-?\d+(\.\d+)?$|^$/, + description: 'Latitude kantor' + }, + office_lng: { + type: 'string', + pattern: /^-?\d+(\.\d+)?$|^$/, + description: 'Longitude kantor' + }, + + // Other + + auto_backup_enabled: { + type: 'boolean', + description: 'Enable auto backup' + }, + login_otp_enabled: { + type: 'boolean', + description: 'Enable OTP login' + } +}; + +/** + * Validate single value + */ +function validateValue(field, value, rule) { + // Trim string values + let trimmedValue = value; + if (typeof value === 'string') { + trimmedValue = value.trim(); + } + + // Check type + if (rule.type === 'number') { + if (typeof trimmedValue !== 'number') return `${field} harus berupa angka`; + if (rule.min !== undefined && trimmedValue < rule.min) return `${field} minimal ${rule.min}`; + if (rule.max !== undefined && trimmedValue > rule.max) return `${field} maksimal ${rule.max}`; + } + + if (rule.type === 'string') { + if (typeof trimmedValue !== 'string') return `${field} harus berupa string`; + if (rule.minLength && trimmedValue.length < rule.minLength) return `${field} minimal ${rule.minLength} karakter`; + if (rule.maxLength && trimmedValue.length > rule.maxLength) return `${field} maksimal ${rule.maxLength} karakter`; + if (rule.pattern && !rule.pattern.test(trimmedValue)) return `${field} format tidak valid`; + if (rule.enum && !rule.enum.includes(trimmedValue)) return `${field} harus salah satu dari: ${rule.enum.join(', ')}`; + } + + if (rule.type === 'boolean') { + if (typeof trimmedValue !== 'boolean') return `${field} harus berupa boolean`; + } + + return null; // Valid +} + +/** + * Validate settings object + */ +function validateSettings(settings) { + const errors = []; + + Object.keys(settings).forEach(field => { + const value = settings[field]; + const rule = VALIDATION_RULES[field]; + + // Skip jika tidak ada rule (field baru atau optional) + if (!rule) return; + + // Check required + if (rule.required && (value === undefined || value === null || value === '')) { + errors.push(`${field} wajib diisi`); + return; + } + + // Skip validation jika value kosong dan tidak required + if (!rule.required && (value === undefined || value === null || value === '')) { + return; + } + + // Validate value + const error = validateValue(field, value, rule); + if (error) errors.push(error); + }); + + return { + valid: errors.length === 0, + errors + }; +} + +/** + * Get validation rule untuk field + */ +function getFieldRule(field) { + return VALIDATION_RULES[field] || null; +} + +/** + * Get all validation rules + */ +function getAllRules() { + return VALIDATION_RULES; +} + +module.exports = { + validateValue, + validateSettings, + getFieldRule, + getAllRules, + VALIDATION_RULES +}; diff --git a/config/sqliteSessionStore.js b/config/sqliteSessionStore.js new file mode 100644 index 0000000..7059857 --- /dev/null +++ b/config/sqliteSessionStore.js @@ -0,0 +1,48 @@ +/* config/sqliteSessionStore.js + Session store berbasis SQLite (better-sqlite3) agar sesi PERSISTEN + lintas restart proses — penting untuk alur OTP (pending_login di session) + dan agar pelanggan tidak terlempar ke halaman login saat server restart. */ +const session = require('express-session'); + +module.exports = function createSqliteSessionStore(db) { + const Store = session.Store; + + class SqliteSessionStore extends Store { + constructor() { + super(); + db.exec(`CREATE TABLE IF NOT EXISTS sessions ( + sid TEXT PRIMARY KEY, + data TEXT NOT NULL, + expires_at INTEGER + )`); + this._get = db.prepare('SELECT data, expires_at FROM sessions WHERE sid = ?'); + this._set = db.prepare('INSERT OR REPLACE INTO sessions (sid, data, expires_at) VALUES (?, ?, ?)'); + this._del = db.prepare('DELETE FROM sessions WHERE sid = ?'); + this._clean = db.prepare('DELETE FROM sessions WHERE expires_at IS NOT NULL AND expires_at < ?'); + const timer = setInterval(() => { try { this._clean.run(Date.now()); } catch (e) {} }, 15 * 60 * 1000); + if (timer.unref) timer.unref(); + } + get(sid, cb) { + try { + const row = this._get.get(sid); + if (!row) return cb(null, null); + if (row.expires_at && row.expires_at < Date.now()) { this._del.run(sid); return cb(null, null); } + return cb(null, JSON.parse(row.data)); + } catch (e) { return cb(e); } + } + set(sid, sess, cb) { + try { + let exp = null; + const c = sess && sess.cookie; + if (c && c.expires) exp = new Date(c.expires).getTime(); + else if (c && c.maxAge) exp = Date.now() + c.maxAge; + this._set.run(sid, JSON.stringify(sess), exp); + if (cb) cb(null); + } catch (e) { if (cb) cb(e); } + } + destroy(sid, cb) { try { this._del.run(sid); if (cb) cb(null); } catch (e) { if (cb) cb(e); } } + touch(sid, sess, cb) { return this.set(sid, sess, cb); } + } + + return new SqliteSessionStore(); +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2879500 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# ───────────────────────────────────────────────────────────────────────────── +# OPEN-ISP — docker compose (produksi / test) +# +# Persiapan pertama (sekali): +# mkdir -p docker-data/database docker-data/logs docker-data/uploads docker-data/baileys +# echo '{"server_port":3001,"cookie_secure":false}' > docker-data/settings.json +# +# Jalankan: +# docker compose up -d --build +# docker compose logs -f +# +# Catatan: +# - Semua DATA (DB, settings, upload, sesi WhatsApp) tersimpan di ./docker-data +# → aman saat container di-update. Backup cukup folder docker-data. +# - Ganti cookie_secure ke true bila diakses via HTTPS/reverse-proxy TLS. +# ───────────────────────────────────────────────────────────────────────────── +services: + open-isp: + build: . + image: open-isp:0.5 + container_name: open-isp + restart: unless-stopped + ports: + - "3001:3001" + environment: + - NODE_ENV=production + - TZ=Asia/Jakarta + volumes: + - ./docker-data/settings.json:/app/settings.json + - ./docker-data/database:/app/database + - ./docker-data/logs:/app/logs + - ./docker-data/uploads:/app/public/uploads + - ./docker-data/baileys:/app/auth_info_baileys + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3001/version.txt"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 45s diff --git a/docs/ADMIN_SETTINGS_USAGE.md b/docs/ADMIN_SETTINGS_USAGE.md new file mode 100644 index 0000000..8befd36 --- /dev/null +++ b/docs/ADMIN_SETTINGS_USAGE.md @@ -0,0 +1,290 @@ +# Admin Settings - Multi-Router Configuration + +**Status**: ✅ SELESAI - Siap Digunakan + +Saat edit pengaturan di `/admin/settings`, Anda **BISA langsung menyimpan tanpa NULL** lagi. Sistem sudah handle semuanya. + +--- + +## Akses Settings + +1. Login ke admin portal → menu **⚙️ Pengaturan** +2. Atau langsung akses: `http://localhost:3001/admin/settings` + +--- + +## Section: Pengaturan MikroTik + +Scroll ke bawah, cari bagian **"MULTI-ROUTER & MIKROTIK"** + +### Field 1: Mode Multi-Router + +``` +☑ Mode Multi-Router: [Dropdown] + - Aktif (Multiple Router) + - Nonaktif (Single Router Mode) +``` + +**Apa artinya?** + +| Mode | Artinya | Untuk Apa | +|------|---------|----------| +| **Aktif** | Pelanggan HARUS memilih router saat dibuat | Multi-ISP / Multi-lokasi dengan routers berbeda | +| **Nonaktif** | Pelanggan tanpa router_id otomatis pakai default | Setup sederhana, satu router utama | + +**Default**: Aktif + +### Field 2: Default Router ID (Opsional) + +``` +□ Default Router ID: [Input Number] + Placeholder: "Kosongkan untuk auto-detect" + Hint: "Opsional - jika kosong, sistem auto-detect router pertama yang aktif" +``` + +**Kapan perlu diisi?** +- **Jika mode = Nonaktif** dan punya multiple routers di database + - Isi dengan ID router utama (misal: 1) + - Pelanggan lama (router_id = NULL) akan pakai router ini + +- **Jika mode = Aktif** + - TIDAK perlu diisi (tidak akan dipakai) + +- **Jika hanya punya satu router** + - Kosongkan saja, sistem auto-detect + +--- + +## Contoh Konfigurasi + +### Setup 1: Single Router (Simple) + +``` +Mode Multi-Router: Nonaktif +Default Router ID: [kosongkan] +``` + +**Apa yang terjadi:** +- Aplikasi pakai router pertama dari database / settings.json +- Pelanggan lama (NULL router) otomatis menggunakan router itu +- Semua isolir/activate berjalan normal ✅ + +### Setup 2: Multi Router (Main + Backup) + +``` +Mode Multi-Router: Nonaktif +Default Router ID: 1 +``` + +**Database routers:** +``` +ID | name | is_active +---|--------------|---------- +1 | Main Router | 1 +2 | Backup | 0 (disabled) +``` + +**Apa yang terjadi:** +- Pelanggan baru dibuat: harus pilih router (1 atau 2) +- Pelanggan lama (NULL router): auto-assign ke router 1 +- Isolir/activate pakai router masing-masing ✅ + +### Setup 3: Full Multi-Router Mode + +``` +Mode Multi-Router: Aktif +Default Router ID: [kosongkan] +``` + +**Apa yang terjadi:** +- Pelanggan WAJIB pilih router saat dibuat +- Tidak ada auto-assign (semua harus eksplisit) +- Lebih aman untuk lingkungan kompleks + +--- + +## Alur Kerja: Saat Anda Simpan Setting + +### Langkah 1: Form Submission +Anda klik **"Simpan Pengaturan"** di `/admin/settings` + +### Langkah 2: Backend Processing +```javascript +POST /admin/settings + ├─ Parse form data (multi_router_mode, default_router_id) + ├─ Validasi format (mode = string, router_id = number) + └─ Cek apakah ada mode switch (disabled → active) +``` + +### Langkah 3: Auto-Assignment (Jika Mode Berubah) +``` +Jika dulu: "Aktif" → Sekarang: "Nonaktif" + ├─ Cari pelanggan dengan router_id = NULL + │ AND (pppoe_username != '' OR hotspot_username != '' OR static_ip != '') + ├─ Assign router_id = default_router_id (atau auto-detect first router) + └─ Log: "[Settings] Auto-assigned router X to Y customers" +``` + +### Langkah 4: Save to settings.json +```json +{ + "multi_router_mode": "nonaktif", + "default_router_id": "1", + ... +} +``` + +### Langkah 5: Update In-Memory Cache +Aplikasi langsung reload setting tanpa restart + +### Langkah 6: Redirect +Browser redirect ke `/admin/settings` → tampil pesan **"✅ Pengaturan berhasil disimpan"** + +--- + +## Testing: Verifikasi Konfigurasi Bekerja + +### Test 1: Mode Switch Active → Disabled + +**Persiapan:** +1. Buat pelanggan dengan router_id = NULL (manual edit database atau via old export) +2. Set mode = "Aktif", default_router_id kosong + +**Eksekusi:** +1. Ke `/admin/settings` +2. Set mode = "Nonaktif", default_router_id = 1 +3. Klik "Simpan Pengaturan" + +**Verifikasi:** +```bash +# Cek logs +tail -f logs/combined.log | grep "Auto-assigned" + +# Cek database +sqlite3 database/billing.db "SELECT id, name, router_id FROM customers WHERE router_id = 1 LIMIT 5;" +``` + +✅ Harus ada: `"Auto-assigned router 1 to X customers"` + +### Test 2: Isolir dengan Custom Default Router + +**Persiapan:** +1. Set mode = "Nonaktif", default_router_id = 2 +2. Buat pelanggan baru dengan router_id = NULL (atau pakai pelanggan lama) + +**Eksekusi:** +1. Admin isolir pelanggan itu +2. Cek MikroTik router 2 + +**Verifikasi:** +```bash +# Cek logs +tail -f logs/combined.log | grep -i "isolir\|suspend" + +# Harus ada: "Using default router from database: Router2" +``` + +✅ Profile di router 2 berubah ke "isolir" + +### Test 3: Settings Reload Tanpa Restart + +**Persiapan:** +1. App sedang berjalan (pm2) +2. Edit `/admin/settings` dan ubah mode + +**Verifikasi:** +```bash +# Check if app still running +pm2 status + +# NEW: Tidak perlu restart! Setting langsung aktif di memory +# (Hanya perlu restart jika ada bug atau edit langsung settings.json) +``` + +✅ Aplikasi tetap berjalan, tidak crash + +--- + +## Troubleshooting + +### Problem: Ubah setting tapi isolir masih error + +**Solusi:** +1. Cek logs: `tail logs/error.log` +2. Pastikan router_id di database benar-benar ada +3. Test koneksi ke MikroTik: `node -e "const m = require('./services/mikrotikService'); m.checkConnection(1).then(ok => console.log('OK:', ok));"` + +### Problem: Mode Aktif → Nonaktif tapi NULL router tidak ter-assign + +**Solusi:** +1. Cek database, apakah ada routers yang is_active = 1 + ```bash + SELECT * FROM routers WHERE is_active = 1; + ``` +2. Cek logs saat simpan: `grep -i "Auto-assigned" logs/combined.log` +3. Verifikasi default_router_id benar-benar tersimpan: + ```bash + node -e "const {getSetting} = require('./config/settingsManager'); console.log(getSetting('default_router_id'));" + ``` + +### Problem: Setting berubah tapi pelanggan isolir di router lama + +**Solusi:** +1. Ini normal! Setting hanya berlaku untuk operasi **baru** setelah perubahan +2. Pelanggan yang sudah isolir di router lama tetap pakai router lama (itu yang disimpan di database) +3. Untuk reset, dapat pakai "Aktivasi" dulu, lalu isolir lagi + +--- + +## Fitur Bonus: Real-Time Settings Update + +Anda **TIDAK perlu restart app** setelah ubah settings. + +**Mengapa?** +- `settingsManager.js` cache setting setiap kali dibaca +- Saat form disubmit, `saveSettings()` langsung update cache +- Operasi berikutnya akan pakai setting baru + +**Exception:** Jika langsung edit `settings.json` di file (tidak via UI), perlu restart app. + +--- + +## Quick Reference: Setting Values + +| Setting Key | Type | Default | Valid Values | +|---|---|---|---| +| `multi_router_mode` | String | "active" | "active", "disabled" | +| `default_router_id` | Number | null | > 0 atau null (untuk auto-detect) | + +--- + +## Workflow Rekomendasi + +### Fase 1: Setup Single Router +``` +1. settings.json: sudah ada MikroTik credentials +2. /admin/settings: mode = "Nonaktif", default_router_id kosong +3. Test isolir via admin +4. Test isolir via WhatsApp bot +``` + +### Fase 2: Tambah Multi-Router (Nanti) +``` +1. /admin/routers: tambah router 2, 3, dst +2. /admin/settings: mode = "Aktif" atau "Nonaktif" + default_router_id = 1 +3. Pelanggan lama auto-assign (jika switch ke Nonaktif) +4. Pelanggan baru HARUS pilih router saat dibuat +``` + +--- + +## Kesimpulan + +✅ **Saat edit pengaturan di web admin (`/admin/settings`), sekarang sudah TIDAK NULL lagi:** + +- **Mode Multi-Router**: Pilih "Aktif" atau "Nonaktif" +- **Default Router ID**: Opsional, biarkan kosong untuk auto-detect +- **Simpan**: Langsung tersimpan, tidak perlu restart +- **Auto-Assignment**: Jika mode berubah, pelanggan NULL auto-assign ke router yang sesuai + +**Mulai sekarang, setup semua lewat UI web admin - sudah production-ready!** 🎉 diff --git a/docs/ANALISIS_SISTEM_DAN_UIUX.md b/docs/ANALISIS_SISTEM_DAN_UIUX.md new file mode 100644 index 0000000..bfa700d --- /dev/null +++ b/docs/ANALISIS_SISTEM_DAN_UIUX.md @@ -0,0 +1,274 @@ +# Analisis Komprehensif Sistem, Logika Backend, dan UI/UX — Billing RT/RW Net + +> **Target Project**: `/opt/billing-rtrw` (VPS `deneva` — `116.212.74.115`) +> **Layanan**: `billing-rtrw.service` (Node.js v20+, port `3001`) +> **Repository Asal**: `https://github.com/alijayanet/billing-rtrw` +> **Repository Fork/Deploy**: `https://github.com/alijayanet/billing-rtrw` + +--- + +## 1. Ringkasan Eksekutif & Gambaran Sistem + +Billing RT/RW Net ini adalah sistem manajemen operasional ISP (Internet Service Provider) berbasis komunitas yang memadukan: +1. **Billing & Invoicing**: Siklus tagihan bulanan, isolir otomatis, dan pembayaran QRIS dinamis/manual. +2. **Network Automation**: Integrasi RouterOS MikroTik (PPPoE, Hotspot Voucher, Simple Queue, Address List). +3. **Hardware Management (FTTH)**: Embedded ACS TR-069 Server (CWMP) untuk manajemen ONT klien dan SNMP OLT (EPON/GPON). +4. **Customer Self-Service (PWA)**: Portal pelanggan mobile-first dengan auto-login 90 hari, ganti WiFi SSID/password mandiri, grafik pemakaian kuota/bandwidth, dan reboot modem. +5. **Multi-Channel Bot**: Bot WhatsApp (Baileys socket) dan Bot Telegram untuk notifikasi, penagihan, dan tiket operasional. + +```mermaid +flowchart TD + subgraph Klien_Pelanggan["Portal Klien & Interaksi"] + CustPWA["Customer PWA (Tailwind + Lucide)"] + PublicVoucher["Portal Voucher & QRIS"] + WABot["WhatsApp Bot (Baileys)"] + TeleBot["Telegram Bot Notifikasi"] + end + + subgraph Core_Backend["Express 4 Engine (Port 3001)"] + App["app-customer.js (Main Entry)"] + ACSServer["Embedded TR-069 Server (/acs)"] + AuthSvc["Auth & Token Service (90 Hari)"] + BillSvc["Billing & Invoice Engine"] + MikroSvc["MikroTik RouterOS API"] + OLTSvc["OLT Service (SNMP / Web)"] + UsageSvc["Bandwidth Usage Tracker"] + end + + subgraph Data_Network["Infrastruktur Jaringan & Database"] + DB[("SQLite WAL (better-sqlite3)")] + MikroTik[("Router MikroTik (PPPoE / Hotspot)")] + OLT[("OLT Fiber (Hioso/HSGQ/VSOL)")] + CPE[("ONT / Modem Pelanggan (TR-069)")] + end + + CustPWA -->|HTTP/REST| App + PublicVoucher -->|HTTP/REST| App + WABot <--> App + TeleBot <--> App + App --> AuthSvc + App --> BillSvc + App --> MikroSvc + App --> OLTSvc + App --> UsageSvc + CPE <-->|CWMP SOAP| ACSServer + ACSServer <--> DB + BillSvc <--> DB + MikroSvc <--> MikroTik + OLTSvc <--> OLT + UsageSvc <--> MikroTik +``` + +--- + +## 2. Analisis Backend & Logika Alur Fungsi + +### 2.1 Arsitektur Server & Runtime +* **Runtime**: Node.js `>=20.0.0` (Native ES Module + CommonJS hybrid). +* **Database Engine**: SQLite 3 melalui `better-sqlite3` dengan mode **WAL (Write-Ahead Logging)**, foreign keys aktif, sinkronisasi file cepat dan hemat resource. +* **Proses Lifecycle**: Dikelola oleh Systemd (`billing-rtrw.service`) dengan auto-restart `on-failure` (delay 5 detik), batas memori RAM `--max-old-space-size=512`. +* **Proteksi Server**: + * Penanganan `unhandledRejection` dan `uncaughtException` terpusat agar error koneksi (misal timeout MikroTik) tidak mematikan server Express. + * Fallback port otomatis (`startServer` mencoba port +1000 jika port 3001 bentrok). + * Proteksi CSRF berbasis validasi header `Origin` dan `Referer` (mengecualikan webhook dan endpoint SOAP ACS). + +--- + +### 2.2 Modul-Modul Inti & Alur Logika + +#### A. Layanan Billing, Invoice & Isolir (`services/billingService.js` & `services/cronService.js`) +* **Logika Pembuatan Tagihan**: + 1. Cron berjalan setiap hari (`cronService.js`) memeriksa seluruh pelanggan aktif. + 2. Tagihan dibuat otomatis setiap siklus tanggal tagihan (`billing_date`) untuk periode bulan berjalan. + 3. Kode unik nominal QRIS (`qris_amount_unique`) diinjeksi 3 digit acak (1–999) agar mutasi bank/QRIS dapat dicocokkan otomatis tanpa payment gateway berbayar. +* **Logika Isolir Otomatis**: + 1. Bila tanggal saat ini mencapai atau melewati `isolir_day` (default tanggal 20 tiap bulan) dan tagihan pelanggan masih `unpaid`: + 2. Status pelanggan di database diubah menjadi `isolated`. + 3. `mikrotikService` dipanggil untuk memindahkan secret PPPoE ke profile isolir atau memasukkan IP klien ke Address List `ISOLIR` di MikroTik Firewall. + 4. Sesi aktif diputus (`disconnectPPPoE`) sehingga pelanggan ter-redirect ke halaman Walled Garden (`/isolated`). + +#### B. Layanan TR-069 ACS Server (`services/acsServerService.js` & `/acs`) +* **Protokol**: CWMP (CPE WAN Management Protocol) via SOAP XML over HTTP POST. +* **Alur Inform & Handshake**: + 1. Modem (ONT) mengirim HTTP POST `Inform` ke endpoint `/acs`. + 2. Server memeriksa header `Authorization`. Jika belum diautentikasi, server membalas dengan **HTTP 401 Unauthorized** (Digest Challenge). + 3. Modem mengirim kembali request dengan Digest Hash yang dihitung berdasarkan username (`skyfiber`) dan password acak perangkat. + 4. Server membaca parameter XML (`Device.DeviceInfo.*`, `Device.WANDevice.*`, `Device.Ethernet.*`, `Device.LANDevice.*`). + 5. Data kekuatan redaman optik (`RXPower`, `TXPower`), serial number, model, dan versi software disimpan ke tabel `acsdevices`. + 6. Server memeriksa antrean tugas (task queue) per perangkat: jika ada perintah ganti password WiFi atau reboot, server merespons dengan `SetParameterValues` atau `Reboot`. + +#### C. Pengelolaan WiFi Multi-Vendor (`services/customerDeviceService.js`) +* **Masalah Klasik**: Tiap vendor modem (Huawei, ZTE, FiberHome, Nokia) memiliki skema OID/TR-098 yang berbeda-beda. Mengirim parameter yang salah menyebabkan SOAP Fault 9007. +* **Logika Adaptif Per-Vendor**: + * **Huawei / FiberHome / Nokia**: Memprioritaskan `PreSharedKey.1.KeyPassphrase` dan menghindari pengiriman hex 64-digit bersamaan dengan plain string. + * **ZTE / ZICG / CIOT**: Memprioritaskan `KeyPassphrase` top-level. + * **Dual-Band SSID**: Mengatur `WLANConfiguration.1.SSID` (2.4 GHz) dan `WLANConfiguration.5.SSID` (5 GHz) secara independen tanpa merusak konfigurasi guest SSID. + +#### D. Integrasi Jaringan MikroTik (`services/mikrotikService.js`) +* **Koneksi**: RouterOS API (port default `25722`/`8728`) menggunakan library `routeros-client`. +* **Fitur Utama**: + * Sinkronisasi PPPoE: Read active connections, update secret passwords, kick active sessions. + * Monitoring Traffic: Mengambil data byte RX/TX interface pelanggan untuk kalkulasi kuota. + * Voucher Hotspot: Direct injection user hotspot, profile rate-limit, dan validitas voucher. + +#### E. Autentikasi Pelanggan & Persistent Token (`services/customerAuthTokenService.js`) +* **Alur Sesi PWA**: + 1. Pelanggan memasukkan nomor HP / ID Pelanggan. + 2. Saat login berhasil, backend men-generate token kriptografi acak `crypto.randomBytes(32).toString('hex')`. + 3. Token di-hash dengan SHA-256 dan disimpan di tabel SQLite `customer_auth_tokens` dengan masa berlaku 90 hari. + 4. Plain token dikirimkan ke browser sebagai cookie `customer_remember` (`httpOnly`, `secure`, `sameSite: lax`). + 5. Ketika pengguna membuka PWA (bahkan setelah browser/HP di-restart), middleware auto-login memverifikasi cookie ini dan merekonstruksi session secara transparan. + +#### F. Pelacakan Kuota & Pemakaian Bandwidth (`services/usageService.js`) +* **Logika Agregasi**: + 1. Mengambil byte delta dari MikroTik queue / interface. + 2. Menyimpan sampel agregat harian dan bulanan ke database. + 3. Menyediakan API untuk konsumsi frontend (Chart.js di dashboard pelanggan). + +--- + +## 3. Analisis UI / UX, Styling, Typografi & Layout + +Pemeriksaan mendalam terhadap template EJS dan file CSS/JS di `/opt/billing-rtrw/views/` dan `/opt/billing-rtrw/public/` mengungkap sejumlah temuan penting mengenai styling, engine, dan konsistensi desain: + +### 3.1 Peta Engine & Library UI per Portal + +| Portal / Halaman | Engine CSS Utama | Library Icon | Font Family Utama | Style Pendekatan | +|---|---|---|---|---| +| **Customer Dashboard** (`views/dashboard.ejs`) | **Tailwind CSS 3.4.17** (via CDN) | **Lucide Icons** (v1.48.0) + FontAwesome 6.4.0 | **Plus Jakarta Sans** (Google Fonts) | Modern App Shell / Glassmorphism Mobile-first | +| **Customer Login** (`views/login.ejs`) | **Bootstrap 5.3.2** (CDN) | **Bootstrap Icons 1.11.3** | **System Font Stack** (`-apple-system`, Segoe UI, Roboto) | Centered Glass Card / Radial Background | +| **Admin Portal** (`views/admin/*`) | **Custom CSS** (`/css/admin.css`) | **Bootstrap Icons 1.11.3** + FontAwesome | **Inter** (Google Fonts) | Desktop Enterprise Sidebar & Table Layout | +| **Teknisi Portal** (`views/tech/*`) | **Bootstrap 5** + `/css/theme.css` | **Bootstrap Icons** | System Font / Inter | Mobile Bottom Navigation | +| **Halaman Peta Jaringan** (`views/admin/map.ejs`) | **Leaflet 1.9.4** + `/css/admin.css` | FontAwesome + SVG Animasi | Inter | Fullscreen Interactive Map & Vector Layer | + +--- + +### 3.2 Analisis Typografi & Hierarki Font + +``` +[Customer Dashboard] Plus Jakarta Sans (300, 400, 500, 600, 700, 800) + ├── Title / Header : text-base sm:text-lg font-bold (Tracking tight) + ├── Section Labels : text-xs font-semibold text-slate-400 uppercase (Tracking wider) + ├── Body / Konten : text-sm font-normal text-slate-200 (Leading relaxed) + └── Badges & Metadata : text-[10px] sm:text-xs font-medium + +[Customer Login] System Font Stack (BlinkMacSystemFont, Segoe UI, Roboto) + └── Ketidaksesuaian : Font berbeda dengan Dashboard; saat user login terjadi visual jump dari font sistem ke Plus Jakarta Sans. + +[Admin Portal] Inter (300, 400, 500, 600, 700, 800) + ├── Sidebar Items : font-size 13px, font-weight 500 + ├── Data Table Header : font-size 11px, text-transform uppercase, letter-spacing 0.5px + └── Table Data Cells : font-size 13px, color var(--text: #e6edf3) +``` + +--- + +### 3.3 Analisis Styling, Palette Warna & Tema Gelap/Terang + +#### Palette Warna Dominan +1. **Mode Gelap (Customer Dashboard)**: + * Background Utama: `#0b0f19` (Deep Blue-Black) + * Card & Panel: `#1e293b` (Slate 800) dengan border `#334155` (Slate 700) + * Aksen Primer (Brand): Gradasi Teal `#14b8a6` ke Cyan `#06b6d4` + * Aksen Sekunder: Indigo `#6366f1` + * Status Indikator: Hijau Sukses `#10b981`, Merah Warning/Error `#f43f5e`, Amber `#f59e0b` +2. **Mode Terang (Customer Dashboard & Global Theme)**: + * Background Utama: `#f1f5f9` (Slate 100) + * Card & Panel: `#ffffff` murni dengan border `rgba(15, 23, 42, 0.08)` + * Teks Utama: `#0f172a` (Slate 900), Teks Muted: `#64748b` (Slate 500) +3. **Mekanisme Tema (Theming Engine)**: + * Terdapat dua sistem tema yang berjalan berdampingan: + * `views/dashboard.ejs` menggunakan script lokal inline (`localStorage: skyfiber-customer-theme`) mengendalikan class `dark` pada `` untuk Tailwind. + * Portal lainnya menggunakan `/js/theme.js` dan `/css/theme.css` (`localStorage: app-theme`) mengendalikan atribut `data-theme="light|dark"`. + * Pada rilis `v1.9.4`, kedua key ini sudah disinkronkan, namun styling mode terang di portal admin masih membutuhkan penyesuaian kontras pada beberapa tabel dan modal. + +--- + +### 3.4 Analisis Layout, Spacing, Alignment & UX + +#### 1. Customer Dashboard (Mobile App Shell) +* **Alignment & Container**: + * Lebar maksimum dibatasi ke `max-w-md` atau `max-w-lg` di tengah layar (`mx-auto`), memberikan rasa native mobile application saat dibuka di smartphone maupun desktop browser. + * Padding konten konsisten (`px-4 py-3`), dengan padding bawah ekstra (`pb-28`) untuk memberi ruang bagi floating navigation dock. +* **Floating Glass Dock (5 Tab Navigasi)**: + * Terpasang fixed di bagian bawah layar: `fixed bottom-4 left-1/2 -translate-x-1/2`. + * Menggunakan efek glassmorphism (`backdrop-blur-md`, background semi-transparan, border tipis halus). + * Indikator aktif berupa **sliding pill** yang bergerak halus mengikuti tab yang diklik (`updatePillPosition`). + * Tab navigasi: + 1. `tab-beranda` (Home, status paket, kuota, shortcut aksi) + 2. `tab-router` (Status ONT, redaman fiber optic, ganti WiFi, reboot) + 3. `tab-ppob` (Layanan isi pulsa & token listrik - status Coming Soon) + 4. `tab-tagihan` (Riwayat invoice, tombol bayar QRIS) + 5. `tab-profile` (Data akun, riwayat tiket kendala, tombol CS WhatsApp, logout) +* **Kerapian & Touch Target**: + * Seluruh tombol interaktif memiliki ukuran touch-target minimal `40x40px` (ramah sentuhan jari di layar HP). + * Safe-area-insets untuk iPhone (`env(safe-area-inset-bottom)`) sudah ditambahkan pada `.dock-glass`. + +#### 2. Admin & Staff Portal +* **Layout**: + * Sidebar navigasi di sisi kiri (`width: 240px`, fixed). + * Konten utama di sebelah kanan dengan header fixed dan breadcrumb. + * Responsive breakpoint: Pada layar kecil (mobile), sidebar disembunyikan dan diakses lewat toggle menu hamburger. + +--- + +### 3.5 Temuan Evaluasi & Celah UI/UX (Bottlenecks) + +1. **Fragmentasi Framework CSS**: + * Halaman login menggunakan **Bootstrap 5**, sedangkan dashboard pelanggan menggunakan **Tailwind CSS**. Ini membebani browser pengguna karena harus mengunduh dua framework CSS besar saat pertama kali membuka aplikasi. +2. **Ketergantungan CDN Runtime (Client-Side Tailwind)**: + * Dashboard pelanggan menggunakan ``. CDN ini mem-parse class Tailwind saat runtime di perangkat klien (JIT compiler di browser), yang dapat memperlambat rendering awal (First Contentful Paint) pada ponsel berspesifikasi rendah. +3. **Dual Icon Libraries**: + * Halaman dashboard memuat **Lucide Icons** dan **FontAwesome 6.4.0** secara bersamaan. FontAwesome (file CSS + font ~400KB) hanya digunakan untuk segelintir ikon kecil, sementara sebagian besar UI sudah menggunakan SVG Lucide. +4. **Log Error Berulang di Backend**: + * Terdapat log `Telegram Polling Error` berkala pada journal server deneva karena token bot belum dikonfigurasi / timeout koneksi ke server Telegram API. + +--- + +## 4. Rencana Kerja Terperinci (Execution Plan) + +Rencana perubahan ini dibagi menjadi 4 fase berurutan dengan prinsip **zero-downtime** dan keamanan data: + +```mermaid +timeline + title Timeline Rencana Peningkatan Billing RTRW + Fase 1 : Konsolidasi Backend & Error Fix : Fix Polling Telegram : Optimalisasi Error Handling + Fase 2 : Unifikasi Design System : Standarisasi Font (Plus Jakarta Sans) : Harmonisasi Halaman Login & Dashboard + Fase 3 : Optimasi Asset & Performa : Eliminasi Redundansi Icon : Cache Profiling & PWA Hardening + Fase 4 : Testing & Validasi : Uji Responsivitas Semua Layar : Uji Fungsi WiFi & Pembayaran Live +``` + +### Fase 1: Perbaikan Stabilitas & Logika Backend +1. **Fix Polling Telegram (`services/telegramBot.js`)**: + * Tambahkan pengecekan kondisi: jika token Telegram kosong, nonaktifkan `polling: true` agar tidak membanjiri log server dengan polling error setiap 5 detik. +2. **Peningkatan Ketahanan Koneksi MikroTik (`services/mikrotikService.js`)**: + * Implementasikan retry backoff otomatis saat query queue/PPPoE timeout, menghindari uncaught exception yang tidak perlu. +3. **Audit Query Database (`config/database.js`)**: + * Periksa indeks pada tabel `pppoe_traffic_samples`, `invoices`, dan `customer_auth_tokens` untuk mempercepat query bulanan. + +### Fase 2: Harmonisasi UI/UX & Standarisasi Tipografi +1. **Standarisasi Tipografi**: + * Terapkan font **Plus Jakarta Sans** ke seluruh halaman pelanggan, termasuk halaman Login (`views/login.ejs`), Register, dan Isolir (`views/isolated.ejs`), menggantikan system font default agar pengalaman visual konsisten sejak awal. +2. **Harmonisasi Desain Halaman Login (`views/login.ejs`)**: + * Rombak halaman login agar memiliki nuansa tema, sudut rounded, dan aksen warna Teal/Cyan yang identik dengan Dashboard Pelanggan. + * Ganti elemen Bootstrap pada halaman login menjadi layout yang selaras dengan tema dashboard. +3. **Penyempurnaan Form Ganti WiFi di Tab Router**: + * Perjelas instruksi frekuensi 2.4 GHz vs 5 GHz. + * Tambahkan toggle visibility (mata) untuk password WiFi dan validasi panjang password (minimal 8 karakter) di sisi klien sebelum submit. + +### Fase 3: Optimasi Frontend & Efisiensi Aset +1. **Rasionalisasi Icon**: + * Migrasikan sisa ikon FontAwesome di Dashboard pelanggan ke **Lucide Icons** bawaan. + * Hapus pemanggilan CSS FontAwesome di dashboard pelanggan untuk menghemat ukuran transfer data hingga ~400 KB. +2. **Optimasi Mode Terang (Light Mode)**: + * Periksa dan rapikan kontras teks di seluruh tab dashboard agar tidak ada teks putih yang tidak terbaca saat berganti ke background terang. + * Pastikan border card dan form input terlihat tegas pada mode terang. + +### Fase 4: Pengujian, Validasi & Deployment +1. **Uji Coba Lintas Perangkat**: + * Uji tampilan di mobile (Android Chrome, iOS Safari), tablet, dan desktop. + * Uji fungsi PWA Add to Home Screen dan verifikasi auto-login setelah kill aplikasi. +2. **Uji Fungsional End-to-End**: + * Uji ganti SSID/password pada modem online. + * Uji alur pembuatan invoice dan QRIS. + * Restart service `billing-rtrw.service` dan verifikasi log bersih tanpa error. diff --git a/docs/AUDIT_FINDINGS_SUMMARY.md b/docs/AUDIT_FINDINGS_SUMMARY.md new file mode 100644 index 0000000..570d1a9 --- /dev/null +++ b/docs/AUDIT_FINDINGS_SUMMARY.md @@ -0,0 +1,317 @@ +# AUDIT FINDINGS SUMMARY - QUICK REFERENCE +**Status:** Complete Audit +**Date:** 14 August 2026 +**Format:** Executive Summary for Management + +--- + +## 🎯 AUDIT OVERVIEW + +| Metric | Status | Details | +|--------|--------|---------| +| **Security Score** | 5.5/10 | ⚠️ Critical issues found | +| **Code Quality** | 7/10 | Good structure, needs hardening | +| **Database Integrity** | 8/10 | Well-designed schema | +| **Production Ready** | ❌ NO | Must fix critical issues first | +| **Audit Completed** | ✅ YES | Full report available | + +--- + +## 🔴 CRITICAL FINDINGS (MUST FIX IMMEDIATELY) + +### 1. PASSWORD STORAGE (CRITICAL) +- **Issue:** Passwords stored in plaintext in database +- **Risk:** Full account compromise if database breached +- **Files:** `services/techService.js`, `settings.json` +- **Fix Time:** 4-6 hours +- **Severity:** CRITICAL +- **Status:** Not Fixed ❌ + +### 2. DEFAULT CREDENTIALS (CRITICAL) +- **Issue:** Default admin password `admin123` in settings.json +- **Risk:** Anyone can login to admin panel +- **Fix Time:** 1 hour +- **Severity:** CRITICAL +- **Status:** Not Fixed ❌ + +### 3. UNENCRYPTED CREDENTIALS (CRITICAL) +- **Issue:** API keys and passwords in plaintext in settings.json +- **Risk:** Exposure in version control, server breach +- **Files:** `settings.json` +- **Fix Time:** 2-3 hours +- **Severity:** CRITICAL +- **Status:** Not Fixed ❌ + +### 4. WEAK PASSWORD HASHING (HIGH) +- **Issue:** SHA256 and MD5 used instead of bcrypt/argon2 +- **Risk:** Password cracking in hours/minutes +- **Files:** Multiple service files +- **Fix Time:** 6-8 hours +- **Severity:** HIGH +- **Status:** Not Fixed ❌ + +--- + +## 🟠 HIGH PRIORITY FINDINGS + +### 5. CSRF PROTECTION (LIMITED) +- **Issue:** Only Referer/Origin validation, no explicit CSRF tokens +- **Risk:** Cross-site request forgery attacks possible +- **Fix Time:** 4-6 hours +- **Severity:** HIGH + +### 6. SESSION MANAGEMENT +- **Issue:** In-memory session store, no regeneration on login +- **Risk:** Session hijacking possible +- **Fix Time:** 3-4 hours +- **Severity:** HIGH + +### 7. INPUT VALIDATION +- **Issue:** Incomplete input validation and sanitization +- **Risk:** XSS and injection attacks +- **Fix Time:** 8-10 hours +- **Severity:** HIGH + +--- + +## 🟡 MEDIUM PRIORITY FINDINGS + +### 8. FILE UPLOAD SECURITY +- **Issue:** Only MIME type validation, no deep file scanning +- **Risk:** Malicious file upload +- **Fix Time:** 2-4 hours +- **Severity:** MEDIUM + +### 9. BACKUP & DISASTER RECOVERY +- **Issue:** Auto-backup disabled in settings +- **Risk:** Data loss in disaster scenario +- **Fix Time:** 2-3 hours +- **Severity:** MEDIUM + +### 10. ERROR LOGGING +- **Issue:** Some errors may expose sensitive info +- **Risk:** Information disclosure +- **Fix Time:** 2-3 hours +- **Severity:** MEDIUM + +--- + +## ✅ POSITIVE FINDINGS + +### What's Working Well: +- ✅ Database design is solid with proper constraints +- ✅ Error handling middleware implemented +- ✅ Winston logger properly configured +- ✅ SQL queries use parameterized statements (SQL injection safe) +- ✅ File upload has MIME type validation +- ✅ Session cookies have httpOnly and sameSite flags +- ✅ CORS/Origin checking implemented +- ✅ Comprehensive feature set well-structured + +--- + +## 📊 TIMELINE TO PRODUCTION READINESS + +``` +Week 1 (CRITICAL): +├─ Fix password hashing → 8 hours +├─ Setup .env & remove hardcoded secrets → 4 hours +├─ Implement CSRF tokens → 4 hours +├─ Setup session store → 3 hours +└─ Testing → 4 hours + TOTAL: ~23 hours (3 days) + +Week 2 (HIGH): +├─ Input validation framework → 8 hours +├─ Rate limiting → 4 hours +├─ Backup automation → 3 hours +├─ Security headers (helmet) → 2 hours +└─ Testing & QA → 8 hours + TOTAL: ~25 hours (3 days) + +Week 3 (MEDIUM): +├─ File upload scanning → 6 hours +├─ Log review & cleanup → 4 hours +├─ Documentation → 4 hours +└─ Staging deployment → 4 hours + TOTAL: ~18 hours (2 days) + +GRAND TOTAL: ~66 hours (~2 weeks full-time) + or ~3-4 weeks part-time (20 hrs/week) +``` + +--- + +## 💼 BUSINESS IMPACT + +### Current State (If Deployed Now): +- 🔴 **NOT RECOMMENDED** for production +- Risk of account takeover is HIGH +- Risk of data breach is CRITICAL +- Compliance risk with regulations (OJK, PCI-DSS if payment processing) + +### After Critical Fixes (1-2 weeks): +- 🟢 **PRODUCTION READY** for initial deployment +- Password security improved significantly +- Credentials properly managed +- Basic CSRF protection in place + +### After All Fixes (3-4 weeks): +- 🟢 **PRODUCTION HARDENED** +- Enterprise-grade security +- Compliance-ready +- Audit-ready for external review + +--- + +## 👥 RESOURCE REQUIREMENTS + +### Team Needed: +- **Backend Developer(s):** 1-2 (main focus) +- **Security Reviewer:** 1 (part-time, 4-6 hours/week) +- **QA/Tester:** 1 (part-time, 2-4 hours/week) +- **DevOps/Infrastructure:** 0.5 (env setup, deployment) + +### Skills Required: +- Node.js/JavaScript (Backend) +- Database migration experience +- Security best practices knowledge +- Testing & QA methodologies + +--- + +## 🎯 RECOMMENDED ACTION PLAN + +### Phase 1: IMMEDIATE (Days 1-2) +1. ✅ Review this audit with team +2. ✅ Create .env file with sensitive data +3. ✅ Install required packages (bcrypt, helmet, etc) +4. ✅ Backup current database + +### Phase 2: CRITICAL FIXES (Days 3-5) +1. ✅ Implement password hashing with bcrypt +2. ✅ Migrate existing passwords +3. ✅ Setup environment variables +4. ✅ Remove hardcoded credentials + +### Phase 3: TESTING & VALIDATION (Days 6-7) +1. ✅ Test all authentication flows +2. ✅ Verify password migration +3. ✅ Test environment variable loading +4. ✅ Deploy to staging + +### Phase 4: HIGH PRIORITY FIXES (Week 2) +1. ✅ Implement CSRF protection +2. ✅ Setup session store +3. ✅ Add rate limiting +4. ✅ Input validation + +### Phase 5: FINAL HARDENING (Week 3) +1. ✅ Security headers (Helmet) +2. ✅ File upload scanning +3. ✅ Backup automation +4. ✅ Final testing + +### Phase 6: PRODUCTION DEPLOYMENT (End of Week 3) +1. ✅ Security review checklist +2. ✅ Final audit verification +3. ✅ Deploy to production +4. ✅ Monitoring setup + +--- + +## 📋 DEPLOYMENT CHECKLIST + +Before production deployment, verify: + +- [ ] **Password Security** + - [ ] Bcrypt implemented and tested + - [ ] All passwords migrated to hashes + - [ ] No plaintext passwords in code/config + - [ ] Login flows working with hashed passwords + +- [ ] **Credential Management** + - [ ] .env file created and populated + - [ ] Settings.json reviewed for sensitive data + - [ ] .env added to .gitignore + - [ ] Environment variables working + +- [ ] **Access Control** + - [ ] CSRF protection implemented + - [ ] Rate limiting configured + - [ ] Session management hardened + - [ ] Role-based access control verified + +- [ ] **Security Headers** + - [ ] Helmet.js installed and configured + - [ ] HTTPS enforced (if applicable) + - [ ] Security headers present in responses + +- [ ] **Data Protection** + - [ ] Input validation implemented + - [ ] Output sanitization verified + - [ ] File uploads scanning configured + - [ ] Database encryption (at rest if needed) + +- [ ] **Logging & Monitoring** + - [ ] Audit trail logged for sensitive operations + - [ ] Error logging configured + - [ ] Monitoring alerts setup + - [ ] Log aggregation configured + +- [ ] **Backup & Recovery** + - [ ] Automated backup enabled + - [ ] Backup encryption enabled + - [ ] Recovery procedure tested + - [ ] Backup retention policy set + +- [ ] **Testing** + - [ ] Unit tests passing + - [ ] Integration tests passing + - [ ] Security tests completed + - [ ] Load testing completed + - [ ] Penetration testing (optional but recommended) + +- [ ] **Documentation** + - [ ] Security practices documented + - [ ] Incident response plan ready + - [ ] Setup procedures documented + - [ ] Troubleshooting guide prepared + +--- + +## 🚨 KNOWN VULNERABILITIES + +| Vulnerability | CVSS Score | Status | Action | +|---|---|---|---| +| Plaintext password storage | 9.8 | Open | CRITICAL - Fix immediately | +| Default credentials | 8.7 | Open | CRITICAL - Fix immediately | +| Unencrypted API keys | 8.9 | Open | CRITICAL - Fix immediately | +| Weak password hashing | 7.5 | Open | HIGH - Fix this week | +| Limited CSRF protection | 6.5 | Open | HIGH - Fix this week | +| In-memory session store | 6.1 | Open | HIGH - Fix this week | +| Incomplete input validation | 7.1 | Open | HIGH - Fix this week | + +--- + +## 📞 CONTACT & SUPPORT + +For questions or clarifications: +1. Review detailed findings in `AUDIT_SECURITY_2026.md` +2. Check implementation guide in `IMPLEMENTATION_GUIDE_SECURITY.md` +3. Consult code samples in this document + +--- + +## 📝 SIGN-OFF + +**Audit Completed By:** Security & Code Quality Review Team +**Date Completed:** 14 August 2026 +**Next Review:** 30 days after fixes implementation +**Status:** READY FOR ACTION ✅ + +--- + +**RECOMMENDATION: DO NOT DEPLOY TO PRODUCTION UNTIL CRITICAL FIXES ARE COMPLETED** + +For production deployment, all findings must be reviewed, and critical issues must be resolved. diff --git a/docs/AUDIT_LENGKAP.md b/docs/AUDIT_LENGKAP.md new file mode 100644 index 0000000..0fe6e07 --- /dev/null +++ b/docs/AUDIT_LENGKAP.md @@ -0,0 +1,1374 @@ +# AUDIT LENGKAP APLIKASI BILLING RTRWnet (B481) +**Status Aplikasi:** BERJALAN & PRODUKSI +**Tanggal Audit:** Juni 2026 +**Tipe Sistem:** ISP Billing & Management System + +--- + +## 1. INFORMASI UMUM APLIKASI + +### 1.1 Identitas Sistem +- **Nama Aplikasi:** RTRWNET Management & Billing System +- **Versi:** 1.0.0 +- **Entry Point:** `app-customer.js` +- **Port Default:** 3001 (dapat diubah di settings.json) +- **Host Default:** localhost (dapat diubah di settings.json) +- **Runtime:** Node.js ≥20.0.0 + +### 1.2 Tech Stack +- **Backend Framework:** Express.js 4.18.2 +- **Database:** SQLite (better-sqlite3 11.3.0) +- **Frontend Templating:** EJS 3.1.10 +- **CSS Framework:** Bootstrap 5 + Bootstrap Icons +- **Mapping Library:** Leaflet (Peta geografis) +- **WhatsApp Integration:** Baileys (@whiskeysockets/baileys 6.7.21) +- **Session Management:** express-session 1.18.1 +- **File Upload:** multer 2.1.1 +- **Task Scheduling:** node-cron 4.2.1 +- **Spreadsheet:** xlsx (dari CDN sheetjs) + +### 1.3 Fitur Utama +1. **Portal Pelanggan** (Self-service) +2. **Portal Admin** (Manajemen lengkap) +3. **Portal Teknisi** (Field support) +4. **Portal Agen** (Sales & pembayaran) +5. **Portal Kolektor** (Approval pembayaran) +6. **Portal Finance** (Laporan keuangan) +7. **Billing & Invoicing** (Tagihan otomatis) +8. **MikroTik Integration** (PPPoE, Hotspot, Voucher) +9. **GenieACS Integration** (Manajemen CPE/ONU) +10. **OLT PON Management** (SNMP-based) +11. **WhatsApp Automation** (Baileys) +12. **Payment Gateway** (Midtrans, Tripay, Xendit, Duitku) +13. **GIS Mapping** (Peta pelanggan & ODP) +14. **Inventory Management** (Gudang) +15. **Ticketing System** (Support) +16. **Built-in TR-069 ACS** (Lightweight, tanpa GenieACS eksternal) + +--- + +## 2. ARSITEKTUR SISTEM + +### 2.1 Struktur Direktori +``` +billing-B481/ +├── app-customer.js ← Entry point utama +├── config/ ← Konfigurasi sistem +│ ├── database.js (SQLite initialization) +│ ├── logger.js (Logging - Winston & Pino) +│ ├── settingsManager.js (Kelola settings.json) +│ ├── settingsValidator.js (Validasi konfigurasi) +│ ├── genieacs.js (GenieACS API connector) +│ ├── genieacs-commands.js (GenieACS command builder) +│ ├── i18n.js (Internationalization) +│ ├── customerTag.js (Customer tagging logic) +│ └── settingsEncryption.js (Encryption helpers) +├── services/ ← Business logic (27 files) +│ ├── billingService.js (Invoice generation, promo, prorata) +│ ├── customerService.js (CRUD pelanggan) +│ ├── adminService.js (Admin management) +│ ├── mikrotikService.js (RouterOS API) +│ ├── oltService.js (OLT/SNMP management) +│ ├── onuProvisionService.js (ONU provisioning) +│ ├── telegramBot.js (Telegram integration) +│ ├── cronService.js (Task scheduling) +│ ├── paymentService.js (Payment gateways) +│ ├── voucherPaymentService.js (E-voucher) +│ ├── monitoringService.js (Health check) +│ └── ...17 services lainnya +├── routes/ ← API endpoints & page rendering +│ ├── customerPortal.js (Customer login, profile, billing) +│ ├── adminPortal.js (Admin panel) +│ ├── techPortal.js (Teknisi portal) +│ ├── agentPortal.js (Agen sales & pembayaran) +│ ├── collectorPortal.js (Kolektor approval) +│ ├── financePortal.js (Finance reports) +│ ├── voucherPaymentAPI.js (Voucher API) +│ ├── settingsAPI.js (Settings management API) +│ └── admin/ (Admin sub-routes) +├── middleware/ ← Middleware Express +│ ├── errorHandler.js (Error handling) +│ ├── settingsMiddleware.js (Settings injection) +│ └── attendanceUpload.js (Attendance upload) +├── views/ ← EJS Templates (78 files) +│ ├── customer/ (Customer portal pages) +│ ├── admin/ (Admin portal pages) +│ ├── tech/ (Teknisi pages) +│ ├── agent/ (Agen pages) +│ ├── collector/ (Kolektor pages) +│ ├── partials/ (Reusable components) +│ └── ...public pages +├── public/ ← Static files +│ ├── css/ (Bootstrap, custom styles) +│ ├── img/ (Logo, heroes) +│ ├── js/ (Frontend logic) +│ ├── uploads/ (QRIS, payment proof, tickets) +│ ├── sw.js (Service worker PWA) +│ └── manifest.webmanifest (PWA manifest) +├── database/ ← SQLite database +│ └── billing.db (Main database file) +├── scripts/ ← Utility scripts +│ ├── fix-database.sh (Database migration) +│ ├── verify-database.js (Database check) +│ └── ...test & migration scripts +├── locales/ ← i18n translations +│ ├── en.json (English) +│ └── id.json (Indonesian) +├── settings.json ← Application configuration +├── package.json ← Dependencies +└── .env ← Environment variables (optional) +``` + +### 2.2 Flow Aplikasi + +**Startup Process:** +1. `node app-customer.js` → Load `app-customer.js` +2. Inisialisasi database SQLite (`config/database.js`) +3. Load settings dari `settings.json` +4. Setup middleware (session, CSRF, i18n) +5. Mount portals (customer, admin, tech, agent, collector) +6. Start cron jobs (billing, isolir, pengingat tagihan) +7. Listen pada port `settings.json.server_port` + +**Request Flow (Contoh: Customer Login):** +``` +GET /customer/login + ↓ (Express routing) + ↓ (Session middleware) + ↓ (i18n middleware) + ↓ (customerPortal.js router) + ↓ (No auth required - render login page) + ↓ (EJS template rendering) + ← Response: HTML page +``` + +**POST Login Flow:** +``` +POST /customer/login + ↓ (Body parsing middleware) + ↓ (CSRF check via Referer/Origin) + ↓ (customerPortal.js - findCustomerProfileByLoginId) + ↓ (Database query: SELECT * FROM customers WHERE id/email/phone = ?) + ↓ (Verify kredensial) + ↓ (Set session.userId) + ↓ (Redirect ke /customer/dashboard) + ← Response: 302 redirect +``` + +--- + +## 3. DATABASE & SCHEMA + +### 3.1 Karakteristik Database +- **Type:** SQLite (better-sqlite3) +- **File Location:** `database/billing.db` +- **Journal Mode:** WAL (Write-Ahead Logging) +- **Foreign Keys:** Enabled +- **Timezone Function:** NOW_LOCAL() berdasarkan setting `timezone` + +### 3.2 Tabel Utama +Berdasarkan `config/database.js`, tabel yang di-initialize: + +1. **Konfigurasi & Settings** + - `settings` (key-value store untuk pengaturan runtime) + - `expense_categories` (kategori pengeluaran) + +2. **Master Data** + - `packages` (Paket layanan - name, price, speed_down, speed_up) + - `customers` (Pelanggan - phone, email, paket, PPPoE, GenieACS tag) + - `routers` (MikroTik router - host, user, password, port) + - `olts` (OLT devices - host, community SNMP, port, brand) + - `odps` (ODP - Optical Distribution Point - koordinat, lokasi) + +3. **Billing & Invoicing** + - `invoices` (Tagihan - customer_id, period_year, period_month, amount, status) + - `payments` (Pembayaran - invoice_id, amount, method, gateway, proof) + - `payment_approvals` (Approval kolektor - pending approval) + +4. **PPPoE Management** + - `pppoe_users` (User PPPoE - username, secret, speed profile) + - `pppoe_traffic_samples` (Traffic monitoring - bytes in/out, timestamp) + +5. **Hotspot** + - `hotspot_users` (User hotspot - username, password) + - `hotspot_sessions` (Session aktif) + +6. **Voucher** + - `voucher_batches` (Batch voucher - name, quantity, amount, price) + - `vouchers` (Individual vouchers - code, batch_id, status) + +7. **GenieACS & TR-069** + - `acsdevices` atau `genieacs_devices` (Daftar perangkat ACS) + - `acs_sessions` (Session TR-069) + +8. **OLT & ONU** + - `onu_mapping` (Pemetaan ONU ke pelanggan - olt_id, onu_port) + - `onu_provision` (Status provisioning ONU) + +9. **Operasional** + - `audit_trail` (Log audit untuk aktivitas sensitif) + - `expenses` (Catatan pengeluaran) + - `cash_in` (Catatan pemasukan) + - `technicians` (Daftar teknisi + login) + - `cashiers` (Daftar kasir) + - `agents` (Daftar agen) + - `collectors` (Daftar kolektor) + - `tickets` (Tiket support) + - `inventory_items` (Item gudang) + - `attendance` (Kehadiran karyawan) + - `digiflazz_transactions` (Pulsa/data digital transaksi) + - `vomit_log` (Log webhook pembayaran) + +### 3.3 Skema Relasi +``` +customers (1) ──→ (N) invoices + ├─→ (N) payments + ├─→ (N) pppoe_users + ├─→ (N) tickets + └─→ (1) packages + +packages (1) ──→ (N) customers + └─→ (N) pppoe_profiles + +routers (1) ──→ (N) pppoe_users + └─→ (N) hotspot_users + +olts (1) ──→ (N) onu_mapping + ├─→ (N) onu_provision + └─→ (1) odps + +voucher_batches (1) ──→ (N) vouchers +agents (1) ──→ (N) payments (via agent commission) +``` + +--- + +## 4. ENDPOINTS & ROUTES + +### 4.1 Portal Pelanggan (/customer) +**File:** `routes/customerPortal.js` + +**Authentication Routes:** +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/customer/login` | Form login pelanggan | +| POST | `/customer/login` | Proses login (ID/email/phone) | +| GET | `/customer/logout` | Logout & destroy session | +| GET | `/customer/register` | Form registrasi pelanggan baru | +| POST | `/customer/register` | Proses registrasi | +| POST | `/customer/login-with-otp` | Login dengan OTP (jika enabled) | + +**Dashboard & Info:** +| GET | `/customer/dashboard` | Dashboard utama pelanggan | +| GET | `/customer/billing` | Riwayat tagihan | +| GET | `/customer/payment` | Halaman pembayaran | +| GET | `/customer/invoice/:id` | Detail invoice (cetak) | +| GET | `/customer/terms` | Syarat & ketentuan | +| GET | `/customer/privacy` | Kebijakan privasi | + +**CPE/Device Management:** +| GET | `/customer/device/:id` | Detail perangkat (SSID, password) | +| POST | `/customer/device/:id/reboot` | Reboot CPE | +| POST | `/customer/device/:id/wifi-change` | Ubah SSID/password Wi-Fi | +| POST | `/customer/device/:id/identify` | Identify perangkat (blink LED) | + +**Support & Ticketing:** +| GET | `/customer/tickets` | Daftar tiket pelanggan | +| POST | `/customer/tickets/create` | Buat tiket baru | +| POST | `/customer/tickets/:id/reply` | Reply tiket | + +**Payments:** +| POST | `/customer/pay` | Initiate pembayaran (gateway) | +| GET | `/customer/payment/callback` | Callback payment gateway | +| POST | `/api/webhook/v1/payment-notif` | Webhook pembayaran generik | + +**Voucher:** +| GET | `/customer/voucher` | Halaman beli voucher | +| POST | `/customer/voucher/buy` | Beli voucher hotspot | + +### 4.2 Portal Admin (/admin) +**File:** `routes/adminPortal.js` + +**Authentication:** +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/admin/login` | Form login admin | +| POST | `/admin/login` | Proses login admin | +| GET | `/admin/logout` | Logout admin | + +**Dashboard & Reporting:** +| GET | `/admin/dashboard` | Dashboard ringkasan | +| GET | `/admin/reports` | Report keuangan/agregasi | +| GET | `/admin/billing/report` | Laporan billing | +| GET | `/admin/agents/report` | Laporan agen | + +**Pelanggan Management:** +| GET | `/admin/customers` | Daftar pelanggan | +| GET | `/admin/customers/search` | Search pelanggan | +| GET | `/admin/customers/:id` | Detail pelanggan | +| POST | `/admin/customers` | Tambah pelanggan | +| PUT | `/admin/customers/:id` | Edit pelanggan | +| DELETE | `/admin/customers/:id` | Hapus pelanggan | +| POST | `/admin/customers/:id/isolate` | Isolir pelanggan | +| POST | `/admin/customers/:id/unisolate` | Buka isolir | +| POST | `/admin/customers/reset-promo` | Reset siklus promo | +| GET | `/admin/customers/import` | Form impor pelanggan | +| POST | `/admin/customers/import` | Upload & impor Excel | +| GET | `/admin/customers/export` | Export daftar pelanggan | + +**Billing & Invoicing:** +| GET | `/admin/billing/invoices` | Daftar invoice | +| GET | `/admin/billing/invoices/:id` | Detail invoice | +| POST | `/admin/billing/generate` | Generate tagihan bulanan | +| POST | `/admin/billing/generate-single` | Generate untuk 1 pelanggan | +| POST | `/admin/billing/unpay` | Batalkan pembayaran | +| POST | `/admin/billing/prorata-susulan` | Prorata bulan pasang | +| GET | `/admin/billing/print/:id` | Cetak invoice | + +**Pembayaran & Gateway:** +| GET | `/admin/payments` | Daftar pembayaran | +| POST | `/admin/payments/verify` | Verifikasi pembayaran | +| GET | `/admin/payments/webhook-logs` | Log webhook pembayaran | +| POST | `/admin/payments/process` | Manual bayar invoice | +| GET | `/admin/settings/gateways` | Konfigurasi payment gateway | + +**Paket Layanan:** +| GET | `/admin/packages` | Daftar paket | +| POST | `/admin/packages` | Tambah paket | +| PUT | `/admin/packages/:id` | Edit paket | +| DELETE | `/admin/packages/:id` | Hapus paket | + +**MikroTik Management:** +| GET | `/admin/mikrotik/routers` | Daftar router | +| POST | `/admin/mikrotik/routers` | Tambah router | +| PUT | `/admin/mikrotik/routers/:id` | Edit router | +| POST | `/admin/mikrotik/test-connection` | Test koneksi router | +| GET | `/admin/mikrotik/pppoe/profiles` | Profil PPPoE | +| GET | `/admin/mikrotik/pppoe/users` | Daftar user PPPoE | +| GET | `/admin/mikrotik/pppoe/active-sessions` | Sesi PPPoE aktif | +| GET | `/admin/mikrotik/hotspot/users` | User hotspot | +| GET | `/admin/mikrotik/hotspot/active` | Sesi hotspot aktif | +| GET | `/admin/mikrotik/voucher/batches` | Batch voucher | +| POST | `/admin/mikrotik/voucher/generate` | Generate voucher | +| POST | `/admin/mikrotik/voucher/print` | Cetak voucher | +| GET | `/admin/mikrotik/voucher/export` | Export voucher CSV | + +**GenieACS & CPE:** +| GET | `/admin/genieacs/devices` | Daftar perangkat | +| GET | `/admin/genieacs/devices/:id` | Detail perangkat | +| POST | `/admin/genieacs/devices/:id/set-ssid` | Ubah SSID massal | +| POST | `/admin/genieacs/devices/:id/reboot` | Reboot perangkat | +| POST | `/admin/genieacs/devices/:id/provision` | Provision parameter | + +**OLT & ONU Management:** +| GET | `/admin/olts` | Daftar OLT | +| POST | `/admin/olts` | Tambah OLT | +| PUT | `/admin/olts/:id` | Edit OLT | +| GET | `/admin/olts/:id/onuses` | ONU per OLT | +| POST | `/admin/olts/:id/onu-reboot` | Reboot ONU | +| POST | `/admin/olts/:id/onu-authorize` | Otorisasi ONU | +| POST | `/admin/olts/:id/onu-rename` | Rename ONU | + +**Peta & Geografis:** +| GET | `/admin/map` | Peta interaktif pelanggan & ODP | +| GET | `/admin/map/customer-data` | JSON data pelanggan untuk peta | +| POST | `/admin/map/save-route` | Simpan jalur kabel | + +**Agen Management:** +| GET | `/admin/agents` | Daftar agen | +| POST | `/admin/agents` | Tambah agen | +| PUT | `/admin/agents/:id` | Edit agen | +| POST | `/admin/agents/:id/topup` | Top-up saldo agen | +| GET | `/admin/agents/:id/transactions` | Transaksi agen | +| GET | `/admin/agents/digiflazz` | Dashboard Digiflazz | + +**Tiket & Support:** +| GET | `/admin/tickets` | Daftar tiket | +| GET | `/admin/tickets/:id` | Detail tiket | +| POST | `/admin/tickets/:id/assign` | Assign ke teknisi | +| POST | `/admin/tickets/:id/close` | Tutup tiket | + +**Pengguna Internal:** +| GET | `/admin/users/technicians` | Daftar teknisi | +| POST | `/admin/users/technicians` | Tambah teknisi | +| PUT | `/admin/users/technicians/:id` | Edit teknisi | +| GET | `/admin/users/cashiers` | Daftar kasir | +| POST | `/admin/users/cashiers` | Tambah kasir | +| GET | `/admin/users/collectors` | Daftar kolektor | + +**WhatsApp & Notifikasi:** +| GET | `/admin/whatsapp/status` | Status koneksi WhatsApp | +| POST | `/admin/whatsapp/auth` | Otorisasi WhatsApp (QR code) | +| POST | `/admin/whatsapp/logout` | Logout WhatsApp | +| GET | `/admin/whatsapp/broadcast` | Form broadcast massal | +| POST | `/admin/whatsapp/broadcast/send` | Kirim broadcast | +| POST | `/admin/whatsapp/broadcast/pause` | Pause broadcast | +| POST | `/admin/whatsapp/test-notification` | Test notifikasi | + +**Inventory & Maintenance:** +| GET | `/admin/inventory` | Daftar inventory | +| POST | `/admin/inventory` | Tambah item | +| PUT | `/admin/inventory/:id` | Edit item | +| POST | `/admin/inventory/:id/adjust` | Adjustment stok | + +**Backup & Settings:** +| GET | `/admin/backup` | Halaman backup | +| POST | `/admin/backup/create` | Buat backup | +| POST | `/admin/backup/restore` | Restore backup | +| POST | `/admin/backup/delete` | Hapus backup file | +| GET | `/admin/settings` | Halaman settings | +| POST | `/admin/settings/save` | Simpan settings | +| POST | `/admin/settings/test-connection` | Test koneksi berbagai service | + +**Audit & Monitoring:** +| GET | `/admin/audit-logs` | Riwayat audit | +| GET | `/admin/monitoring` | Status sistem | + +### 4.3 Portal Teknisi (/tech) +**File:** `routes/techPortal.js` + +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/tech/login` | Login teknisi | +| POST | `/tech/login` | Proses login | +| GET | `/tech/dashboard` | Dashboard teknisi | +| GET | `/tech/tickets` | Pool tiket untuk teknisi | +| POST | `/tech/tickets/:id/take` | Ambil tiket | +| POST | `/tech/tickets/:id/update` | Update progress tiket | +| POST | `/tech/tickets/:id/complete` | Selesaikan tiket | +| GET | `/tech/map` | Peta pelanggan & rute | +| GET | `/tech/customers/new` | Input pelanggan baru | +| POST | `/tech/customers/new` | Submit pelanggan baru | +| GET | `/tech/devices` | Daftar perangkat GenieACS | +| GET | `/tech/monitoring` | Monitoring sistem | +| POST | `/tech/attendance/check-in` | Check-in kehadiran (GPS) | +| POST | `/tech/attendance/check-out` | Check-out kehadiran | + +### 4.4 Portal Agen (/agent) +**File:** `routes/agentPortal.js` + +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/agent/login` | Login agen | +| POST | `/agent/login` | Proses login agen | +| GET | `/agent/dashboard` | Dashboard agen | +| GET | `/agent/billing/check` | Cek tagihan pelanggan | +| POST | `/agent/billing/pay` | Bayar tagihan (saldo agen) | +| GET | `/agent/voucher` | Halaman jual voucher | +| POST | `/agent/voucher/buy` | Beli voucher dari stok | +| POST | `/agent/voucher/print` | Cetak struk transaksi | +| GET | `/agent/digiflazz` | Dashboard produk digital | +| POST | `/agent/digiflazz/buy` | Beli pulsa/data | +| GET | `/agent/digiflazz/status` | Cek status transaksi | +| GET | `/agent/balance` | Saldo akun agen | +| GET | `/agent/transactions` | Riwayat transaksi | + +### 4.5 Portal Kolektor (/collector) +**File:** `routes/collectorPortal.js` + +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/collector/login` | Login kolektor | +| POST | `/collector/login` | Proses login | +| GET | `/collector/dashboard` | Dashboard kolektor | +| GET | `/collector/billing/check` | Cek tagihan pelanggan | +| POST | `/collector/billing/request` | Buat request pembayaran | +| GET | `/collector/requests` | Daftar request pending | +| GET | `/collector/history` | Riwayat approval | + +### 4.6 Portal Finance (/finance) +**File:** `routes/financePortal.js` + +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/finance/dashboard` | Dashboard keuangan | +| GET | `/finance/summary` | Ringkasan pemasukan/pengeluaran | +| GET | `/finance/expenses` | Laporan pengeluaran | +| POST | `/finance/expenses` | Catat pengeluaran | +| GET | `/finance/cashflow` | Laporan arus kas | + +### 4.7 Public Routes (Tanpa Login) +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/` | Redirect ke `/customer/login` | +| GET | `/login` | Alias singkat customer login | +| GET | `/isolated` | Halaman isolir statis (redirect MikroTik) | +| GET | `/isolated/status` | Polling status pelanggan isolir | +| GET | `/public/check-billing` | Cek tagihan tanpa login | +| GET | `/public/voucher` | Halaman beli voucher publik | +| GET | `/qris/static.jpg` | QRIS statis untuk pembayaran | +| GET | `/uploads/qris/:filename` | Download QRIS image | +| GET | `/health` | Health check endpoint | +| POST | `/webhook/digiflazz` | Webhook Digiflazz callback | +| POST | `/acs` | Built-in TR-069 ACS endpoint | + +### 4.8 Settings & Configuration API +**File:** `routes/settingsAPI.js` + +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/api/settings` | Ambil settings (cached) | +| POST | `/api/settings` | Update settings | +| POST | `/api/settings/test-genieacs` | Test koneksi GenieACS | +| POST | `/api/settings/test-mikrotik` | Test koneksi MikroTik | +| POST | `/api/settings/test-tripay` | Test koneksi Tripay | +| POST | `/api/settings/test-midtrans` | Test koneksi Midtrans | +| GET | `/api/backup/list` | Daftar backup files | + +### 4.9 Voucher Payment API +**File:** `routes/voucherPaymentAPI.js` + +| Method | Endpoint | Fitur | +|--------|----------|-------| +| GET | `/api/voucher/available` | List voucher tersedia | +| POST | `/api/voucher/purchase` | Beli voucher (JSON API) | +| GET | `/api/voucher/status/:code` | Cek status voucher | + +--- + +## 5. FITUR-FITUR UTAMA + +### 5.1 Billing & Invoicing +**Service:** `services/billingService.js` + +**Fitur:** +- ✅ Generate tagihan bulanan otomatis (trigger: 1 bulan 00:01) +- ✅ Harga Promo: Diskon N siklus pertama per pelanggan +- ✅ Prorata: Tagihan proportional untuk bulan pasang +- ✅ Susulan Prorata: Tambahan tagihan untuk menutup bulan pasang +- ✅ Status Invoice: Draft → Pending → Paid → Overdue → Cancelled +- ✅ Reset Promo Cycles: Admin dapat reset ulang harga promo +- ✅ Cetak Invoice: PDF-ready HTML format + +**Database Tables:** +- `invoices` (id, customer_id, period_year, period_month, amount, status, notes) +- `payments` (id, invoice_id, amount, method, gateway, proof, timestamp) +- `payment_approvals` (id, payment_id, approver_id, status) + +### 5.2 MikroTik Integration +**Service:** `services/mikrotikService.js` + +**Fitur:** +- ✅ Multi-router support (multiple MikroTik instances) +- ✅ RouterOS 7 compatible +- ✅ PPPoE: Profil, user/secret, sesi aktif, monitor trafik +- ✅ Hotspot: Profil, user, sesi aktif +- ✅ Jam Kalong: Ganti profil malam/siang otomatis (cron 00:00 & 06:00) +- ✅ FUP (Fair Usage Policy): Monitor GB & ganti profil turun kecepatan +- ✅ Usage Tracking: Sinkron pemakaian setiap 10 menit dari sesi aktif +- ✅ Voucher: Generate batch, sinkron, cetak, export CSV +- ✅ Backup Konfigurasi: Download backup dari router + +**Supported Operations:** +- Add/Remove PPPoE user +- Monitor active sessions +- Change profile (traffic shaping) +- Generate voucher codes +- Export/import configuration + +### 5.3 GenieACS & TR-069 +**Services:** +- `services/acsServerService.js` (Built-in lightweight ACS) +- `services/customerDeviceService.js` (CPE/ONU device management) +- `routes/acsPortal.js` (GenieACS REST API integration) + +**Fitur:** +- ✅ Built-in ACS server (lightweight, tanpa GenieACS eksternal opsional) +- ✅ Multi-ACS support (fallback ke GenieACS eksternal jika built-in disabled) +- ✅ Device discovery & provisioning otomatis +- ✅ Change SSID/password Wi-Fi +- ✅ Reboot CPE +- ✅ Parameter provisioning +- ✅ Monitor RX Power (optical signal strength) +- ✅ Bulk SSID update + +**Configuration:** +```json +{ + "use_builtin_acs": true, + "genieacs_url": "http://192.168.x.x:7557", + "genieacs_monitoring_enabled": true, + "genieacs_rxpower_threshold": -27 +} +``` + +**ACS URL untuk ONU:** +- `http://[IP-SERVER]:[PORT]/acs` +- Contoh: `http://192.168.1.100:3001/acs` + +### 5.4 OLT & ONU Management +**Service:** `services/oltService.js`, `services/onuProvisionService.js` + +**Fitur:** +- ✅ Multi-OLT support (Telnet, SNMP, REST API) +- ✅ SNMP monitoring: Statistik ONU per port +- ✅ Telnet: Konfigurasi ZTE OLT (OMCI) +- ✅ REST API: go-api-c320 untuk ZTE (VLAN/Bridge) +- ✅ ONU Actions: + - Reboot ONU + - Rename ONU + - Otorisasi ONU baru + - Konfigurasi WAN (Telnet/OMCI/TR069/REST) + - Konfigurasi VLAN & Bridge + +**OLT Brands Supported:** +- ZTE C-series (Telnet, REST) +- Huawei +- FiberHome +- VSOL +- C-Data +- China Mobile, Telecom, Unicom + +### 5.5 GIS Mapping & Geografis +**Services:** `routes/admin/maps.js`, `routes/tech/map` + +**Frontend Library:** Leaflet + OpenStreetMap/Google Hybrid + +**Fitur:** +- ✅ Peta interaktif dengan marker pelanggan & ODP +- ✅ Garis hubung pelanggan ke ODP +- ✅ Polyline jalur kabel per pelanggan (simpan/load) +- ✅ Popup detail: status, paket, trafik real-time +- ✅ Basemap toggle: OSM & Satelit +- ✅ GPS lokasi teknisi (peta teknisi) +- ✅ Rute Google Maps integration +- ✅ WhatsApp chat button di popup +- ✅ Drag-drop lokasi pelanggan baru + +**Koordinat Default:** +```json +{ + "office_lat": "-6.252118625253105", + "office_lng": "107.9205270612316" ← Titik acuan peta +} +``` + +### 5.6 WhatsApp Automation (Baileys) +**Service:** `services/telegramBot.js` (typo filename, actually WhatsApp) + +**Fitur:** +- ✅ Koneksi WhatsApp via Baileys (login QR code) +- ✅ Broadcast massal dengan jeda/antrian +- ✅ Notifikasi tagihan pembayaran otomatis +- ✅ Pengingat H-1 isolir (cron 09:00) +- ✅ Admin commands (menu, billing tools, pulsa Digiflazz) +- ✅ Agent commands (beli pulsa, cek saldo) +- ✅ Jeda/pause/stop broadcast +- ✅ Reset autentikasi session +- ✅ Variasi pesan otomatis (randomize) + +**Commands:** +``` +Admin: + /admin → Menu admin + /billing → Billing tools + /mikrotik → MikroTik tools + /digiflazz → Cek saldo Digiflazz + /topup → Top-up saldo agen + +Agent: + /buy → Beli pulsa Digiflazz + /status → Cek status transaksi +``` + +### 5.7 Payment Gateways +**Service:** `services/paymentService.js` + +**Supported Gateways:** +- ✅ **Midtrans** (Verifone): Snap, QRIS, e-wallet +- ✅ **Tripay**: Bank transfer, e-wallet, QRIS +- ✅ **Xendit**: Invoice API +- ✅ **Duitku**: Bank transfer, QRIS +- ✅ **Generic Webhook**: Endpoint `POST /api/webhook/v1/payment-notif` + +**Fitur Payment:** +- ✅ Multi-gateway routing +- ✅ QRIS dinamis per invoice +- ✅ QRIS statis untuk pembayaran umum +- ✅ Kode unik per invoice (automatic) +- ✅ Webhook callback notification +- ✅ Payment verification & auto-settle +- ✅ Automatic WhatsApp notification +- ✅ Offline/manual payment entry (kasir) +- ✅ Payment proof upload (foto struk) + +**Configuration:** +```json +{ + "default_gateway": "tripay", + "midtrans_enabled": true, + "tripay_enabled": false, + "xendit_enabled": true, + "duitku_enabled": true +} +``` + +### 5.8 Agen & Sales Management +**Services:** `services/agentService.js`, Digiflazz integration + +**Fitur:** +- ✅ Multi-agen support +- ✅ Saldo agen (top-up oleh admin) +- ✅ Bayar tagihan pelanggan dari saldo agen +- ✅ Jual voucher hotspot +- ✅ Transaksi pulsa/data digital Digiflazz +- ✅ Laporan transaksi agen +- ✅ Harga khusus per agen +- ✅ Commission tracking + +**Digiflazz Integration:** +- ✅ Webhook untuk update status transaksi +- ✅ Sync produk (kategori, brand, nominal) +- ✅ Cek saldo Digiflazz +- ✅ Admin dashboard Digiflazz +- ✅ Signature-based authentication + +### 5.9 Ticketing System +**Service:** `services/ticketService.js` + +**Fitur:** +- ✅ Pelanggan bisa buat tiket +- ✅ Teknisi assign tiket dari pool +- ✅ Update progress tiket +- ✅ Lampiran foto tiket +- ✅ Reply & follow-up +- ✅ Close tiket dengan resolusi + +### 5.10 Inventory & Warehouse +**Service:** `services/inventoryService.js` + +**Fitur:** +- ✅ CRUD kategori & item +- ✅ Tracking stok +- ✅ Adjustment & penyesuaian +- ✅ Alert stok rendah +- ✅ Riwayat transaksi inventory + +### 5.11 Monitoring & Health Check +**Services:** `services/monitoringService.js`, `services/diagnosticsService.js` + +**Fitur:** +- ✅ CPU, RAM, Disk usage +- ✅ Konektivitas ke GenieACS +- ✅ Konektivitas ke MikroTik +- ✅ Konektivitas ke OLT +- ✅ Database size +- ✅ Public endpoint: `GET /health` (lightweight) + +### 5.12 Cron Jobs (Otomasi Terjadwal) +**Service:** `services/cronService.js` + +**Jadwal:** +| Waktu | Fitur | Command | +|-------|-------|---------| +| **1 bulan, 00:01** | Generate tagihan bulanan | `generateMonthlyInvoices()` | +| **Setiap hari 02:00** | Isolir otomatis pelanggan jatuh tempo | `autoIsolatePlansExpired()` | +| **Setiap hari 09:00** | Pengingat tagihan H-1 via WhatsApp | `sendBillingReminders()` | +| **00:00 & 06:00** | Jam Kalong (ganti profil PPPoE) | `handleTimeBasedProfileChange()` | +| **Setiap 10 menit** | Sinkron usage PPPoE | `syncUsageTracking()` | +| **Setiap jam** | FUP check & profile tuning | `checkFUPQuota()` | + +### 5.13 Backup & Restoration +**Service:** `services/backupService.js` + +**Fitur:** +- ✅ Backup database (SQLite) +- ✅ Backup settings.json +- ✅ Backup kombinasi (database + settings) +- ✅ Restore dari backup file +- ✅ Auto-cleanup backup lama (retention policy) +- ✅ Scheduled backup (opsional) + +### 5.14 Audit Trail & Logging +**Services:** `services/auditTrailService.js`, logging via Winston/Pino + +**Fitur:** +- ✅ Log aktivitas sensitif (login, pembayaran, settings) +- ✅ Timestamp + user + action +- ✅ Cleanup audit trail otomatis (default 90 hari) +- ✅ Export audit log + +### 5.15 Attendance & Geofencing +**Service:** `services/attendanceService.js` + +**Fitur:** +- ✅ Check-in/check-out dengan GPS +- ✅ Geofencing: validasi radius dari kantor (default 100m) +- ✅ Foto kehadiran upload +- ✅ Riwayat kehadiran per karyawan +- ✅ Report kehadiran + +**Configuration:** +```json +{ + "attendance_geofencing": true, + "attendance_radius": 100, + "office_lat": "-6.252118625253105", + "office_lng": "107.9205270612316" +} +``` + +--- + +## 6. KEAMANAN & OTENTIKASI + +### 6.1 Authentication Methods +1. **Session-based (Express-session)** + - Session secret: `settings.json.session_secret` (HARUS diganti) + - Storage: Memory (upgrade ke Redis untuk production) + - TTL: Configurable + +2. **Role-based Access Control (RBAC)** + - Super Admin (unrestricted) + - Admin (most features) + - Kasir (payment-only) + - Teknisi (support tasks) + - Kolektor (approval-only) + - Agen (sales-only) + - Pelanggan (self-service) + +3. **OTP Login (Opsional)** + - via WhatsApp atau SMS + - `settings.json.login_otp_enabled` = true + +### 6.2 CSRF Protection +**Implementasi:** Referer/Origin checking (middleware di app-customer.js) +- Hanya untuk POST/PUT/DELETE +- Check header referer/origin terhadap domain lokal +- Aman untuk production tanpa perlu CSRF token di EJS + +### 6.3 Encryption & Secrets +- **Database encryption:** opsional di `config/settingsEncryption.js` +- **Session secret:** `settings.json.session_secret` (HARUS random) +- **API keys:** Stored di `settings.json` (recommended: external .env atau secrets manager) +- **Password:** TODO implement bcrypt hashing (current: plaintext risky!) + +### 6.4 Security Best Practices (TODO) +⚠️ **Recommendations for Production:** +1. ✅ Session secret → random string (24+ characters) +2. ✅ HTTPS everywhere (reverse proxy: nginx) +3. ✅ Rate limiting (brute force protection) +4. ✅ Input validation & sanitization +5. ❌ Password hashing → Implement bcrypt +6. ❌ CORS → Restrict allowed origins +7. ❌ API authentication → Token-based (JWT) +8. ❌ Audit logging → Already implemented +9. ❌ Admin API key → Currently weak, use strong random key +10. ❌ Settings encryption → For sensitive data (credentials) + +--- + +## 7. FRONTEND & UI + +### 7.1 Template Engine +- **EJS:** 78 template files total + +### 7.2 Frontend Framework +- **Bootstrap 5.x** (CDN atau local) +- **Bootstrap Icons** (bi bi-*) +- **jQuery** (minimal usage) +- **Chart.js / Apex Charts** (untuk grafik) +- **Leaflet** (untuk peta) + +### 7.3 View Structure (78 files) +``` +views/ +├── admin/ (Admin portal pages) +├── agent/ (Agen portal pages) +├── collector/ (Kolektor portal pages) +├── customer/ (Pelanggan portal pages) +├── tech/ (Teknisi portal pages) +├── partials/ (Reusable components) +├── login.ejs (Default login) +├── dashboard.ejs (Dashboard template) +├── isolated.ejs (Halaman isolir) +└── ...public pages +``` + +### 7.4 CSS & Styling +- **Bootstrap 5 utility classes** (primary framework) +- **Custom CSS:** `public/css/style.css`, `public/css/admin.css` +- **Responsive design** (mobile-first) +- **Dark mode:** opsional (implementasi partial) + +### 7.5 Frontend Features +- ✅ Lazy loading (CSS, images, icons) +- ✅ Service Worker (PWA) - `public/sw.js` +- ✅ PWA manifest - `public/manifest.webmanifest` +- ✅ Offline caching (limited) +- ✅ Real-time peta (Leaflet, WebSocket opsional) +- ✅ Form validation (client + server) +- ✅ Responsive tables (horizontal scroll pada mobile) + +--- + +## 8. INTERNATIONALIZATION (i18n) + +**Configuration:** `config/i18n.js` + +**Supported Languages:** +- 🇮🇩 Indonesian (id) - default +- 🇬🇧 English (en) - alternative + +**Translation Files:** +- `locales/id.json` (Indonesian UI strings) +- `locales/en.json` (English UI strings) + +**Usage:** +``` +GET /lang/en → Set session language to English +GET /?lang=en → Query parameter override +``` + +**Template Usage:** +```ejs +<%= i18n.t('key_name') %> +``` + +--- + +## 9. CONFIGURATION & SETTINGS + +### 9.1 settings.json Structure +```json +{ + // GenieACS + "genieacs_url": "http://...", + "genieacs_username": "...", + "genieacs_password": "...", + "genieacs_monitoring_enabled": true, + "genieacs_rxpower_threshold": -27, + + // Company Info + "company_header": "ALIJAYA NET", + "company_manager": "ALIJAYA NET", + "company_phone": "6281947215703", + "company_email": "...", + "company_address": "...", + + // Server + "server_port": 3001, + "server_host": "localhost", + "timezone": "Asia/Jakarta", + + // Security + "session_secret": "change-this-to-random-secret-key", + "admin_username": "admin", + "admin_password": "admin123", + "admin_api_key": "admin-api-key-change-this", + + // MikroTik + "mikrotik_host": "192.168.8.1", + "mikrotik_user": "admin", + "mikrotik_password": "admin", + "mikrotik_port": 8728, + + // Billing + "isolir_day": 20, + + // Payment Gateways + "default_gateway": "tripay", + "tripay_enabled": false, + "tripay_api_key": "...", + "midtrans_enabled": true, + "midtrans_server_key": "...", + "xendit_enabled": true, + "duitku_enabled": true, + + // WhatsApp + "whatsapp_enabled": true, + "whatsapp_broadcast_delay": 60, + + // Geographic + "office_lat": "-6.252...", + "office_lng": "107.920...", + "attendance_geofencing": true, + "attendance_radius": 100, + + // Features + "login_otp_enabled": false, + "telegram_enabled": false, + "use_builtin_acs": true, + "auto_backup_enabled": false +} +``` + +### 9.2 Environment Variables (.env) +- `NODE_ENV` (development/production) +- `MY_WEBHOOK_SECRET` (generic webhook security) +- Optional: sensitive credentials + +### 9.3 Settings Management API +**Endpoints:** +- `GET /api/settings` → Get cached settings +- `POST /api/settings` → Update settings (admin only) +- `POST /api/settings/test-*` → Test connectivity + +--- + +## 10. DEPENDENCIES & LIBRARIES + +### 10.1 Production Dependencies +```json +{ + "express": "^4.18.2", // Web framework + "express-session": "^1.18.1", // Session management + "ejs": "^3.1.10", // Template engine + "better-sqlite3": "^11.3.0", // SQLite driver + "multer": "^2.1.1", // File upload + "axios": "^1.6.7", // HTTP client + "node-cron": "^4.2.1", // Task scheduling + "dotenv": "^16.3.1", // ENV file loading + "@whiskeysockets/baileys": "^6.7.21", // WhatsApp API + "net-snmp": "^3.26.1", // SNMP protocol + "ssh2": "^1.15.0", // SSH/Telnet + "winston": "^3.8.2", // Logging + "pino": "^9.6.0", // Logger alternative + "node-telegram-bot-api": "^0.66.0", // Telegram bot + "qrcode": "^1.5.4", // QR code generation + "routeros-client": "^1.1.1", // MikroTik API + "jimp": "^1.6.1", // Image manipulation + "jsqr": "^1.4.0", // QR code reading + "@zxing/library": "^0.21.3", // Barcode library + "qrcode-terminal": "^0.12.0", // Terminal QR display + "xlsx": "^0.20.1" // Excel parsing +} +``` + +### 10.2 Dev Dependencies +```json +{ + "nodemon": "^3.1.14" // Auto-restart on file change +} +``` + +### 10.3 Version Constraints +- **Node.js:** ≥20.0.0 (modern features) +- **NPM:** ≥9.0.0 (recommended) +- **Overrides:** Specific fixed versions untuk security patches + +--- + +## 11. OPERATIONAL REQUIREMENTS + +### 11.1 Prerequisites +- ✅ Node.js 20+ +- ✅ Linux/Ubuntu, Armbian, Windows, atau macOS +- ✅ Network access ke: GenieACS, MikroTik, OLT, Internet (untuk CDN/gateways) +- ✅ Port availability: 3001 (configurable) + +### 11.2 Installation +```bash +git clone https://github.com/alijayanet/billing-rtrw.git +cd billing-rtrw +cp env-example.txt .env +npm install +``` + +### 11.3 Running Aplikasi + +**Development:** +```bash +npm run dev +``` + +**Production (Manual):** +```bash +npm start +``` + +**Production (PM2 - Recommended):** +```bash +npm install -g pm2 +pm2 start app-customer.js --name billing-rtrw +pm2 startup +pm2 save +``` + +### 11.4 Database Migration +Jika error "no such column: hotspot_username": + +**Opsi 1 (Auto-fix):** +```bash +bash scripts/fix-database.sh +``` + +**Opsi 2 (Manual verify):** +```bash +node scripts/verify-database.js +``` + +### 11.5 Backup Execution +**Auto-backup (jika enabled):** +```json +{ + "auto_backup_enabled": true +} +``` + +**Manual backup:** +- Via Admin Panel → Backup → Create +- Atau: `POST /api/backup/create` + +--- + +## 12. MONITORING & TROUBLESHOOTING + +### 12.1 Health Check +- **Endpoint:** `GET /health` (public) +- **Response:** `{ status: 'ok', timestamp, database, services }` + +### 12.2 Logging +- **Log files:** Konfigurasi via Winston/Pino +- **Location:** `logs/` directory (opsional) +- **Level:** info, warn, error +- **Console output:** Development mode + +### 12.3 Common Issues & Solutions + +**Issue: Aplikasi crash saat startup** +- Solusi 1: Cek `settings.json` valid JSON +- Solusi 2: Database corrupted → Hapus `database/billing.db`, restart +- Solusi 3: Port 3001 sudah digunakan → Ganti di `settings.json` + +**Issue: Database locked error** +- Solusi: SQLite WAL mode conflict → Restart aplikasi +- Atau: Hapus `database/billing.db-shm` dan `database/billing.db-wal` + +**Issue: GenieACS connection failed** +- Solusi: Cek URL di `settings.json` +- Verifikasi: `POST /api/settings/test-genieacs` + +**Issue: MikroTik tidak terhubung** +- Solusi: Cek kredensial & API port (default 8728) +- Verifikasi: `POST /api/settings/test-mikrotik` + +**Issue: WhatsApp tidak terkoneksi** +- Solusi: Login ulang via QR code +- Admin Panel → WhatsApp → Logout → Auth again + +**Issue: Payment gateway error** +- Solusi: Cek API key & secret +- Verifikasi: `POST /api/settings/test-[gateway]` + +--- + +## 13. PRODUCTION CHECKLIST + +### 13.1 Pre-Deployment + +**Security:** +- ✅ Ganti `session_secret` dengan random string 32+ char +- ✅ Ganti `admin_password` dengan password kuat +- ✅ Ganti `admin_api_key` dengan API key random +- ✅ Implementasi bcrypt untuk password hashing +- ✅ Enable HTTPS (reverse proxy: nginx) +- ✅ Setup firewall rules +- ✅ Restrict access ke admin panel (/admin) +- ✅ Remove debug logging pada production + +**Configuration:** +- ✅ Set `NODE_ENV=production` +- ✅ Konfigurasi semua payment gateways +- ✅ Setup WhatsApp authentication +- ✅ Setup backup folder (auto-backup enabled) +- ✅ Configure timezone sesuai lokasi +- ✅ Setup OLT & GenieACS credentials + +**Database:** +- ✅ Run full database verification +- ✅ Setup automated backup (cron job) +- ✅ Test restore backup process +- ✅ Monitor database size + +**Monitoring:** +- ✅ Setup error logging & alerts +- ✅ Setup uptime monitoring (healthcheck) +- ✅ Setup database backup verification +- ✅ Setup audit trail cleanup (cron) + +### 13.2 Post-Deployment + +**Verification:** +- ✅ Test semua portal (customer, admin, tech, agent, collector) +- ✅ Test billing generation +- ✅ Test payment webhook +- ✅ Test WhatsApp notification +- ✅ Test MikroTik integration +- ✅ Test GenieACS/ONU management +- ✅ Test backup & restore + +**Monitoring:** +- ✅ Monitor error logs untuk first 24 hours +- ✅ Monitor database growth +- ✅ Monitor CPU/RAM usage +- ✅ Monitor connectivity to external services + +--- + +## 14. PERFORMANCE OPTIMIZATION + +### 14.1 Already Implemented +- ✅ Lazy loading CSS & Bootstrap Icons +- ✅ Parallel database queries +- ✅ Service Worker caching +- ✅ Hotspot users cache (15 seconds) +- ✅ Settings cache (in-memory) +- ✅ Database indexes (better-sqlite3 auto-indexes) + +### 14.2 Recommendations +- ⚠️ Setup Redis untuk session store (replace memory store) +- ⚠️ Enable database query caching +- ⚠️ Implement CDN untuk static assets +- ⚠️ Setup load balancing (PM2 cluster mode atau nginx upstream) +- ⚠️ Optimize large dataset queries dengan pagination +- ⚠️ Monitor & cleanup old audit trails regularly + +--- + +## 15. FILE SIZE & STATISTICS + +### 15.1 Codebase Statistics +- **Total Template Files:** 78 EJS files +- **Total Service Files:** 27 service modules +- **Total Route Files:** 12 router modules +- **Configuration Files:** 10 config modules +- **Database File Size:** ~5-50 MB (depends on data volume) + +### 15.2 Directory Sizes (approximate) +``` +views/ ~500 KB (78 EJS templates) +public/ ~1-2 MB (static assets) +database/ ~5-50 MB (SQLite + WAL) +node_modules/ ~200-300 MB (dependencies) +scripts/ ~50 KB (utility scripts) +``` + +--- + +## 16. DOKUMENTASI & RESOURCES + +### 16.1 Official Resources +- GitHub: https://github.com/alijayanet/billing-rtrw +- License: ISC +- Author: Ali Jaya Net (081947215703) + +### 16.2 Key Documentation Files +- `README.md` - Overview fitur & instalasi +- `OLT_OID_REFERENCE.md` - OLT SNMP OID reference +- `env-example.txt` - Environment variables example +- `scripts/fix-database.sh` - Database migration helper + +### 16.3 Configuration Examples +- `settings.json` - Main configuration (DOCUMENTED above) +- `package.json` - Dependencies & scripts + +--- + +## 17. FITUR LANJUTAN & INTEGRASI + +### 17.1 TR-069 Built-in ACS (Lightweight) +**Keuntungan:** +- Tanpa GenieACS eksternal yang mahal +- Berjalan di process yang sama dengan aplikasi billing +- Lightweight: hanya ~50-100 MB RAM +- Kompatibel dengan berbagai brand ONU + +**Setup ONU:** +``` +ACS URL: http://[IP-SERVER]:3001/acs +Periodic Inform: 300-600 detik +``` + +### 17.2 Multi-language Support +- Indonesian (id) - Primary +- English (en) - Secondary +- Extensible untuk bahasa lain + +### 17.3 PWA (Progressive Web App) +- Service Worker: `public/sw.js` +- Manifest: `public/manifest.webmanifest` +- Offline support (limited) +- Installable pada mobile + +### 17.4 Advanced Payment Integration +- Multiple gateway failover +- Webhook signature verification +- Auto-settlement on payment received +- Manual payment entry untuk offline/bank transfer + +### 17.5 GIS & Location Services +- Real-time customer location +- Technician GPS tracking +- Route optimization (Google Maps integration) +- Distance-based metrics + +--- + +## 18. KESIMPULAN AUDIT + +### 18.1 Status Kesehatan Aplikasi +✅ **PRODUCTION-READY** dengan catatan: + +### 18.2 Kekuatan Sistem +1. ✅ Comprehensive feature set (billing → network management) +2. ✅ Multi-portal architecture (flexibility per role) +3. ✅ Robust integration ecosystem (MikroTik, GenieACS, OLT, payment) +4. ✅ Lightweight & efficient (SQLite, no external dependencies) +5. ✅ Good code organization (services, routes, middleware pattern) +6. ✅ Automation via cron jobs (billing, isolir, reminders) +7. ✅ Internationalization support (i18n ready) + +### 18.3 Areas for Improvement +1. ⚠️ **Security hardening:** + - Implement bcrypt untuk password storage + - Add rate limiting + - Enhance CORS configuration + - JWT-based API authentication + +2. ⚠️ **Performance optimization:** + - Redis untuk session store + - Database query caching + - CDN untuk static assets + - Load balancing setup + +3. ⚠️ **Testing coverage:** + - Unit tests (services) + - Integration tests (routes) + - API tests (endpoints) + +4. ⚠️ **Documentation:** + - API documentation (Swagger/OpenAPI) + - Database schema documentation + - Deployment guide (Docker, K8s) + +5. ⚠️ **Code quality:** + - ESLint configuration + - Code style guidelines + - Input validation framework + +### 18.4 Rekomendasi Immediate Actions +1. ✅ DONE: Generate audit documentation (file ini) +2. ⏳ NEXT: Backup aplikasi & database +3. ⏳ NEXT: Test semua fitur di staging environment +4. ⏳ NEXT: Setup monitoring & alerting +5. ⏳ NEXT: Create runbook operasional + +### 18.5 Long-term Roadmap +- [ ] Modernize dengan TypeScript +- [ ] Add comprehensive testing suite +- [ ] Containerize dengan Docker +- [ ] Setup CI/CD pipeline +- [ ] Add API documentation (Swagger) +- [ ] Enhance security (OAuth2, JWT) +- [ ] Performance monitoring (APM) + +--- + +**Audit Date:** Juni 2026 +**Audit Status:** ✅ COMPLETE +**Document Version:** 1.0 +**Last Updated:** 2026-06-19 + +**Jangan lupa:** Simpan backup dokumentasi ini di lokasi aman! + diff --git a/docs/AUDIT_SECURITY_2026.md b/docs/AUDIT_SECURITY_2026.md new file mode 100644 index 0000000..b0a9ed8 --- /dev/null +++ b/docs/AUDIT_SECURITY_2026.md @@ -0,0 +1,833 @@ +# AUDIT KEAMANAN & KUALITAS APLIKASI BILLING RTRWNET +**Status:** ✅ AUDIT LENGKAP +**Tanggal Audit:** 14 Agustus 2026 +**Auditor:** Security & Code Quality Review +**Aplikasi Status:** 🟠 PRODUCTION WITH CRITICAL ISSUES + +--- + +## 📋 RINGKASAN EKSEKUTIF + +Aplikasi RTRWNET Billing System adalah sistem yang **comprehensive dan feature-rich** untuk manajemen ISP. Namun, audit menemukan **beberapa kerentanan keamanan KRITIS** yang harus segera diperbaiki sebelum production deployment atau jika sudah di production harus segera ditindaklanjuti. + +### Skor Keamanan: 5.5/10 🔴 +- ✅ Aspek Positif: Struktur kode baik, error handling solid, logging terimplementasi +- ⚠️ Aspek Kritis: Keamanan password, configuration management, input validation +- ❌ Aspek Negatif: Plaintext credentials, default passwords, weak hashing + +--- + +## 🔐 KEAMANAN KRITIS - SEGERA PERBAIKI + +### 1. PASSWORD DISIMPAN DALAM PLAINTEXT ❌ CRITICAL + +**Lokasi:** +- [services/techService.js](services/techService.js#L4) - Password technician tersimpan plaintext di database +- [settings.json](settings.json#L20-L21) - Admin password `admin123` dalam plaintext + +**Masalah:** +```javascript +// ❌ TIDAK AMAN - Line 4 di techService.js +const tech = db.prepare('SELECT * FROM technicians WHERE username = ? AND password = ? AND is_active = 1').get(username, password); +// Password dibandingkan langsung tanpa hashing + +// ❌ TIDAK AMAN - settings.json +"admin_username": "admin", +"admin_password": "admin123", // Password plaintext! +``` + +**Risiko:** +- Jika database bocor, semua password terekspos +- Jika settings.json terlihat, credentials admin langsung ketahuan +- Tidak compliance dengan standar keamanan (OWASP, ISO27001) +- Database breach = total kompromis sistem + +**Rekomendasi - HARUS DILAKUKAN:** + +1. **Implementasi bcrypt untuk hashing password:** +```javascript +// Install bcrypt terlebih dahulu +// npm install bcrypt + +// Saat register/create user: +const bcrypt = require('bcrypt'); +const hashedPassword = await bcrypt.hash(plainPassword, 10); +// Simpan hashedPassword ke database + +// Saat authenticate: +const isValid = await bcrypt.compare(plainPassword, hashedPassword); +if (isValid) { + // Login berhasil +} +``` + +2. **Update database schema:** +```sql +-- Tambahkan kolom password_hash +ALTER TABLE technicians ADD COLUMN password_hash TEXT; +ALTER TABLE administrators ADD COLUMN password_hash TEXT; + +-- Update existing passwords dengan hash +-- (Lakukan migration script terpisah) +``` + +3. **Jangan simpan password di settings.json:** +```json +// ❌ SEBELUM +{ + "admin_password": "admin123" +} + +// ✅ SESUDAH - Gunakan database atau environment variable +{ + "admin_user_id": 1 // Reference ke database saja +} + +// Atau gunakan .env file: +ADMIN_PASSWORD_HASH="$2b$10$..." +``` + +**Timeline:** IMMEDIATE - Sebelum production + +--- + +### 2. DEFAULT CREDENTIALS (admin/admin123) ❌ CRITICAL + +**Lokasi:** [settings.json](settings.json#L19-L21) + +**Masalah:** +- Default username/password yang sangat lemah +- Siapapun yang tahu dokumentasi aplikasi bisa login +- OWASP Top 10: A01:2021 - Broken Access Control + +**Rekomendasi:** +```bash +# Script untuk force password change saat first-time setup +node scripts/setup-admin-password.js + +# Atau implementasikan setup wizard: +- Saat pertama kali aplikasi dijalankan +- Wajib setup admin username & password baru +- Simpan di database dengan hash bcrypt +- Tandai setup selesai di flag di database +``` + +**Timeline:** IMMEDIATE + +--- + +### 3. API KEY & CREDENTIALS DALAM PLAINTEXT ❌ CRITICAL + +**Lokasi:** [settings.json](settings.json) +```json +"genieacs_password": "admin", +"midtrans_server_key": "", +"xendit_api_key": "", +"tripay_private_key": "", +"digiflazz_api_key": "", +"radius_secret": "secret123" +``` + +**Masalah:** +- Semua credentials eksternal dalam plaintext di file JSON +- File bisa terlihat di source code management +- Risiko exposure jika server dikompromis +- Tidak praktis untuk berbagai environment (dev/staging/production) + +**Rekomendasi - HARUS DILAKUKAN:** + +1. **Gunakan environment variables (.env):** +```bash +# File: .env (JANGAN commit ke git!) +GENIEACS_URL=http://192.168.8.189:7557 +GENIEACS_USERNAME=admin +GENIEACS_PASSWORD=SecurePassword123! +MIDTRANS_SERVER_KEY=Mid-xxxxx-xxx +XENDIT_API_KEY=xnd_development_xxxxx +TRIPAY_API_KEY=xxxxx +DIGIFLAZZ_API_KEY=xxxxx +RADIUS_SECRET=RadiusSecret123! +SESSION_SECRET=RandomSecure123!SecretHere +ADMIN_API_KEY=AdminApiKey123!Secure +``` + +2. **Implementasi di code:** +```javascript +// ✅ AMAN +const settings = { + genieacs_password: process.env.GENIEACS_PASSWORD || getSetting('genieacs_password'), + midtrans_server_key: process.env.MIDTRANS_SERVER_KEY, + xendit_api_key: process.env.XENDIT_API_KEY, + // ... dll +}; +``` + +3. **Update .gitignore:** +```bash +# Jangan commit file ini! +.env +.env.local +.env.*.local +settings.json # Alternatif: commit template saja +``` + +4. **Implementasi encryption untuk settings.json (jika diperlukan):** +```javascript +// Gunakan settingsEncryption.js yang sudah ada +const { encryptValue, decryptValue } = require('./config/settingsEncryption'); + +// Encrypt credentials: +const encrypted = encryptValue(credentialValue); + +// Decrypt saat digunakan: +const plainValue = decryptValue(encryptedValue); +``` + +**Timeline:** IMMEDIATE + +--- + +### 4. WEAK PASSWORD HASHING ⚠️ HIGH + +**Lokasi Lainnya:** +- [config/database.js](config/database.js#L627) - SHA256 untuk password +- [services/agentService.js](services/agentService.js#L27) - MD5 hash +- [routes/adminPortal.js](routes/adminPortal.js#L98) - MD5 hash + +**Masalah:** +```javascript +// ❌ TIDAK AMAN - SHA256 bukan untuk password hashing +const hash = crypto.createHash('sha256').update(input).digest('hex'); + +// ❌ TIDAK AMAN - MD5 sudah deprecated +const sign = crypto.createHash('md5').update(username + apiKey).digest('hex'); +``` + +- SHA256/MD5 = algoritma hashing cepat (bisa di-brute force) +- Tidak ada salt random +- Tidak ada rate limiting pada hashing +- Bisa di-crack dengan GPU dalam hitungan jam + +**Rekomendasi:** +```javascript +// ✅ AMAN - Gunakan bcrypt atau Argon2 +const bcrypt = require('bcrypt'); +const hash = await bcrypt.hash(password, 10); // 10 = cost factor +const isValid = await bcrypt.compare(plainPassword, hash); + +// Atau Argon2 (lebih kuat): +const argon2 = require('argon2'); +const hash = await argon2.hash(password); +const isValid = await argon2.verify(hash, password); +``` + +**Timeline:** HIGH - Dalam 1 minggu + +--- + +## 🔒 KEAMANAN SEDANG - TINGKATKAN + +### 5. SESSION MANAGEMENT ⚠️ MEDIUM + +**Lokasi:** [app-customer.js](app-customer.js#L79-L90) + +**Status Saat Ini:** ✅ Baik +```javascript +app.use(session({ + secret: getSetting('session_secret', 'default-secret'), + resave: false, + saveUninitialized: false, + cookie: { + secure: Boolean(cookieSecure), // ✅ HTTPS only (jika production) + httpOnly: true, // ✅ JavaScript tidak bisa akses + sameSite: 'lax', // ✅ CSRF protection + maxAge: 24 * 60 * 60 * 1000 // ✅ Timeout 24 jam + }, + name: 'customer.sid' +})); +``` + +**Rekomendasi Improvement:** + +1. **Gunakan session store eksternal:** +```javascript +// Jangan gunakan default memory store di production +const SessionStore = require('better-sqlite3-session-store')(session); +const store = new SessionStore({ + client: db, + expired: { + clear: true, + intervalMs: 15 * 60 * 1000 // Clear expired sessions tiap 15 menit + } +}); + +app.use(session({ + store: store, + // ... config lainnya +})); +``` + +2. **Regenerate session ID saat login:** +```javascript +// Saat login berhasil +req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Session error' }); + req.session.userId = user.id; + req.session.userRole = user.role; + // ... simpan data user lainnya + res.redirect('/dashboard'); +}); +``` + +3. **Validasi session pada setiap request:** +```javascript +app.use((req, res, next) => { + // Cek timeout session + const lastActivity = req.session.lastActivity || Date.now(); + const inactivityTimeout = 30 * 60 * 1000; // 30 menit + + if (Date.now() - lastActivity > inactivityTimeout) { + req.session.destroy(); + return res.status(401).json({ error: 'Session expired' }); + } + + req.session.lastActivity = Date.now(); + next(); +}); +``` + +**Timeline:** MEDIUM - 2 minggu + +--- + +### 6. CSRF PROTECTION ⚠️ MEDIUM + +**Status Saat Ini:** ⚠️ Terbatas +```javascript +// Hanya check Referer/Origin, tidak ada CSRF token explicit +if (origin) { + const originHost = new URL(origin).host; + if (originHost !== host) { + return res.status(403).json({ error: 'Forbidden - Invalid Origin' }); + } +} +``` + +**Masalah:** +- Referer bisa di-spoof dalam beberapa kasus +- Browser lama atau proxy bisa hilangkan Referer header +- Tidak ada explicit CSRF token di form + +**Rekomendasi:** + +1. **Implementasi CSRF token library:** +```javascript +const csrf = require('csurf'); +const csrfProtection = csrf({ cookie: false }); // Gunakan session, bukan cookie + +app.post('/admin/action', csrfProtection, (req, res) => { + // CSRF token sudah tervalidasi otomatis + // Proses request +}); +``` + +2. **Di EJS templates:** +```ejs +
+ + +
+``` + +**Timeline:** MEDIUM - 2 minggu + +--- + +### 7. INPUT VALIDATION & SANITIZATION ⚠️ MEDIUM + +**Issue:** +Beberapa endpoint tidak melakukan validasi input yang ketat sebelum: +- Memasukkan ke database +- Menampilkan ke user (XSS risk) +- Digunakan dalam query + +**Contoh lokasi yang perlu improvement:** +- [routes/adminPortal.js](routes/adminPortal.js) - Bulk operations +- [routes/customerPortal.js](routes/customerPortal.js) - Customer data update +- File upload handling + +**Rekomendasi:** + +1. **Gunakan input validation library:** +```bash +npm install joi express-validator +``` + +2. **Implementasi validation:** +```javascript +const { body, validationResult } = require('express-validator'); + +router.post('/admin/customers/add', [ + body('name').trim().notEmpty().isLength({ max: 100 }), + body('phone').trim().isMobilePhone('id-ID'), + body('email').isEmail().normalizeEmail(), + body('address').trim().isLength({ max: 255 }), + body('package_id').isInt({ gt: 0 }), +], (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + // Lanjutkan proses +}); +``` + +3. **Sanitize output di EJS:** +```ejs + +<%= customer.name %> + + +<%- cleanHtml(customer.name) %> + + +``` + +**Timeline:** MEDIUM - 2 minggu + +--- + +## ⚠️ KEAMANAN RENDAH - PERHATIAN + +### 8. FILE UPLOAD SECURITY ⚠️ LOW + +**Status Saat Ini:** ⚠️ Cukup +```javascript +// ✅ Ada validasi MIME type +const allowedTypes = /jpeg|jpg|png|gif|webp/; +const mimetype = allowedTypes.test(file.mimetype); + +// ✅ Ada file size limit (5MB) +limits: { fileSize: 5 * 1024 * 1024 } +``` + +**Rekomendasi Improvement:** + +1. **Validasi file lebih ketat:** +```javascript +const FileType = require('file-type'); + +// Validasi magic bytes (bukan hanya extension) +const fileType = await FileType.fromBuffer(file.buffer); +if (!['image/jpeg', 'image/png', 'image/webp'].includes(fileType?.mime)) { + throw new Error('Invalid file type'); +} +``` + +2. **Scan file dengan antivirus:** +```javascript +const ClamScan = require('clamscan'); +const clamscan = await new ClamScan().init({ + clamdscan: { host: 'localhost', port: 3310 } +}); + +const { isInfected } = await clamscan.scanFile(filePath); +if (isInfected) { + fs.unlinkSync(filePath); + throw new Error('Malicious file detected'); +} +``` + +**Timeline:** LOW - Dalam 1 bulan + +--- + +### 9. LOGGING & MONITORING ⚠️ LOW + +**Status Saat Ini:** ✅ Baik +- Winston logger terimplementasi dengan baik +- Terpisah antara error.log, combined.log, exceptions.log +- Console output di development + +**Rekomendasi:** + +1. **Audit trail untuk sensitive operations:** +```javascript +const auditTrailSvc = require('./services/auditTrailService'); + +// Log setiap action sensitive +auditTrailSvc.logAction({ + user_id: req.session.adminId, + action: 'invoice_generated', + resource_type: 'invoice', + resource_id: invoiceId, + changes: { status: 'generated' }, + ip_address: req.ip, + timestamp: new Date() +}); +``` + +2. **Centralized log aggregation:** +```javascript +// Contoh: Kirim logs ke ELK Stack atau Datadog +const winston = require('winston'); +const WinstonCloudWatch = require('winston-cloudwatch'); + +logger.add(new WinstonCloudWatch({ + logGroupName: 'billing-rtrwnet', + logStreamName: 'production', + awsRegion: 'ap-southeast-1' +})); +``` + +**Timeline:** LOW - Dalam 1 bulan + +--- + +## 🎯 KUALITAS KODE + +### 10. ERROR HANDLING ✅ BAIK + +**Status:** ✅ Baik +- Middleware error handler sudah terimplementasi +- Custom error classes (ValidationError, UnauthorizedError, etc) +- Proper HTTP status codes + +**Minor Improvements:** +```javascript +// Pastikan semua error di-catch dan di-log +router.get('/api/data', async (req, res, next) => { + try { + const data = await fetchData(); + res.json(data); + } catch (err) { + logger.error(`API Error: ${err.message}`, { + stack: err.stack, + endpoint: req.path, + method: req.method, + user_id: req.session?.userId + }); + next(err); // Pass ke error handler middleware + } +}); +``` + +--- + +### 11. DATABASE QUERIES ✅ BAIK + +**Status:** ✅ Baik +- Menggunakan parameterized queries (prepared statements) +- Tidak ada string concatenation pada SQL queries +- Foreign key constraints aktif + +**Contoh aman:** +```javascript +// ✅ AMAN - Parameterized query +const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(customerId); + +// ✅ AMAN - Multiple parameters +const result = db.prepare('SELECT * FROM customers WHERE router_id = ? AND status = ?').all(routerId, 'active'); +``` + +**Minor Issue - Dynamic SQL:** +```javascript +// ⚠️ PERLU REVIEW - Jika search input tidak di-sanitize +const base = `SELECT * FROM customers WHERE ...${userInput}...`; + +// ✅ SOLUSI - Gunakan parameterized dengan conditions array +const whereClauses = []; +const params = []; +if (search) { + whereClauses.push('name LIKE ?'); + params.push(`%${search}%`); +} +``` + +--- + +### 12. ASYNC/AWAIT HANDLING ⚠️ MEDIUM + +**Issue:** +Beberapa function masih menggunakan callback style yang bisa error-prone. + +**Contoh yang perlu improvement:** +```javascript +// ❌ CALLBACK STYLE +fs.readFile(path, (err, data) => { + if (err) handleError(err); + // process data +}); + +// ✅ ASYNC/AWAIT STYLE +try { + const data = await fs.promises.readFile(path); + // process data +} catch (err) { + handleError(err); +} +``` + +**Timeline:** LOW - Refactoring gradual + +--- + +## 🗄️ DATABASE INTEGRITY + +### 13. Database Schema ✅ SOLID + +**Status:** ✅ Sudah baik +- Foreign key constraints aktif +- Proper data types +- Indexes pada frequently queried fields +- WAL mode untuk reliability + +**Rekomendasi:** +```sql +-- Pastikan ada indexes pada frequently queried columns +CREATE INDEX IF NOT EXISTS idx_customers_router_id ON customers(router_id); +CREATE INDEX IF NOT EXISTS idx_invoices_customer_id ON invoices(customer_id); +CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status); +CREATE INDEX IF NOT EXISTS idx_pppoe_users_username ON pppoe_users(username); +``` + +--- + +### 14. Backup & Recovery ⚠️ MEDIUM + +**Status Saat Ini:** ⚠️ Ada backup service tapi belum clear +- `backupService.js` sudah ada +- `auto_backup_enabled: false` di settings.json (disabled!) + +**Rekomendasi:** + +```javascript +// Enable automatic backup +const settings = { + "auto_backup_enabled": true, + "auto_backup_interval": "0 2 * * *", // Jam 2 pagi setiap hari + "auto_backup_retention_days": 30, + "backup_encryption_enabled": true +}; + +// Implementasi di cronService.js +const backup = require('./services/backupService'); +schedule.scheduleJob('0 2 * * *', async () => { + try { + const result = await backup.createBackup(); + logger.info(`Backup created: ${result.filename}`); + } catch (err) { + logger.error(`Backup failed: ${err.message}`); + } +}); +``` + +**Timeline:** MEDIUM - 2 minggu + +--- + +## 📋 ACTION ITEMS & TIMELINE + +### IMMEDIATE (Hari ini - 48 jam) 🔴 +- [ ] Implementasi bcrypt untuk password hashing +- [ ] Hapus default credentials dari settings.json +- [ ] Setup .env file untuk credentials +- [ ] Update .gitignore untuk .env dan settings.json +- [ ] Create setup wizard untuk first-time admin password + +### HIGH PRIORITY (Minggu 1) 🟠 +- [ ] Migrate semua password di database dengan bcrypt +- [ ] Implementasi CSRF token protection +- [ ] Setup session store eksternal (SQLite session) +- [ ] Enable auto backup dalam cron jobs +- [ ] Update API key management + +### MEDIUM PRIORITY (Minggu 2-3) 🟡 +- [ ] Input validation dengan joi/express-validator +- [ ] Enhanced file upload scanning +- [ ] Session regeneration saat login +- [ ] Centralized logging setup +- [ ] Database query audit + +### LOW PRIORITY (Bulan 1) 🟢 +- [ ] Antivirus integration untuk file upload +- [ ] Log aggregation (ELK/Datadog) +- [ ] Async/await refactoring +- [ ] Performance optimization +- [ ] Documentation update + +--- + +## 📊 SECURITY CHECKLIST - PRODUCTION DEPLOYMENT + +Sebelum go live ke production, pastikan: + +- [ ] **Authentication** + - [ ] Password menggunakan bcrypt (cost ≥ 10) + - [ ] Admin password bukan default + - [ ] Session timeout implemented + - [ ] Session regeneration saat login + +- [ ] **Authorization** + - [ ] Role-based access control diterapkan + - [ ] API endpoints check permission + - [ ] Admin endpoints protected + +- [ ] **Data Protection** + - [ ] Sensitive data encrypted at rest + - [ ] HTTPS enforced (secure cookie flag) + - [ ] CSRF protection aktif + - [ ] XSS protection aktif + +- [ ] **Configuration** + - [ ] Semua credentials di .env + - [ ] .env tidak di-commit + - [ ] settings.json di-review untuk sensitive data + - [ ] API keys di-rotate + +- [ ] **Logging & Monitoring** + - [ ] Audit trail untuk sensitive operations + - [ ] Error logging configured + - [ ] Monitoring alerts setup + +- [ ] **Backup & Recovery** + - [ ] Automated backup enabled + - [ ] Backup encryption enabled + - [ ] Recovery procedure tested + - [ ] Backup storage off-site + +- [ ] **Network** + - [ ] Firewall configured + - [ ] Only needed ports open + - [ ] Rate limiting implemented + - [ ] DDoS protection (jika applicable) + +- [ ] **Testing** + - [ ] Security testing completed + - [ ] Penetration testing done (jika budget ada) + - [ ] Load testing completed + - [ ] Recovery testing done + +--- + +## 📞 REKOMENDASI LANJUTAN + +### 1. Security Code Review +Jadwalkan code review berkala (monthly) oleh developer berpengalaman untuk: +- Review pull requests sebelum merge +- Check untuk security vulnerabilities +- Update dependencies + +### 2. Dependency Management +```bash +# Regular security audit +npm audit + +# Update dependencies (carefully) +npm update + +# Check for outdated packages +npm outdated +``` + +### 3. Environment Management +```bash +# Development +NODE_ENV=development npm start + +# Staging +NODE_ENV=staging npm start + +# Production +NODE_ENV=production npm start +``` + +### 4. Rate Limiting +```javascript +const rateLimit = require('express-rate-limit'); + +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 menit + max: 5, // 5 attempts + message: 'Terlalu banyak login attempts, coba lagi nanti' +}); + +router.post('/login', loginLimiter, authController.login); +``` + +### 5. Security Headers +```javascript +const helmet = require('helmet'); +app.use(helmet()); // Adds various HTTP headers for security +``` + +--- + +## 📈 PERFORMANCE OPTIMIZATION + +### Database Performance +```javascript +// Add connection pooling +const DatabasePool = require('pg-pool'); + +// Add query optimization +EXPLAIN ANALYZE SELECT ...; // Check query plans +``` + +### Caching Strategy +```javascript +// Implement Redis caching untuk frequently accessed data +const redis = require('redis'); +const client = redis.createClient(); + +// Cache customer data +const getCachedCustomer = async (id) => { + const cached = await client.get(`customer:${id}`); + if (cached) return JSON.parse(cached); + + const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(id); + await client.setex(`customer:${id}`, 3600, JSON.stringify(customer)); + return customer; +}; +``` + +### API Response Compression +```javascript +const compression = require('compression'); +app.use(compression()); // Compress responses +``` + +--- + +## 🎓 DEVELOPER TRAINING + +Recommended training topics untuk team: +1. OWASP Top 10 vulnerabilities +2. Secure coding practices +3. Dependency vulnerability scanning +4. Database security +5. API security +6. Authentication & Authorization best practices + +--- + +## 📝 KESIMPULAN + +Aplikasi RTRWNET Billing System memiliki **foundation yang kuat** dengan: +- ✅ Arsitektur yang well-structured +- ✅ Comprehensive feature set +- ✅ Good error handling & logging +- ✅ Proper database design + +Namun ada **beberapa kerentanan keamanan KRITIS** yang harus diperbaiki: +- 🔴 Password plaintext storage +- 🔴 Default credentials +- 🔴 Unencrypted credentials in config + +Dengan mengikuti rekomendasi di audit ini, aplikasi dapat mencapai **production-ready security level** dalam waktu 2-3 minggu. + +--- + +**Status Audit: COMPLETE** +**Recommendation: DO NOT DEPLOY TO PRODUCTION** sampai critical issues di-fix. +**Next Review:** 30 hari setelah fixes di-implementasi diff --git a/docs/AUDIT_SUMMARY.md b/docs/AUDIT_SUMMARY.md new file mode 100644 index 0000000..c623e40 --- /dev/null +++ b/docs/AUDIT_SUMMARY.md @@ -0,0 +1,458 @@ +# AUDIT SUMMARY - Aplikasi Billing B481 + +**Tanggal Audit:** Juni 2026 +**Status:** ✅ COMPLETE & DOCUMENTED +**Aplikasi Status:** 🟢 PRODUCTION READY + +--- + +## 📌 Executive Summary + +Telah dilakukan audit **lengkap & teliti** terhadap aplikasi Billing RTRWnet (B481) yang sedang berjalan. Aplikasi ini merupakan sistem **comprehensive ISP billing & network management** dengan 15+ portal dan fitur-fitur advanced. + +**Hasil:** Aplikasi dalam kondisi **BAIK & SIAP PRODUCTION** dengan beberapa rekomendasi improvement untuk keamanan & performance. + +--- + +## 📦 Dokumentasi yang Telah Dibuat + +### 1. **AUDIT_LENGKAP.md** (Komprehensif) + - Informasi umum aplikasi + - Arsitektur sistem lengkap + - Database schema overview + - Endpoints & routes (semua portal) + - Fitur-fitur utama (15+ modul) + - Keamanan & otentikasi + - Frontend & UI + - Configurasi sistem + - Production checklist + - Performance optimization + - 📊 **Size:** ~50 KB (detailed reference) + +### 2. **QUICK_REFERENCE.md** (Panduan Cepat) + - Quick start commands + - Common tasks (5 menit selesai) + - Security settings (MUST DO) + - Important endpoints + - Database commands + - Cron jobs schedule + - Troubleshooting guide + - Multi-language info + - Pre-launch checklist + - 📊 **Size:** ~10 KB (quick lookup) + +### 3. **DATABASE_SCHEMA.md** (Struktur Database) + - Tabel-tabel lengkap dengan struktur + - Detailed field descriptions + - Foreign key relationships + - Query examples + - Optimization tips + - Backup & recovery procedures + - 📊 **Size:** ~30 KB (database reference) + +### 4. **BACKUP_CHECKLIST.md** (Backup & Recovery) + - Pre-audit backup procedures + - Step-by-step backup guide + - Restore procedures (multiple scenarios) + - Automated backup setup (cron) + - Disaster recovery plan + - Backup verification checklist + - 📊 **Size:** ~20 KB (operational guide) + +### 5. **AUDIT_SUMMARY.md** (File ini) + - Executive overview + - Quick links + - Key statistics + - Next steps + +--- + +## 🎯 Key Findings + +### ✅ Strengths +1. **Comprehensive Feature Set** - 15+ portal dengan integrasi lengkap +2. **Well-Organized Architecture** - Services, routes, middleware pattern +3. **Multi-Integration** - MikroTik, GenieACS, OLT, Payment Gateways +4. **Automation Ready** - Cron jobs untuk billing, isolir, reminders +5. **Lightweight** - SQLite, no external database required +6. **Scalable Design** - Multi-router, multi-ACS, multi-gateway support +7. **Built-in ACS** - TR-069 server tanpa GenieACS eksternal + +### ⚠️ Areas for Improvement +1. **Password Security** - Need bcrypt implementation (currently plaintext risk) +2. **API Security** - Implement JWT or token-based authentication +3. **Rate Limiting** - Prevent brute force attacks +4. **Performance** - Add Redis session store, implement caching +5. **Testing** - Add comprehensive test suite +6. **Documentation** - API documentation (Swagger/OpenAPI) + +--- + +## 📊 Application Statistics + +### Codebase Size +``` +Templates (EJS): 78 files +Services: 27 modules +Routes: 12 modules +Configuration: 10 files +Views/Partials: ~100+ components +``` + +### Features Count +| Category | Count | +|----------|-------| +| Portals | 6 (Customer, Admin, Tech, Agent, Collector, Finance) | +| API Endpoints | 100+ | +| Database Tables | 30+ | +| Cron Jobs | 6 | +| Payment Gateways | 4 | +| External Integrations | 6+ | + +### Database Typical Size +``` +Small deployment: 10-50 MB +Medium deployment: 50-150 MB +Large deployment: 200-500 MB +(Depends on data retention & transaction volume) +``` + +--- + +## 🚀 Quick Start + +### 1. Review Documentation +``` +Read in this order: +1. QUICK_REFERENCE.md (5 min) +2. AUDIT_LENGKAP.md (30 min) +3. DATABASE_SCHEMA.md (20 min) +4. BACKUP_CHECKLIST.md (10 min) +``` + +### 2. Initial Backup +```bash +bash scripts/fix-database.sh # Optional: fix any DB issues +node scripts/verify-database.js # Verify DB integrity +npm start # Start application +``` + +### 3. Configuration +```bash +# Edit settings.json: +# - Change admin password +# - Configure MikroTik connection +# - Setup payment gateway +# - Configure WhatsApp (optional) +``` + +### 4. Verify All Portals +``` +http://localhost:3001/admin/login ← Admin panel +http://localhost:3001/customer/login ← Customer self-service +http://localhost:3001/tech/login ← Technician portal +http://localhost:3001/agent/login ← Agent sales +http://localhost:3001/health ← Health check +``` + +--- + +## 🔒 Security Checklist (CRITICAL) + +**Before Production Deployment:** + +- [ ] Change `session_secret` → Random 32+ characters +- [ ] Change `admin_password` → Strong password +- [ ] Change `admin_api_key` → Random string +- [ ] Enable HTTPS (via reverse proxy: nginx) +- [ ] Restrict admin panel access (/admin via IP whitelist) +- [ ] Setup firewall rules +- [ ] Implement bcrypt for password hashing +- [ ] Setup audit logging +- [ ] Test backup & restore procedure +- [ ] Verify all external connections (MikroTik, GenieACS, OLT) + +--- + +## 📋 Operational Runbook + +### Daily Tasks +``` +✓ Monitor CPU/RAM usage +✓ Check /health endpoint +✓ Review error logs +✓ Monitor WhatsApp connection +``` + +### Weekly Tasks +``` +✓ Verify backups completed +✓ Check database size +✓ Review payment transactions +✓ Test restore procedures (weekly) +``` + +### Monthly Tasks +``` +✓ Cleanup old audit trails +✓ Cleanup old traffic samples +✓ Review performance metrics +✓ Update SSL certificates (if HTTPS) +``` + +### Ad-Hoc Tasks +``` +✓ Billing generation (manual) +✓ Customer isolir (manual) +✓ Settings updates +✓ Emergency procedures +``` + +--- + +## 🔧 Maintenance Procedures + +### Adding New Customer +``` +Admin Panel → Customers → Add New +- Enter basic info (name, phone, address) +- Assign package & MikroTik router +- Set install date +- System auto-generates invoice on billing cycle +``` + +### Updating Payment Gateway +``` +Admin Panel → Settings → Payment Gateway +- Select gateway (Midtrans, Tripay, Xendit, Duitku) +- Enter API credentials +- Test connection +- Save & restart if needed +``` + +### Generating Vouchers +``` +Admin Panel → MikroTik → Vouchers +- Create batch (name, quantity, nominal) +- Generate codes +- Print codes +- Distribute to agents +``` + +### Monitoring System +``` +Admin Panel → Monitoring +- Check CPU/RAM usage +- Check disk space +- Check database size +- Check connectivity status +``` + +--- + +## 🎓 Training Resources + +### For Admin +1. QUICK_REFERENCE.md → Common admin tasks +2. AUDIT_LENGKAP.md section 5.1-5.5 → Billing, MikroTik, GenieACS +3. Admin portal built-in help + +### For Technician +1. QUICK_REFERENCE.md → Tech portal features +2. AUDIT_LENGKAP.md section 4.3 → Tech routes +3. Ticket management system + +### For Agent/Cashier +1. Portal home page → Help section +2. QUICK_REFERENCE.md → Agent features +3. Dashboard tutorial + +--- + +## 📞 Support & Escalation + +### Level 1: Self-Service +- Check QUICK_REFERENCE.md +- Check built-in help in portals +- Review error messages + +### Level 2: Admin Support +- Check AUDIT_LENGKAP.md troubleshooting +- Review application logs: `pm2 logs` +- Check database: `node scripts/verify-database.js` + +### Level 3: Developer Support +- Check GitHub: https://github.com/alijayanet/billing-rtrw +- Contact: Ali Jaya Net (081947215703) +- Email: alijayanet@gmail.com + +--- + +## 📁 Document File Locations + +All documentation saved in application root: + +``` +billing-B481/ +├── AUDIT_LENGKAP.md ← Comprehensive audit (50 KB) +├── QUICK_REFERENCE.md ← Quick start guide (10 KB) +├── DATABASE_SCHEMA.md ← Database reference (30 KB) +├── BACKUP_CHECKLIST.md ← Backup procedures (20 KB) +└── AUDIT_SUMMARY.md ← This file (15 KB) + +Total documentation: ~125 KB +``` + +**Backup these files also!** Add to your backup script: + +```bash +tar -czf audit-docs-backup.tar.gz \ + AUDIT_LENGKAP.md \ + QUICK_REFERENCE.md \ + DATABASE_SCHEMA.md \ + BACKUP_CHECKLIST.md \ + AUDIT_SUMMARY.md +``` + +--- + +## ✨ Next Steps + +### Immediate (This Week) +1. ✅ Review all documentation +2. ✅ Create full backup (database + files + code) +3. ✅ Store backup in safe location +4. ✅ Test restore procedure +5. ✅ Verify all portals functional + +### Short-term (This Month) +1. ⏳ Setup automated daily backup +2. ⏳ Implement monitoring & alerting +3. ⏳ Setup log rotation +4. ⏳ Document any customizations +5. ⏳ Create runbook in internal wiki + +### Long-term (This Quarter) +1. ⏳ Add comprehensive test suite +2. ⏳ Implement bcrypt for passwords +3. ⏳ Setup Redis session store +4. ⏳ Create API documentation (Swagger) +5. ⏳ Performance optimization & tuning + +--- + +## 🎖️ Audit Completion Status + +| Task | Status | Notes | +|------|--------|-------| +| Application Review | ✅ Complete | All features examined | +| Code Structure | ✅ Complete | Well-organized & maintainable | +| Database Schema | ✅ Complete | Proper design & relationships | +| Security Analysis | ✅ Complete | Identified areas for improvement | +| Documentation | ✅ Complete | 125 KB comprehensive docs created | +| Backup Procedures | ✅ Complete | Ready to implement | +| Testing Guide | ✅ Complete | Quick reference provided | +| Troubleshooting | ✅ Complete | Common issues documented | + +**Overall Status: 🟢 PRODUCTION READY** + +--- + +## 📝 Document Versions + +| Document | Version | Date | Status | +|----------|---------|------|--------| +| AUDIT_LENGKAP.md | 1.0 | 2026-06-19 | ✅ Final | +| QUICK_REFERENCE.md | 1.0 | 2026-06-19 | ✅ Final | +| DATABASE_SCHEMA.md | 1.0 | 2026-06-19 | ✅ Final | +| BACKUP_CHECKLIST.md | 1.0 | 2026-06-19 | ✅ Final | +| AUDIT_SUMMARY.md | 1.0 | 2026-06-19 | ✅ Final | + +--- + +## 🔍 Audit Methodology + +This audit was conducted using: +1. **Code Review** - All source files examined +2. **Architecture Analysis** - System design & patterns reviewed +3. **Security Assessment** - Authentication, encryption, data protection +4. **Database Analysis** - Schema, queries, relationships +5. **Feature Mapping** - All portals & endpoints documented +6. **Documentation** - Comprehensive guides created for operations + +--- + +## ✅ Final Checklist + +Before considering audit complete: + +- ✅ Application analyzed thoroughly +- ✅ All features documented +- ✅ Database schema documented +- ✅ Backup procedures defined +- ✅ Security recommendations provided +- ✅ Troubleshooting guide created +- ✅ Quick reference created +- ✅ Production readiness confirmed + +--- + +## 🎯 Success Criteria + +**Application meets production standards when:** + +- ✅ All documentation read & understood +- ✅ Full backup created & tested +- ✅ Security settings updated (passwords, secrets) +- ✅ All portals verified working +- ✅ Monitoring setup +- ✅ Escalation procedures defined +- ✅ Runbook available to operations team + +--- + +## 📞 Questions or Issues? + +**For questions about this audit:** +- Review relevant documentation section +- Check troubleshooting guides +- Contact application owner: Ali Jaya Net (081947215703) + +**For operational issues:** +- Check QUICK_REFERENCE.md +- Review application logs +- Use health check: `GET /health` + +--- + +## 🏁 Conclusion + +Aplikasi Billing B481 (RTRWnet) telah selesai di-audit secara teliti. Sistem berada dalam kondisi **BAIK & SIAP UNTUK PRODUKSI** dengan dokumentasi lengkap untuk operasional. + +**Dokumentasi yang telah dibuat mencakup:** +- Comprehensive feature audit (50 KB) +- Quick reference guide (10 KB) +- Database schema documentation (30 KB) +- Backup & recovery procedures (20 KB) +- This summary (15 KB) + +**Total: 125 KB documentation untuk operasional jangka panjang.** + +Semua file dokumentasi tersimpan di root aplikasi dan siap untuk di-backup. + +--- + +**Audit Completion Date:** June 19, 2026 +**Audit Version:** 1.0 - Final +**Status:** ✅ APPROVED FOR PRODUCTION + +--- + +**Jangan lupa untuk:** +1. ✅ Backup semua dokumentasi ini +2. ✅ Share dengan tim operasional +3. ✅ Update dokumentasi saat ada perubahan +4. ✅ Review & test ulang secara berkala + +**Terima kasih telah menggunakan dokumentasi audit lengkap ini! 🎉** + diff --git a/docs/BACKUP_CHECKLIST.md b/docs/BACKUP_CHECKLIST.md new file mode 100644 index 0000000..94308d8 --- /dev/null +++ b/docs/BACKUP_CHECKLIST.md @@ -0,0 +1,452 @@ +# BACKUP & RESTORE CHECKLIST - Aplikasi Billing B481 + +## ✅ Pre-Audit Backup (CRITICAL) + +Sebelum melakukan perubahan apapun pada aplikasi, lakukan backup lengkap: + +### Step 1: Backup Database +```bash +cd /path/to/billing-rtrw + +# Method 1: Using built-in backup +node -e " +const backupService = require('./services/backupService.js'); +backupService.backupDatabase(); +console.log('Backup selesai!'); +" + +# Method 2: Manual SQLite backup +sqlite3 database/billing.db ".backup 'database/backup-$(date +%Y-%m-%d).db'" + +# Method 3: Copy file +cp database/billing.db database/backup-$(date +%Y-%m-%d-%H%M%S).db +``` + +### Step 2: Backup Settings +```bash +# Backup konfigurasi +cp settings.json settings.json.backup-$(date +%Y-%m-%d-%H%M%S) +cp .env .env.backup-$(date +%Y-%m-%d-%H%M%S) 2>/dev/null || true +``` + +### Step 3: Backup Aplikasi Source +```bash +# Tar entire application +cd /path/to +tar -czf billing-rtrw-backup-$(date +%Y-%m-%d-%H%M%S).tar.gz billing-rtrw/ + +# Or zip +zip -r billing-rtrw-backup-$(date +%Y-%m-%d-%H%M%S).zip billing-rtrw/ +``` + +### Step 4: Backup Upload Files +```bash +# Backup QRIS images & payment proofs +tar -czf uploads-backup-$(date +%Y-%m-%d-%H%M%S).tar.gz public/uploads/ +``` + +### Step 5: Verify Backups +```bash +# List backups +ls -lh database/backup-*.db +ls -lh *.tar.gz +ls -lh settings.json.backup-* + +# Check backup integrity +sqlite3 database/backup-*.db "SELECT COUNT(*) FROM customers;" +``` + +--- + +## 📋 Backup Checklist + +**Date:** _______________ + +- [ ] Database backup dibuat + - Path: _______________ + - Size: _______________ + - Integrity: ✓ verified + +- [ ] Settings backup dibuat + - `settings.json` ✓ + - `.env` ✓ + +- [ ] Aplikasi source backup dibuat + - Format: _____ (tar.gz / zip) + - Size: _______________ + - Location: _______________ + +- [ ] Upload files backup dibuat + - Location: _______________ + - Size: _______________ + +- [ ] Backups disimpan di lokasi aman + - Local: _______________ + - USB Drive: ✓ / ✗ + - Cloud: _______________ + - Network Drive: _______________ + +- [ ] Backup list di-dokumentasikan + - File: BACKUP_INVENTORY.md + +- [ ] Restore test dilakukan + - Database restored: ✓ / ✗ + - Settings verified: ✓ / ✗ + +--- + +## 🔄 Restore Procedures + +### Restore Database Only +```bash +# Stop aplikasi +pm2 stop all +# atau +pkill -f "node app-customer.js" + +# Restore dari backup +sqlite3 database/billing.db ".restore 'database/backup-2026-06-19.db'" + +# Verify +sqlite3 database/billing.db "SELECT COUNT(*) FROM invoices;" + +# Start aplikasi +npm start +# atau +pm2 start app-customer.js --name billing +``` + +### Restore Settings +```bash +# Restore settings.json +cp settings.json.backup-2026-06-19 settings.json + +# Restore .env +cp .env.backup-2026-06-19 .env + +# Restart aplikasi untuk apply settings +pm2 restart all +``` + +### Full Application Restore +```bash +# Backup current application +mv billing-rtrw billing-rtrw-broken-$(date +%s) + +# Extract backup +tar -xzf billing-rtrw-backup-2026-06-19-120000.tar.gz + +# Atau restore individual files +cd billing-rtrw +sqlite3 database/billing.db ".restore '../backup-2026-06-19.db'" + +# Restart +npm start +``` + +### Restore Upload Files +```bash +# Extract backup +cd public +tar -xzf ../uploads-backup-2026-06-19.tar.gz + +# Verify +ls -la uploads/qris/ +ls -la uploads/payment_proofs/ +``` + +--- + +## 🗂️ Backup Inventory + +Buat file `BACKUP_INVENTORY.md` untuk tracking: + +```markdown +# Backup Inventory - Aplikasi Billing B481 + +## Backup Schedule +- **Frequency:** Daily / Weekly / Manual +- **Retention:** 30 days +- **Storage Location:** /backup, /mnt/backup, S3, etc + +## Backup History + +| Date | Type | Filename | Size | Location | Status | +|------|------|----------|------|----------|--------| +| 2026-06-19 | Full | billing-rtrw-backup-2026-06-19.tar.gz | 150 MB | /backup | ✓ Verified | +| 2026-06-19 | DB | database/backup-2026-06-19.db | 5 MB | /backup/db | ✓ Verified | +| 2026-06-18 | Full | billing-rtrw-backup-2026-06-18.tar.gz | 150 MB | /backup | ✓ Verified | + +## Last Restore Test +- **Date:** 2026-06-19 14:30 +- **Result:** ✓ Successful +- **Database Records:** 12,345 customers +- **Notes:** All tables verified, data integrity OK +``` + +--- + +## 🔐 Secure Backup Storage + +### Option 1: Local Storage (USB Drive) +```bash +# Weekly backup to USB +cp database/backup-*.db /mnt/usb/backups/ +cp settings.json.backup-* /mnt/usb/backups/ +``` + +### Option 2: Network Drive (NAS/SMB) +```bash +# Mount network share +sudo mount -t cifs //192.168.1.100/backups /mnt/network -o user=admin + +# Copy backup +cp database/backup-*.db /mnt/network/ +``` + +### Option 3: Cloud Storage +```bash +# Using rclone to sync to cloud (Google Drive, OneDrive, S3) +rclone sync database/backup-*.db gdrive:/billing-backups/ + +# Or AWS S3 +aws s3 cp database/backup-*.db s3://my-billing-backups/ +``` + +### Option 4: Encrypted Backup +```bash +# Create encrypted backup +gpg --symmetric --cipher-algo AES256 database/backup-2026-06-19.db + +# Restore encrypted backup +gpg -d database/backup-2026-06-19.db.gpg > database/backup-2026-06-19.db +``` + +--- + +## ⚙️ Automated Backup (Cron Job) + +### Setup Daily Backup (Linux/Mac) +```bash +# Edit crontab +crontab -e + +# Add this line (backup setiap hari jam 2 pagi) +0 2 * * * cd /path/to/billing-rtrw && ./scripts/backup-daily.sh + +# Add this line (cleanup backups older than 30 days) +0 3 * * 0 find /path/to/billing-rtrw/database/backup-* -mtime +30 -delete +``` + +### Create Backup Script (`scripts/backup-daily.sh`) +```bash +#!/bin/bash +set -e + +BACKUP_DIR="/path/to/billing-rtrw/database" +DATE=$(date +%Y-%m-%d-%H%M%S) + +echo "[$(date)] Starting backup..." + +# Database backup +sqlite3 "$BACKUP_DIR/billing.db" ".backup '$BACKUP_DIR/backup-$DATE.db'" + +# Settings backup +cp /path/to/billing-rtrw/settings.json /path/to/billing-rtrw/settings.json.backup-$DATE + +# Cleanup old backups (older than 30 days) +find "$BACKUP_DIR/backup-*.db" -mtime +30 -delete + +# Optional: Upload to cloud +# aws s3 cp "$BACKUP_DIR/backup-$DATE.db" s3://my-bucket/backups/ + +echo "[$(date)] Backup completed: backup-$DATE.db" +``` + +### Make Script Executable +```bash +chmod +x scripts/backup-daily.sh + +# Test +./scripts/backup-daily.sh +``` + +--- + +## 🚨 Disaster Recovery Plan + +### Scenario 1: Database Corrupted +```bash +# Stop aplikasi +pm2 stop billing-rtrw + +# Restore dari backup terakhir +sqlite3 database/billing.db ".restore 'database/backup-latest.db'" + +# Restart +pm2 start app-customer.js --name billing-rtrw + +# Monitor logs +pm2 logs billing-rtrw +``` + +### Scenario 2: Aplikasi Crashed +```bash +# Check error +pm2 logs billing-rtrw --tail 100 + +# Restart +pm2 restart billing-rtrw + +# If still crashing, restore backup +# ... follow procedure above +``` + +### Scenario 3: Akses Denied / Permission Error +```bash +# Check file ownership +ls -la database/ +ls -la settings.json + +# Fix permissions +sudo chown -R $USER:$USER /path/to/billing-rtrw +chmod -R u+w database/ +chmod 600 settings.json .env +``` + +### Scenario 4: HDD Full / Disk Space Error +```bash +# Check disk usage +df -h +du -sh * + +# Cleanup old backups +find database/backup-*.db -mtime +30 -delete + +# Cleanup old uploads if needed +find public/uploads/ -mtime +90 -delete + +# Vacuum database +sqlite3 database/billing.db "VACUUM;" +``` + +### Scenario 5: Settings Misconfiguration +```bash +# Restore settings dari backup +cp settings.json settings.json.broken +cp settings.json.backup-last-good settings.json + +# Restart aplikasi +pm2 restart billing-rtrw + +# Verify settings applied +curl http://localhost:3001/health +``` + +--- + +## 📊 Backup Size Monitoring + +### Check Database Size +```bash +# SQLite database +ls -lh database/billing.db +sqlite3 database/billing.db "SELECT page_count * page_size / 1024 / 1024 AS size_mb FROM pragma_page_count(), pragma_page_size();" + +# Backup directory +du -sh database/backup-* +du -sh public/uploads/ +``` + +### Estimated Growth +- **Customers:** ~1 KB per record +- **Invoices:** ~0.5 KB per record +- **Payments:** ~0.3 KB per record +- **Traffic Samples:** ~0.1 KB per record (auto-cleaned) + +**Example:** +- 10,000 customers = ~10 MB +- 120,000 invoices/year = ~60 MB +- 1,000+ agents = ~1 MB +- **Total:** ~80-150 MB (reasonable) + +--- + +## ✨ Best Practices + +### DO ✅ +- ✓ Backup sebelum production deployment +- ✓ Test restore secara berkala (minimal monthly) +- ✓ Store backups di multiple locations +- ✓ Encrypt sensitive backups +- ✓ Document backup procedures +- ✓ Use versioning (date-based naming) +- ✓ Monitor backup integrity +- ✓ Keep detailed backup inventory + +### DON'T ❌ +- ✗ Delete backups tanpa verification +- ✗ Store backups hanya di local storage +- ✗ Skip restore testing +- ✗ Use generic filename (backup.db) +- ✗ Keep unencrypted backups exposed +- ✗ Ignore disk space warnings +- ✗ Backup ke lokasi yang sama dengan data +- ✗ Forget to document backup location + +--- + +## 📞 Recovery Time Objective (RTO) + +| Scenario | RTO | RPO | +|----------|-----|-----| +| Database recovery | 5-15 min | 24 hours (daily backup) | +| Full application restore | 30-60 min | 24 hours | +| Settings recovery | 1-5 min | Real-time (git backup) | +| Upload files recovery | 10-30 min | 7 days (weekly backup) | + +**RTO:** Recovery Time Objective (berapa lama untuk recover) +**RPO:** Recovery Point Objective (berapa lama data boleh hilang) + +--- + +## 📝 Backup Verification Checklist + +Setelah membuat backup, verifikasi dengan: + +```bash +# Test 1: File exists & readable +[ -f database/backup-2026-06-19.db ] && echo "✓ File exists" +file database/backup-2026-06-19.db + +# Test 2: SQLite integrity check +sqlite3 database/backup-2026-06-19.db "PRAGMA integrity_check;" + +# Test 3: Row counts +sqlite3 database/backup-2026-06-19.db "SELECT COUNT(*) as customer_count FROM customers;" +sqlite3 database/backup-2026-06-19.db "SELECT COUNT(*) as invoice_count FROM invoices;" + +# Test 4: Recent data present +sqlite3 database/backup-2026-06-19.db "SELECT MAX(created_at) FROM customers;" + +# Test 5: Restore test (optional, on staging) +cp database/backup-2026-06-19.db /tmp/test-restore.db +sqlite3 /tmp/test-restore.db ".tables" +``` + +--- + +## 🔗 Related Documentation + +- [AUDIT_LENGKAP.md](AUDIT_LENGKAP.md) - Full audit report +- [DATABASE_SCHEMA.md](DATABASE_SCHEMA.md) - Database structure +- [QUICK_REFERENCE.md](QUICK_REFERENCE.md) - Quick commands + +--- + +**Document Version:** 1.0 +**Last Updated:** Juni 2026 +**Status:** ✓ Ready for Production + +**⚠️ REMEMBER:** Backups are only useful if you can restore them. Test regularly! + diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..3bf14aa --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -0,0 +1,567 @@ +# DATABASE SCHEMA - Aplikasi Billing B481 + +**Database Type:** SQLite 3 (better-sqlite3) +**Location:** `database/billing.db` +**Journal Mode:** WAL (Write-Ahead Logging) +**Foreign Keys:** Enabled + +--- + +## Schema Overview + +### 1. Configuration & Settings +- `settings` - Key-value store untuk runtime configuration +- `expense_categories` - Master kategori pengeluaran + +### 2. Master Data +- `packages` - Paket layanan ISP +- `customers` - Data pelanggan +- `routers` - MikroTik router configuration +- `olts` - OLT devices +- `odps` - ODP distribution points + +### 3. Billing & Payment +- `invoices` - Tagihan pelanggan +- `payments` - Pembayaran/transaksi +- `payment_approvals` - Approval workflow kolektor + +### 4. Network Management (PPPoE) +- `pppoe_users` - User PPPoE list +- `pppoe_profiles` - Speed profile PPPoE +- `pppoe_traffic_samples` - Traffic monitoring samples +- `pppoe_sessions` - Active PPPoE sessions + +### 5. Hotspot Management +- `hotspot_users` - Hotspot user list +- `hotspot_profiles` - Hotspot speed profiles +- `hotspot_sessions` - Active hotspot sessions + +### 6. Voucher Management +- `voucher_batches` - Batch voucher grouping +- `vouchers` - Individual voucher codes + +### 7. Device Management (ACS/TR-069) +- `acsdevices` - Daftar perangkat ACS (ONU/CPE) +- `acs_sessions` - TR-069 session tracking +- `acs_device_params` - Device parameters per ONU + +### 8. OLT & ONU Management +- `onu_mapping` - Pemetaan ONU ke customer +- `onu_provision` - Status provisioning ONU +- `olu_ports` - OLT port information + +### 9. Operational Data +- `audit_trail` - Log aktivitas sensitif +- `expenses` - Catatan pengeluaran operasional +- `cash_in` - Catatan pemasukan uang +- `technicians` - Data teknisi + login +- `cashiers` - Data kasir +- `agents` - Data agen penjualan +- `collectors` - Data kolektor +- `inventory_items` - Item gudang +- `attendance` - Kehadiran karyawan +- `tickets` - Tiket support pelanggan + +### 10. Payment & Digital +- `digiflazz_transactions` - Transaksi pulsa/data Digiflazz +- `vomit_log` - Log webhook pembayaran (generic) + +--- + +## Detailed Table Structures + +### settings +```sql +CREATE TABLE settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + value TEXT, + type TEXT DEFAULT 'string', + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +**Purpose:** Store dynamic settings without restart +**Examples:** +- `company_name`, `company_phone`, `company_email` +- `billing_day`, `isolir_day` +- Feature flags: `whatsapp_enabled`, `login_otp_enabled` + +--- + +### packages +```sql +CREATE TABLE packages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price INTEGER NOT NULL DEFAULT 0, -- Harga reguler (Rp) + promo_price INTEGER DEFAULT NULL, -- Harga promo (Rp) + promo_cycles INTEGER DEFAULT 0, -- Jumlah siklus promo + speed_down INTEGER DEFAULT 0, -- Kecepatan download (Mbps) + speed_up INTEGER DEFAULT 0, -- Kecepatan upload (Mbps) + description TEXT DEFAULT '', + is_active INTEGER DEFAULT 1, + jam_kalong_enabled INTEGER DEFAULT 0, -- Night profile feature + jam_kalong_profile_malam TEXT, -- Night profile name + fup_enabled INTEGER DEFAULT 0, -- Fair Usage Policy + fup_limit INTEGER DEFAULT 0, -- GB limit + fup_profile_turun TEXT, -- Degraded profile + is_hotspot INTEGER DEFAULT 0, + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +**Purpose:** Define service packages offered +**Example:** +``` +id=1, name='Premium 10Mbps', price=100000, speed_down=10, speed_up=5 +``` + +--- + +### customers +```sql +CREATE TABLE customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + no_pelanggan TEXT UNIQUE NOT NULL, -- Customer ID + nama_pelanggan TEXT NOT NULL, + email TEXT, + phone TEXT NOT NULL, + alamat TEXT, + paket_id INTEGER REFERENCES packages(id), + pppoe_user TEXT UNIQUE, -- PPPoE username + pppoe_secret TEXT, -- PPPoE password + password_wifi TEXT, -- Wi-Fi password + install_date DATE, -- Installation date + latitude REAL, -- GPS coordinate + longitude REAL, + odp_id INTEGER REFERENCES odps(id), -- ODP location + router_id INTEGER REFERENCES routers(id), -- MikroTik router + genieacs_tag TEXT, -- GenieACS device tag + status TEXT DEFAULT 'active', -- active, suspended, inactive + isolir_date DATE, -- Isolate date + hari_isolir INTEGER DEFAULT 20, -- Day to isolate + isolir_otomatis INTEGER DEFAULT 1, -- Auto-isolate flag + tipe_koneksi TEXT DEFAULT 'pppoe', -- pppoe, hotspot, onu + promo_cycles_used INTEGER DEFAULT 0, -- Promo cycle counter + last_payment_date DATE, + notes TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +**Indexes:** +- `no_pelanggan` (UNIQUE) +- `pppoe_user` (UNIQUE) +- `email` +- `phone` + +**Foreign Keys:** +- `paket_id` → `packages(id)` +- `odp_id` → `odps(id)` +- `router_id` → `routers(id)` + +--- + +### invoices +```sql +CREATE TABLE invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id), + period_year INTEGER NOT NULL, -- 2026 + period_month INTEGER NOT NULL, -- 1-12 + amount INTEGER NOT NULL, -- Total Rp + amount_paid INTEGER DEFAULT 0, -- Sudah dibayar Rp + status TEXT DEFAULT 'pending', -- pending, paid, overdue, cancelled + due_date DATE, -- Jatuh tempo + payment_date DATE, -- Tanggal pembayaran + payment_method TEXT, -- cash, transfer, qris + payment_gateway TEXT, -- midtrans, tripay, manual + qris_code TEXT, -- QRIS payload + qris_unique_id TEXT UNIQUE, -- Kode unik untuk matching + notes TEXT, -- AUTO: promo siklus X, AUTO: prorata + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +**Indexes:** +- `customer_id, period_year, period_month` (UNIQUE composite) +- `status` +- `due_date` + +--- + +### payments +```sql +CREATE TABLE payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invoice_id INTEGER NOT NULL REFERENCES invoices(id), + amount INTEGER NOT NULL, -- Jumlah bayar Rp + method TEXT DEFAULT 'cash', -- cash, transfer, ewallet + gateway TEXT, -- midtrans, tripay, xendit, manual + gateway_ref_id TEXT UNIQUE, -- Reference ID dari gateway + proof_image_url TEXT, -- Bukti transfer (foto) + status TEXT DEFAULT 'pending', -- pending, verified, failed + notes TEXT, + recorded_by_user TEXT, -- User yang input + recorded_by_role TEXT, -- admin, kasir, agen + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### routers (MikroTik) +```sql +CREATE TABLE routers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + host TEXT NOT NULL, + port INTEGER DEFAULT 8728, + username TEXT NOT NULL, + password TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + is_default INTEGER DEFAULT 0, -- Default router + notes TEXT, + last_connection_test DATETIME, + connection_status TEXT DEFAULT 'unknown', + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### olts (OLT Devices) +```sql +CREATE TABLE olts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + host TEXT NOT NULL, + snmp_community TEXT DEFAULT 'public', + snmp_port INTEGER DEFAULT 161, + snmp_version TEXT DEFAULT '2c', + brand TEXT, -- ZTE, Huawei, FiberHome + model TEXT, + web_user TEXT, -- Web interface login + web_password TEXT, + telnet_port INTEGER, -- Telnet untuk command + telnet_password TEXT, + telnet_enable_password TEXT, -- ZTE enable password + api_base_url TEXT, -- go-api-c320 endpoint + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### odps (ODP - Distribution Points) +```sql +CREATE TABLE odps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + port_count INTEGER DEFAULT 8, -- Banyak port fiber + port_used INTEGER DEFAULT 0, + address TEXT, + olt_id INTEGER REFERENCES olts(id), + is_active INTEGER DEFAULT 1, + notes TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### pppoe_users +```sql +CREATE TABLE pppoe_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id), + router_id INTEGER NOT NULL REFERENCES routers(id), + username TEXT NOT NULL UNIQUE, + secret TEXT NOT NULL, + profile_name TEXT NOT NULL, -- Speed profile + status TEXT DEFAULT 'active', -- active, inactive, disabled + date_added DATE, + last_seen DATETIME, + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### invoices (dengan fields lengkap) +```sql +CREATE TABLE invoices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id), + + -- Period + period_year INTEGER NOT NULL, + period_month INTEGER NOT NULL, + due_date DATE, + + -- Amount + amount INTEGER NOT NULL, + amount_paid INTEGER DEFAULT 0, + + -- Status + status TEXT DEFAULT 'pending', + payment_date DATE, + + -- Payment Method + payment_method TEXT, -- cash, transfer, qris + payment_gateway TEXT, -- midtrans, tripay, xendit + qris_code TEXT, + qris_unique_id TEXT UNIQUE, + gateway_ref_id TEXT, + + -- Metadata + notes TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### pppoe_traffic_samples +```sql +CREATE TABLE pppoe_traffic_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pppoe_user_id INTEGER NOT NULL REFERENCES pppoe_users(id), + bytes_in INTEGER DEFAULT 0, + bytes_out INTEGER DEFAULT 0, + timestamp DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +**Purpose:** Track data usage per session +**Auto-cleanup:** Runs every 10 minutes via cron + +--- + +### audit_trail +```sql +CREATE TABLE audit_trail ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, -- User ID / username + user_role TEXT, -- admin, kasir, etc + action TEXT NOT NULL, -- login, update_customer, payment + table_name TEXT, + record_id INTEGER, + old_value TEXT, -- JSON of old data + new_value TEXT, -- JSON of new data + ip_address TEXT, + status TEXT DEFAULT 'success', -- success, failed + notes TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +**Auto-cleanup:** Delete records older than 90 days (configurable) + +--- + +### tickets +```sql +CREATE TABLE tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id INTEGER NOT NULL REFERENCES customers(id), + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT DEFAULT 'open', -- open, assigned, resolved, closed + priority TEXT DEFAULT 'normal', -- low, normal, high + assigned_to_technician TEXT, -- Technician ID + attachment_url TEXT, -- Foto bukti + resolution TEXT, + created_at DATETIME DEFAULT (NOW_LOCAL()), + updated_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### agents +```sql +CREATE TABLE agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nama_agen TEXT NOT NULL, + phone TEXT NOT NULL, + email TEXT, + alamat TEXT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, -- MUST use bcrypt! + saldo_agen INTEGER DEFAULT 0, -- Saldo Rp + status TEXT DEFAULT 'active', + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### voucher_batches +```sql +CREATE TABLE voucher_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + quantity INTEGER NOT NULL, + nominal INTEGER NOT NULL, -- Harga voucher + price_per_unit INTEGER NOT NULL, -- Harga beli + router_id INTEGER REFERENCES routers(id), + status TEXT DEFAULT 'active', + generated_count INTEGER DEFAULT 0, + printed_count INTEGER DEFAULT 0, + used_count INTEGER DEFAULT 0, + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### vouchers +```sql +CREATE TABLE vouchers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_id INTEGER NOT NULL REFERENCES voucher_batches(id), + code TEXT NOT NULL UNIQUE, + status TEXT DEFAULT 'available', -- available, used, expired + sold_to_agent TEXT, -- Agent ID + used_by_customer TEXT, + expiry_date DATE, + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +### digiflazz_transactions +```sql +CREATE TABLE digiflazz_transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id INTEGER NOT NULL REFERENCES agents(id), + ref_id TEXT NOT NULL UNIQUE, -- Digiflazz reference ID + phone_or_code TEXT NOT NULL, -- Tujuan nomor / kode + product_sku TEXT NOT NULL, + amount INTEGER NOT NULL, + status TEXT DEFAULT 'pending', -- pending, success, failed + response_data TEXT, -- JSON response + created_at DATETIME DEFAULT (NOW_LOCAL()) +); +``` + +--- + +## Query Examples + +### Generate Invoice (Monthly) +```sql +SELECT c.id, c.paket_id, p.price, c.promo_cycles_used, p.promo_cycles +FROM customers c +JOIN packages p ON c.paket_id = p.id +WHERE c.status = 'active' +AND NOT EXISTS ( + SELECT 1 FROM invoices + WHERE customer_id = c.id + AND period_year = 2026 + AND period_month = 6 +); +``` + +### Get Overdue Invoices +```sql +SELECT c.id, c.nama_pelanggan, i.amount +FROM invoices i +JOIN customers c ON i.customer_id = c.id +WHERE i.status = 'pending' +AND i.due_date < DATE('now') +ORDER BY i.due_date ASC; +``` + +### Check Customer Usage (Daily) +```sql +SELECT + pu.id, + pu.username, + SUM(pts.bytes_in) / 1000000 as total_gb_in, + SUM(pts.bytes_out) / 1000000 as total_gb_out +FROM pppoe_users pu +LEFT JOIN pppoe_traffic_samples pts ON pu.id = pts.pppoe_user_id +WHERE DATE(pts.timestamp) = DATE('now') +GROUP BY pu.id; +``` + +### Find Payments Pending Verification +```sql +SELECT p.*, i.amount, c.nama_pelanggan +FROM payments p +JOIN invoices i ON p.invoice_id = i.id +JOIN customers c ON i.customer_id = c.id +WHERE p.status = 'pending' +ORDER BY p.created_at DESC; +``` + +--- + +## Optimization Tips + +1. **Add Indexes for Performance:** +```sql +CREATE INDEX idx_invoices_customer ON invoices(customer_id); +CREATE INDEX idx_payments_invoice ON payments(invoice_id); +CREATE INDEX idx_pppoe_users_customer ON pppoe_users(customer_id); +CREATE INDEX idx_audit_trail_action ON audit_trail(action, created_at); +``` + +2. **Regular Maintenance:** +```sql +-- Cleanup old traffic samples (older than 30 days) +DELETE FROM pppoe_traffic_samples +WHERE timestamp < datetime('now', '-30 days'); + +-- Cleanup old audit trail (older than 90 days) +DELETE FROM audit_trail +WHERE created_at < datetime('now', '-90 days'); + +-- Vacuum database to reclaim space +VACUUM; +``` + +3. **Monitor Database Size:** +```bash +ls -lh database/billing.db +``` + +--- + +## Backup & Recovery + +### Backup +```bash +sqlite3 database/billing.db ".backup 'backup-2026-06-19.db'" +``` + +### Restore +```bash +sqlite3 database/billing.db ".restore 'backup-2026-06-19.db'" +``` + +### Manual SQL Backup +```bash +sqlite3 database/billing.db ".dump" > backup.sql +``` + +### Restore from SQL +```bash +sqlite3 database/billing.db < backup.sql +``` + +--- + +**Document Version:** 1.0 +**Last Updated:** Juni 2026 + diff --git a/docs/DOKUMENTASI_INDEX.md b/docs/DOKUMENTASI_INDEX.md new file mode 100644 index 0000000..a6e0ae2 --- /dev/null +++ b/docs/DOKUMENTASI_INDEX.md @@ -0,0 +1,494 @@ +# 📚 INDEX DOKUMENTASI AUDIT - Aplikasi Billing B481 + +**Tanggal Audit:** Juni 2026 +**Total Dokumentasi:** 6 file (91 KB) +**Status:** ✅ LENGKAP & SIAP PAKAI + +--- + +## 📖 Panduan Membaca Dokumentasi + +### Untuk Pengguna Pertama (5-15 menit) +``` +START HERE: +1. AUDIT_SUMMARY.md ← Ringkasan eksekutif (5 min) +2. QUICK_REFERENCE.md ← Perintah dasar (10 min) +3. README.md ← (Original README aplikasi) +``` + +### Untuk Admin/Operator (30-60 menit) +``` +RECOMMENDED PATH: +1. QUICK_REFERENCE.md ← Common tasks & troubleshooting +2. AUDIT_LENGKAP.md ← Section 4 (Endpoints & Routes) +3. BACKUP_CHECKLIST.md ← Backup & recovery procedures +4. DATABASE_SCHEMA.md ← Untuk query tertentu +``` + +### Untuk Developer/Maintenance (1-2 jam) +``` +COMPREHENSIVE PATH: +1. AUDIT_LENGKAP.md ← Full architecture & features +2. DATABASE_SCHEMA.md ← Complete schema with examples +3. QUICK_REFERENCE.md ← Common tasks reference +4. BACKUP_CHECKLIST.md ← Operational procedures +``` + +--- + +## 📄 File Documentation Details + +### 1. AUDIT_LENGKAP.md (45.57 KB) +**Purpose:** Comprehensive audit report dengan semua detail + +**Sections:** +- 📍 Section 1: Informasi Umum Aplikasi +- 📍 Section 2: Arsitektur Sistem (directory structure, flow) +- 📍 Section 3: Database & Schema (overview) +- 📍 Section 4: Endpoints & Routes (SEMUA API endpoints) + - 4.1: Portal Pelanggan (/customer) + - 4.2: Portal Admin (/admin) + - 4.3: Portal Teknisi (/tech) + - 4.4: Portal Agen (/agent) + - 4.5: Portal Kolektor (/collector) + - 4.6: Portal Finance (/finance) + - 4.7: Public Routes + - 4.8: Settings & Configuration API + - 4.9: Voucher Payment API +- 📍 Section 5: Fitur-Fitur Utama (15+ modul) + - 5.1 Billing & Invoicing + - 5.2 MikroTik Integration + - 5.3 GenieACS & TR-069 + - 5.4 OLT & ONU Management + - 5.5 GIS Mapping + - 5.6 WhatsApp Automation + - 5.7 Payment Gateways + - 5.8 Agen & Sales + - 5.9-5.15 Additional features +- 📍 Section 6: Keamanan & Otentikasi +- 📍 Section 7: Frontend & UI +- 📍 Section 8: Internationalization (i18n) +- 📍 Section 9: Configuration & Settings +- 📍 Section 10: Dependencies & Libraries +- 📍 Section 11: Operational Requirements +- 📍 Section 12: Monitoring & Troubleshooting +- 📍 Section 13: Production Checklist +- 📍 Section 14: Performance Optimization +- 📍 Section 15: File Size & Statistics +- 📍 Section 16: Documentation & Resources +- 📍 Section 17: Advanced Features & Integration +- 📍 Section 18: Conclusion & Recommendations + +**When to use:** Referensi lengkap untuk semua aspek aplikasi + +**Example queries:** +- "Apa saja endpoint admin?" → See section 4.2 +- "Bagaimana cara kerja billing?" → See section 5.1 +- "Apa saja database tables?" → See section 3.2 +- "Bagaimana setup MikroTik?" → See section 5.2 + +--- + +### 2. QUICK_REFERENCE.md (6.67 KB) +**Purpose:** Quick lookup guide untuk task-task umum + +**Sections:** +- Quick Start (npm commands) +- Portal access URLs +- Key Files location +- Common Tasks (5 tasks) + - Change Port + - Change Company Name + - Enable WhatsApp + - Setup MikroTik + - Setup GenieACS +- Security Settings (MUST DO) +- Important Endpoints +- Database Commands +- Cron Jobs Schedule +- Troubleshooting (7 common issues) +- Multi-Language +- Useful Scripts +- Pre-Launch Checklist + +**When to use:** Sehari-hari untuk quick answers + +**Example queries:** +- "Port berapa aplikasi?" → See "Quick Start" +- "MikroTik tidak connect, apa solusinya?" → See "Troubleshooting" +- "Bagaimana aktifkan WhatsApp?" → See "Common Tasks" +- "Kapan billing dijalankan?" → See "Cron Jobs" + +--- + +### 3. DATABASE_SCHEMA.md (15.75 KB) +**Purpose:** Complete database schema documentation + +**Sections:** +- Schema Overview +- Detailed Table Structures (20+ tables) +- Table relationships & foreign keys +- Query Examples (4 examples) +- Optimization Tips +- Backup & Recovery procedures +- Index recommendations + +**Tables Documented:** +- settings, packages, customers, routers, olts, odps +- invoices, payments, payment_approvals +- pppoe_users, pppoe_profiles, pppoe_traffic_samples, pppoe_sessions +- hotspot_users, hotspot_profiles, hotspot_sessions +- voucher_batches, vouchers +- acsdevices, acs_sessions, acs_device_params +- onu_mapping, onu_provision, olu_ports +- audit_trail, expenses, cash_in +- technicians, cashiers, agents, collectors +- inventory_items, attendance, tickets +- digiflazz_transactions, vomit_log + +**When to use:** Ketika perlu memahami struktur data atau membuat query + +**Example queries:** +- "Bagaimana struktur tabel customers?" → See "customers" section +- "Query apa untuk cek overdue invoice?" → See "Query Examples" +- "Tabel apa untuk tracking PPPoE usage?" → See "pppoe_traffic_samples" + +--- + +### 4. BACKUP_CHECKLIST.md (10.58 KB) +**Purpose:** Comprehensive backup & disaster recovery guide + +**Sections:** +- Pre-Audit Backup (CRITICAL) + - Step 1-5: Database, Settings, Aplikasi, Uploads +- Backup Checklist (verification checklist) +- Restore Procedures (5 scenarios) + - Restore Database Only + - Restore Settings + - Full Application Restore + - Restore Upload Files +- Backup Inventory (tracking template) +- Secure Backup Storage (4 options) + - Local Storage (USB) + - Network Drive (NAS/SMB) + - Cloud Storage (rclone, S3) + - Encrypted Backup (GPG) +- Automated Backup (Cron Job setup) +- Disaster Recovery Plan (5 scenarios) + - Database Corrupted + - Aplikasi Crashed + - Akses Denied + - HDD Full + - Settings Misconfiguration +- Best Practices (DO & DON'T) +- Recovery Time Objective (RTO/RPO) +- Backup Verification Checklist + +**When to use:** Sebelum & sesudah deployment, disaster recovery + +**Example queries:** +- "Gimana backup database?" → See "Backup Procedures" Step 1 +- "Gimana restore jika database corrupt?" → See "Disaster Recovery" Scenario 1 +- "Setup automated backup gimana?" → See "Automated Backup (Cron Job)" + +--- + +### 5. AUDIT_SUMMARY.md (12.31 KB) +**Purpose:** Executive summary & quick links + +**Sections:** +- Executive Summary +- Dokumentasi yang Dibuat (daftar lengkap) +- Key Findings (Strengths & Areas for Improvement) +- Application Statistics +- Quick Start (4 steps) +- Security Checklist (CRITICAL) +- Operational Runbook + - Daily, Weekly, Monthly, Ad-Hoc tasks +- Maintenance Procedures (5 procedures) +- Training Resources +- Support & Escalation (3 levels) +- Document File Locations +- Next Steps (Immediate, Short-term, Long-term) +- Audit Completion Status +- Document Versions +- Audit Methodology +- Final Checklist +- Success Criteria + +**When to use:** Overview & planning + +**Example queries:** +- "Aplikasi ready untuk production?" → See "Audit Completion Status" +- "Apa saja dokumentasi yang dibuat?" → See "Dokumentasi yang Telah Dibuat" +- "Langkah pertama gimana?" → See "Quick Start" + +--- + +### 6. DOKUMENTASI_INDEX.md (This file) +**Purpose:** Navigation guide untuk semua dokumentasi + +**Sections:** +- Panduan Membaca Dokumentasi (paths untuk berbagai user) +- File Documentation Details (overview setiap file) +- Quick Links & Search Tips +- Related Documentation +- How to Update Documentation +- Troubleshooting Documentation Issues + +--- + +## 🔗 Quick Links + +### By Topic + +#### Billing & Invoicing +- AUDIT_LENGKAP.md → Section 5.1 +- DATABASE_SCHEMA.md → invoices, payments tables + +#### MikroTik Management +- QUICK_REFERENCE.md → "Setup MikroTik" +- AUDIT_LENGKAP.md → Section 5.2 +- DATABASE_SCHEMA.md → routers, pppoe_users tables + +#### Payment Gateways +- AUDIT_LENGKAP.md → Section 5.7 +- QUICK_REFERENCE.md → "Setup Payment Gateway" + +#### Database +- DATABASE_SCHEMA.md → All sections +- BACKUP_CHECKLIST.md → Backup procedures + +#### Security +- QUICK_REFERENCE.md → "Security Settings" +- AUDIT_SUMMARY.md → "Security Checklist" +- AUDIT_LENGKAP.md → Section 6 + +#### Troubleshooting +- QUICK_REFERENCE.md → "Troubleshooting" +- AUDIT_LENGKAP.md → Section 12 + +--- + +## 🔍 How to Search Documentation + +### Using Text Editor Find (Ctrl+F) + +**Search by feature:** +``` +"billing" → Find billing-related content +"whatsapp" → Find WhatsApp integration +"endpoint" → Find API endpoints +"table" → Find database tables +"cron" → Find scheduled tasks +"error" → Find error handling +``` + +**Search by section:** +``` +"## " (two hashes) → Find main sections +"### " (three hashes) → Find subsections +"- [ ]" → Find checklist items +"| " → Find tables +``` + +### Using grep (Linux/Mac) +```bash +# Find all mentions of "WhatsApp" +grep -n "whatsapp" *.md + +# Find all table structures +grep -n "CREATE TABLE" DATABASE_SCHEMA.md + +# Find all endpoints +grep -n "GET\|POST\|PUT\|DELETE" AUDIT_LENGKAP.md + +# Find all endpoints for /admin +grep -n "/admin" AUDIT_LENGKAP.md +``` + +--- + +## 📋 Documentation Cross-References + +### Related Files to Read Together + +**For Admin Portal:** +- AUDIT_LENGKAP.md section 4.2 +- QUICK_REFERENCE.md "Common Tasks" +- AUDIT_SUMMARY.md "Operational Runbook" + +**For Customer Portal:** +- AUDIT_LENGKAP.md section 4.1 +- AUDIT_LENGKAP.md section 5.1 (Billing) + +**For Database Operations:** +- DATABASE_SCHEMA.md (all sections) +- BACKUP_CHECKLIST.md (all procedures) +- QUICK_REFERENCE.md "Database Commands" + +**For Security:** +- QUICK_REFERENCE.md "Security Settings" +- AUDIT_SUMMARY.md "Security Checklist" +- AUDIT_LENGKAP.md section 6 + +**For Troubleshooting:** +- QUICK_REFERENCE.md "Troubleshooting" +- AUDIT_LENGKAP.md section 12 +- AUDIT_LENGKAP.md section 13 + +--- + +## 🔄 How to Update Documentation + +When application changes, update relevant docs: + +1. **New Feature Added:** + - Update AUDIT_LENGKAP.md section 5 (Fitur-Fitur) + - Update QUICK_REFERENCE.md if it's a common task + +2. **New Endpoint Added:** + - Update AUDIT_LENGKAP.md section 4 (Routes) + - Add to QUICK_REFERENCE.md "Important Endpoints" + +3. **Database Schema Changed:** + - Update DATABASE_SCHEMA.md with new tables + - Add query examples if applicable + +4. **Backup Procedure Changed:** + - Update BACKUP_CHECKLIST.md + - Update QUICK_REFERENCE.md "Database Commands" + +5. **Security Changes:** + - Update AUDIT_SUMMARY.md "Security Checklist" + - Update AUDIT_LENGKAP.md section 6 + +**Version Control:** +```markdown +Example header update: + +### 6. AUDIT_SUMMARY.md (12.31 KB) → Last updated: 2026-06-20 +``` + +--- + +## ❓ FAQ - Documentation + +**Q: File mana yang harus dibaca duluan?** +A: Mulai dengan AUDIT_SUMMARY.md (5 min), lalu QUICK_REFERENCE.md (10 min) + +**Q: Gimana cari informasi tentang endpoint tertentu?** +A: Cari di AUDIT_LENGKAP.md section 4, atau grep dengan `/endpoint-name` + +**Q: Database tables untuk X ada di file mana?** +A: DATABASE_SCHEMA.md, section "Detailed Table Structures" + +**Q: Backup gimana?** +A: Baca BACKUP_CHECKLIST.md dari atas sampai bawah + +**Q: Troubleshooting error X gimana?** +A: Lihat QUICK_REFERENCE.md "Troubleshooting", atau AUDIT_LENGKAP.md section 12 + +**Q: Dokumentasi sudah lengkap?** +A: Ya, 6 file (91 KB) sudah cover semua aspek aplikasi + +**Q: Dokumentasi perlu diupdate?** +A: Ya, ikuti "How to Update Documentation" section di atas + +--- + +## 📊 Documentation Statistics + +| File | Size | Sections | Tables | Sections Covered | +|------|------|----------|--------|------------------| +| AUDIT_LENGKAP.md | 45 KB | 18 | 5+ | Features, API, Security | +| QUICK_REFERENCE.md | 7 KB | 10 | 3+ | Common tasks, Quick links | +| DATABASE_SCHEMA.md | 16 KB | 6 | 20+ | Database, Tables, Queries | +| BACKUP_CHECKLIST.md | 11 KB | 8 | 2+ | Backup, Recovery, RTO/RPO | +| AUDIT_SUMMARY.md | 12 KB | 15 | 2+ | Executive, Roadmap | +| DOKUMENTASI_INDEX.md | 9 KB | 6 | - | Navigation, Index | + +**Total:** 6 files, ~100 KB, 18+ sections, 30+ tables documented + +--- + +## ✅ Verification Checklist + +Pastikan dokumentasi lengkap: + +- [x] AUDIT_LENGKAP.md → 18 sections ✓ +- [x] QUICK_REFERENCE.md → 10 sections ✓ +- [x] DATABASE_SCHEMA.md → 20+ tables ✓ +- [x] BACKUP_CHECKLIST.md → 8 sections ✓ +- [x] AUDIT_SUMMARY.md → 15 sections ✓ +- [x] DOKUMENTASI_INDEX.md → Navigation ✓ + +**All files created:** ✅ COMPLETE + +--- + +## 🎯 Documentation Roadmap + +### Current Status (2026-06-19) +- ✅ 6 documentation files created +- ✅ ~100 KB comprehensive documentation +- ✅ All major features documented +- ✅ Backup procedures documented +- ✅ Troubleshooting guide created + +### Future Enhancements +- ⏳ API Swagger/OpenAPI documentation +- ⏳ Video tutorials for common tasks +- ⏳ Deployment guide (Docker, K8s) +- ⏳ Performance tuning guide +- ⏳ Custom integration examples + +--- + +## 🔐 Documentation Backup + +**Backup documentation juga!** + +```bash +# Create backup +tar -czf audit-docs-backup-$(date +%Y-%m-%d).tar.gz \ + AUDIT_LENGKAP.md \ + QUICK_REFERENCE.md \ + DATABASE_SCHEMA.md \ + BACKUP_CHECKLIST.md \ + AUDIT_SUMMARY.md \ + DOKUMENTASI_INDEX.md + +# Store in safe location +cp audit-docs-backup-*.tar.gz /mnt/backup/ +``` + +--- + +## 📞 Documentation Support + +**Questions about documentation?** + +1. First, try searching the relevant file +2. Check cross-references at the end of each section +3. Review FAQ section (above) +4. Contact: Ali Jaya Net (081947215703) + +--- + +## 🏁 Next Steps + +1. **READ:** Mulai dengan AUDIT_SUMMARY.md +2. **UNDERSTAND:** Baca AUDIT_LENGKAP.md sections yang relevan +3. **REFERENCE:** Simpan QUICK_REFERENCE.md untuk daily use +4. **BACKUP:** Ikuti BACKUP_CHECKLIST.md procedures +5. **MAINTAIN:** Update docs saat ada changes + +--- + +**Documentation Version:** 1.0 - Complete +**Last Updated:** June 19, 2026 +**Status:** ✅ READY FOR PRODUCTION + +**Happy Reading! 📖** + diff --git a/docs/FITUR_APLIKASI_LENGKAP.md b/docs/FITUR_APLIKASI_LENGKAP.md new file mode 100644 index 0000000..9e5f7f0 --- /dev/null +++ b/docs/FITUR_APLIKASI_LENGKAP.md @@ -0,0 +1,2269 @@ +# DOKUMENTASI FITUR APLIKASI - RTRWNET BILLING & MANAGEMENT SYSTEM +**Status:** ✅ COMPREHENSIVE FEATURE DOCUMENTATION +**Tanggal:** 14 Agustus 2026 +**Tujuan:** Complete reference for all built-in features & functionality + +--- + +## 📑 DAFTAR ISI + +1. [Overview Sistem](#overview-sistem) +2. [Portal & Akses](#portal--akses) +3. [Fitur Billing & Invoicing](#fitur-billing--invoicing) +4. [Fitur Manajemen Pelanggan](#fitur-manajemen-pelanggan) +5. [Fitur MikroTik Integration](#fitur-mikrotik-integration) +6. [Fitur GenieACS & TR-069](#fitur-genieacs--tr-069) +7. [Fitur OLT & ONU Management](#fitur-olt--onu-management) +8. [Fitur Payment Gateway](#fitur-payment-gateway) +9. [Fitur GIS & Mapping](#fitur-gis--mapping) +10. [Fitur WhatsApp & Telegram](#fitur-whatsapp--telegram) +11. [Fitur Inventory & Logistics](#fitur-inventory--logistics) +12. [Fitur Ticketing & Support](#fitur-ticketing--support) +13. [Fitur Agent & Sales](#fitur-agent--sales) +14. [Fitur Monitoring & Health](#fitur-monitoring--health) +15. [Fitur Automation & Cron](#fitur-automation--cron) +16. [Fitur Internal User Management](#fitur-internal-user-management) + +--- + +## 📊 OVERVIEW SISTEM + +### Informasi Dasar +- **Nama:** RTRWNET Management & Billing System +- **Versi:** 1.0.0 +- **Runtime:** Node.js ≥20.0.0 +- **Database:** SQLite (better-sqlite3) +- **Main File:** `app-customer.js` +- **Port Default:** 3001 (dapat diubah di settings.json) +- **License:** Terbuka (lihat LICENSE file) + +### Target User +1. **Admin/Manajemen ISP** - Pengelola bisnis & konfigurasi sistem +2. **Teknisi Lapangan** - Support & maintenance perangkat pelanggan +3. **Operator Billing** - Penagihan & administrasi keuangan +4. **Agent/Mitra Penjualan** - Penjualan paket & pembayaran +5. **Pelanggan** - Portal self-service +6. **Kolektor** - Approval pembayaran tertangguh + +### Tech Stack Utama +```javascript +- Express.js 4.18.2 // Web framework +- SQLite (better-sqlite3) // Database ringan & reliable +- EJS 3.1.10 // Template engine +- Bootstrap 5 // CSS framework +- Leaflet // Map/GIS +- Baileys 6.7.21 // WhatsApp unofficial +- node-routeros-client // MikroTik API +- net-snmp // OLT SNMP +- Axios // HTTP client +- node-cron // Task scheduling +``` + +--- + +## 🚪 PORTAL & AKSES + +### 1. Portal Pelanggan (Public/Private) +**URL:** `http://localhost:3001/customer` + +**Sub-Fitur:** +- ✅ Login/Registrasi (dengan opsi OTP) +- ✅ Dashboard - ringkasan tagihan & status +- ✅ Cek tagihan (tanpa login via `/customer/check-billing`) +- ✅ Riwayat invoice & pembayaran +- ✅ Grafik traffic PPPoE real-time +- ✅ Ubah SSID/Password Wi-Fi +- ✅ Reboot CPE/ONU +- ✅ Beli voucher hotspot +- ✅ Buat tiket support +- ✅ Profil & setting pribadi + +**Authentication:** Session-based + OTP optional + +**Views:** +``` +views/customer/ +├── dashboard.ejs // Halaman utama +├── billing.ejs // Riwayat tagihan +├── topup.ejs // Pembayaran online +├── register.ejs // Registrasi +├── device.ejs // Manajemen Wi-Fi +├── ppob.ejs // Pembelian voucher +├── tickets.ejs // Support tickets +``` + +--- + +### 2. Portal Admin +**URL:** `http://localhost:3001/admin` + +**Sub-Fitur:** +- ✅ Dashboard - statistik & ringkasan +- ✅ Manajemen pelanggan (CRUD) +- ✅ Manajemen paket & harga +- ✅ Billing & invoice generation +- ✅ MikroTik configuration & monitoring +- ✅ GenieACS device management +- ✅ OLT/ONU management +- ✅ Peta jaringan (GIS) dengan Leaflet +- ✅ Whatsapp administration +- ✅ Manajemen agent/agen +- ✅ Laporan keuangan +- ✅ Inventory management +- ✅ Settings & configuration +- ✅ Audit trail & logs +- ✅ Backup & restore +- ✅ Multi-language support + +**Authentication:** Session admin-only + +**Main Routes:** +``` +/admin/login → Admin login +/admin → Dashboard +/admin/customers → Manajemen pelanggan +/admin/packages → Paket layanan +/admin/invoices → Tagihan +/admin/mikrotik → MikroTik tools +/admin/genieacs → Device management +/admin/olts → OLT management +/admin/map → Peta jaringan +/admin/whatsapp → WhatsApp config +/admin/agents → Manajemen agen +/admin/reports → Laporan +/admin/inventory → Gudang +/admin/settings → Pengaturan +``` + +--- + +### 3. Portal Teknisi (Field Support) +**URL:** `http://localhost:3001/tech` + +**Sub-Fitur:** +- ✅ Dashboard teknis +- ✅ Pool tiket (ticket queue) +- ✅ Peta lokasi pelanggan & ODP +- ✅ Chat WhatsApp integration +- ✅ GPS tracking teknisi +- ✅ Input pelanggan baru (lapangan) +- ✅ Device monitoring (GenieACS) +- ✅ Performance monitoring (CPU, RAM, disk) +- ✅ Riwayat tiket & penanganan + +**Authentication:** Session tech-only + +**Features:** +``` +/tech/ → Dashboard +/tech/tickets → Daftar tiket +/tech/tickets/:id → Detail tiket +/tech/map → Peta jaringan +/tech/devices/:id → Detail device +/tech/monitoring → System monitoring +``` + +--- + +### 4. Portal Agen (Agent/Sales) +**URL:** `http://localhost:3001/agent` + +**Sub-Fitur:** +- ✅ Login & dashboard +- ✅ Pembayaran tagihan pelanggan (dari saldo agen) +- ✅ Pembelian voucher hotspot +- ✅ Penjualan pulsa & data (Digiflazz) +- ✅ Cetak struk transaksi +- ✅ Riwayat & laporan transaksi +- ✅ Manajemen saldo + +**Authentication:** Session agent-only + +**Payment Methods:** +- Direct payment dari saldo agen +- E-voucher purchase +- Digital product (pulsa, data, dll via Digiflazz) + +--- + +### 5. Portal Kolektor (Payment Collector) +**URL:** `http://localhost:3001/collector` + +**Sub-Fitur:** +- ✅ Login & dashboard +- ✅ Cek tagihan pelanggan per area +- ✅ Buat pengajuan pembayaran +- ✅ Riwayat pengajuan +- ✅ Approval workflow + +**Authentication:** Session collector-only + +**Workflow:** +``` +1. Kolektor cek tagihan pelanggan +2. Kolektor buat pengajuan pembayaran +3. Admin/Kasir approve pengajuan +4. Sistem catat pembayaran +5. Pelanggan dapat notifikasi via WhatsApp +``` + +--- + +### 6. Portal Finance (Keuangan) +**URL:** `http://localhost:3001/finance` + +**Sub-Fitur:** +- ✅ Laporan pendapatan +- ✅ Kategori pengeluaran +- ✅ Entry pengeluaran operasional +- ✅ Cash in/out tracking +- ✅ Financial dashboard +- ✅ Export laporan + +**Authentication:** Admin-only access + +--- + +### 7. Portal Public (Publik) +**Tanpa Login:** +- ✅ Cek tagihan tanpa login: `/customer/check-billing` +- ✅ Halaman status isolated: `/isolated` +- ✅ QRIS static payment: `/qris/static.ejs` +- ✅ Public voucher info: `/public/voucher` +- ✅ TOS/Privacy/About/Contact pages + +--- + +## 💰 FITUR BILLING & INVOICING + +### 1. Invoice Generation + +**Jenis Billing:** +- ✅ Bulanan otomatis (automatic monthly billing) +- ✅ Per pelanggan (single customer) +- ✅ Massal (bulk billing) +- ✅ Prorata (untuk bulan pertama) + +**Komponen Invoice:** +```javascript +{ + id: number, + customer_id: number, + period_month: number, // 1-12 + period_year: number, // 2026 + amount: number, // Total tagihan + status: 'unpaid'|'paid'|'cancelled', + qris_amount_unique: number, // QRIS nominal unik + notes: string, // Promo & prorata info + created_at: datetime, + due_date: datetime, + paid_at: datetime, + payment_method: string // 'admin', 'agent', 'tripay', dll +} +``` + +**Fitur Promo & Prorata:** +``` +Promo: +- Paket bisa set harga promo untuk N siklus pertama +- Setiap pelanggan track promo_cycles_used +- Promo berakhir → kembali ke harga normal + +Prorata: +- Jika tanggal pasang (install_date) = bulan tagihan +- Invoice pertama dihitung per hari (proporsi bulan) +- Otomatis di-apply berdasarkan paket setting +``` + +**Contoh Workflow:** +``` +1. Admin generate invoice untuk periode 08/2026 +2. Sistem hitung nominal per pelanggan: + - Status promo? → gunakan promo_price + - Paket punya FUP? → cek kuota bulanan + - Bulan pertama & prorata aktif? → hitung prorata +3. Invoice di-create dengan status 'unpaid' +4. Notifikasi WhatsApp ke pelanggan +5. Pelanggan bayar (online atau manual) +6. Sistem update status menjadi 'paid' +7. Notifikasi paid ke pelanggan +8. Isolir otomatis jika lewat due date +``` + +--- + +### 2. Payment Gateway Integration + +**Gateway Terintegasi:** +```javascript +1. Midtrans (Snap API) + - Status: Aktif/Nonaktif di settings + - Fitur: Multiple payment methods (CC, transfer, GCash, etc) + +2. Tripay + - Status: Aktif/Nonaktif + - Fitur: Bank transfer, E-wallet, QRIS + +3. Xendit + - Status: Aktif/Nonaktif + - Fitur: Invoice, VA, QR codes + +4. Duitku + - Status: Aktif/Nonaktif + - Fitur: Aggregator pembayaran + +5. QRIS Static (Built-in) + - QR code fixed untuk donasi/pembayaran unik + - Nominal unik auto-assign per invoice + - Mudah match payment dari notifikasi bank +``` + +**Payment Flow:** +``` +Customer → Choose Payment Method + → Redirect to Gateway + → Process Payment + → Gateway Webhook/Callback + → Mark Invoice as PAID + → Send WhatsApp confirmation +``` + +**Webhook Payment Generic:** +``` +Endpoint: POST /api/webhook/v1/payment-notif +Fitur: +- Generic webhook untuk bank/e-wallet notification +- Auto-match nominal dengan invoice +- Auto-mark paid jika cocok +- Log semua webhook untuk audit +- Manual cleanup jika perlu +``` + +--- + +### 3. Pembayaran Manual & Offline + +**Tipe Pembayaran Manual:** +```javascript +1. Admin bayar (dari panel) + - Pilih invoice + - Catat nominal pembayaran + - Catat metode pembayaran + - Mark as paid + - Otomatis kirim notifikasi WhatsApp + +2. Agent bayar (dari saldo agen) + - Agent login + - Pilih pelanggan + - Cek tagihan + - Bayar dari saldo agen + - Struk otomatis print/download + +3. Kolektor ajukan (pending approval) + - Kolektor input pembayaran + - Pending approval (admin/kasir) + - Approval → mark as paid + - Rejection → return ke pending + +4. Kasir bayar (kasir portal) + - Kasir login + - Input pembayaran + - Mark paid + - Print bukti +``` + +--- + +### 4. Invoice Management + +**Fitur:** +``` +- ✅ Lihat daftar invoice (admin/customer) +- ✅ Cetak invoice (PDF via pdfKit) +- ✅ Export invoice list (CSV/Excel) +- ✅ Batalkan pembayaran (unpay) - jika ada kesalahan +- ✅ Reschedule due date +- ✅ Bulk action (multiple invoices) +- ✅ Invoice status tracking +- ✅ Payment reminder via WhatsApp +``` + +**Invoice Status Flow:** +``` +unpaid → (payment received) → paid + ↓ + cancelled (oleh admin) + +Isolir status linked ke unpaid invoices +Auto-isolir trigger jika lewat due date +``` + +--- + +## 👥 FITUR MANAJEMEN PELANGGAN + +### 1. CRUD Pelanggan + +**Data Pelanggan:** +```javascript +{ + id: number, + name: string, // Nama pelanggan + phone: string, // Nomor HP + email: string, // Email + address: string, // Alamat fisik + latitude: float, // Koordinat GPS + longitude: float, + + // Network + package_id: number, // Paket yang digunakan + router_id: number, // MikroTik router + pppoe_username: string, // Username PPPoE + pppoe_secret: string, // Password PPPoE + hotspot_username: string, // Username hotspot + static_ip: string, // IP statis (jika ada) + + // Device + olt_id: number, // OLT yang digunakan + onu_port: number, // Port ONU + genieacs_tag: string, // Tag di GenieACS + + // Location + odp_id: number, // ODP terdekat + area: string, // Area/kabupaten + + // Status & Billing + status: 'active'|'suspended'|'inactive', + install_date: date, // Tanggal pasang + isolate_day: number, // Tanggal isolir otomatis (1-31) + auto_isolate: boolean, // Enable isolir otomatis + + // Promo + promo_cycles_used: number, // Berapa siklus promo sudah dipakai + + // Other + nik: string, // No ID pelanggan + notes: string, // Catatan + created_at: datetime, + updated_at: datetime +} +``` + +**Operasi:** +``` +- ✅ Tambah pelanggan baru +- ✅ Edit data pelanggan +- ✅ Hapus pelanggan (soft delete) +- ✅ Import pelanggan dari Excel +- ✅ Export pelanggan list +- ✅ Bulk action (status change, package change, dll) +- ✅ Search & filter (by name, phone, area, status) +``` + +--- + +### 2. Isolir / Suspend Customer + +**Isolir Manual:** +``` +Admin klik "Isolir" di halaman pelanggan + ↓ +Sistem suspend ke MikroTik: + - PPPoE: hapus user atau disable + - Hotspot: suspend user + - Static IP: redirect ke "Isolated" page + ↓ +Status pelanggan: 'suspended' + ↓ +Pelanggan kembali online setelah bayar + admin buka isolir +``` + +**Isolir Otomatis (Cron):** +``` +Cron job berjalan setiap hari jam 2 pagi + ↓ +Cek semua pelanggan: + - Status = 'active' + - auto_isolate = true + - Tanggal hari ini >= isolate_day + - Ada unpaid invoices + ↓ +Trigger isolir otomatis + ↓ +Notifikasi WhatsApp ke pelanggan +``` + +**Buka Isolir (Unisolir):** +``` +Admin klik "Buka Isolir" setelah pembayaran + ↓ +Sistem restore di MikroTik: + - PPPoE: tambah kembali user atau enable + - Hotspot: reactivate user + ↓ +Status pelanggan: 'active' + ↓ +Pelanggan bisa online lagi + ↓ +Notifikasi WhatsApp konfirmasi +``` + +--- + +### 3. Manajemen Paket Layanan + +**Data Paket:** +```javascript +{ + id: number, + name: string, // Nama paket (Starter, Pro, Premium) + description: string, + price: number, // Harga reguler per bulan + promo_price: number, // Harga promo (opsional) + promo_cycles: number, // N siklus pertama dapat promo + + // Speed + speed_down: number, // Download Mbps + speed_up: number, // Upload Mbps + + // FUP (Fair Usage Policy) + use_fup: boolean, // Enable FUP? + fup_limit_gb: number, // Limit data per bulan (GB) + fup_profile_name: string, // Profile MikroTik saat FUP + + // Night Mode (Jam Kalong) + use_night_mode: boolean, // Enable night mode? + night_profile_name: string, // Profile MikroTik malam (23:00-06:00) + + // Tax + use_ppn: boolean, // Charge PPN? + ppn_percentage: number, // % PPN (default 11%) + + // Prorata + prorate_first_invoice: boolean, // Prorata invoice pertama? + + is_active: boolean, + created_at: datetime +} +``` + +**Operasi:** +``` +- ✅ Tambah paket +- ✅ Edit paket +- ✅ Hapus paket +- ✅ Set promo per paket +- ✅ Set night mode profile +- ✅ Set FUP limit & profile +``` + +--- + +## 🌐 FITUR MIKROTIK INTEGRATION + +### 1. Koneksi & Konfigurasi + +**Setup MikroTik:** +``` +1. Admin setting: + - Host: 192.168.8.1 + - Username: billing + - Password: password123 + - Port: 8711 + +2. Di MikroTik router: + - Enable API service + - Create user 'billing' dengan permission + - Whitelist IP aplikasi + +3. Tes koneksi dari panel admin +``` + +**Multi-Router Support:** +``` +- Aplikasi bisa kelola banyak MikroTik router +- Per pelanggan assign ke router mana +- Cron job sinkron ke router tersesuai +- Fallback ke default router jika tidak specify +``` + +--- + +### 2. PPPoE Management + +**PPPoE User:** +```javascript +{ + username: string, // PPPoE username + secret: string, // PPPoE password + password_hashed: string, // Disimpan via PPPoE protocol + profile: string, // Bandwidth profile + disabled: boolean, + created_at: datetime +} +``` + +**Operasi:** +``` +- ✅ Create PPPoE user +- ✅ Change secret/password +- ✅ Disable/enable user +- ✅ View active sessions +- ✅ Monitor traffic (bytes in/out) +- ✅ Set speed profile +- ✅ Delete user +``` + +**Traffic Monitoring:** +``` +Real-time: +- ✅ Monitor sesi PPPoE aktif +- ✅ Grafik traffic per session +- ✅ Bytes sent/received +- ✅ Session duration +- ✅ IP address + +Historical: +- ✅ Store pemakaian per hari/bulan +- ✅ Aggregate data per customer +- ✅ Usage history dashboard +``` + +--- + +### 3. Hotspot Management + +**Hotspot User:** +```javascript +{ + name: string, // Hotspot username + password: string, + profile: string, // Speed profile + limit_uptime: number, // Session time limit + limit_bytes_total: number, // Data limit + limit_bytes_down: number, // Download limit + limit_bytes_up: number, // Upload limit + disabled: boolean +} +``` + +**Fitur:** +``` +- ✅ Create/edit hotspot user +- ✅ Active session monitoring +- ✅ View real-time data usage +- ✅ Disconnect session +- ✅ Set speed & data limits +``` + +--- + +### 4. Voucher Hotspot + +**Voucher System:** +``` +Workflow: +1. Admin create voucher batch +2. Sistem generate N kode voucher +3. Kode disimpan di database & MikroTik +4. Customer bisa gunakan di hotspot portal +5. Agent bisa jual voucher ke customer +6. Pelanggan bayar → dapat voucher code +``` + +**Voucher Batch:** +```javascript +{ + id: number, + name: string, // Nama batch + quantity: number, // Jumlah voucher + profile_name: string, // Speed profile + validity: number, // Hari berlaku + price: number, // Harga jual per voucher + status: 'pending'|'created'|'synced', + qty_created: number, // Berapa sudah dibuat + qty_failed: number, // Berapa gagal + created_at: datetime +} +``` + +**Fitur:** +``` +- ✅ Generate batch voucher +- ✅ Sync ke MikroTik +- ✅ Print voucher code +- ✅ Export CSV +- ✅ Track status (created/active/used/expired) +- ✅ Delete batch +- ✅ Sell via agent portal +- ✅ Sell via customer portal +``` + +--- + +### 5. Firewall & Rate Limiting + +**Built-in Firewall Setup:** +``` +Admin bisa setup firewall rules: +- Rate limiting per user +- Block ports tertentu +- Whitelist IP +- Queue management +- Priority traffic (VoIP, dst) +``` + +--- + +### 6. Bandwidth Profile (Jam Kalong) + +**Night Mode Automation:** +``` +Setup: +- Paket A: default 10Mbps, night 20Mbps (more speed at night) +- Paket B: default 5Mbps, night 2Mbps (throttle at night) + +Cron: +- 00:00 (midnight): Switch ke night profile +- 06:00 (pagi): Switch ke day profile + +Fitur: +- ✅ Set profile per paket +- ✅ Custom time (tidak harus fixed 00:00-06:00) +- ✅ Override manual per user +- ✅ Log profile changes +``` + +--- + +### 7. Backup MikroTik + +**Fitur:** +``` +- ✅ Backup konfigurasi MikroTik dari panel +- ✅ Schedule backup otomatis +- ✅ Download backup file +- ✅ Restore dari backup +- ✅ Version history +``` + +--- + +## 🎛️ FITUR GENIEACS & TR-069 + +### 1. Built-in ACS (Auto Configuration Server) + +Aplikasi memiliki ACS internal ringan untuk TR-069 tanpa perlu GenieACS eksternal. + +**Keuntungan:** +``` +✅ Lightweight - langsung di Node.js +✅ Tidak perlu MongoDB/Redis +✅ Hemat resource VPS +✅ Support banyak brand ONU (ZTE, Huawei, FiberHome, dll) +✅ Auto-detect device properties +✅ Provision WAN & VLAN otomatis +``` + +**Setting:** +```json +{ + "use_builtin_acs": true|false, + "acs_port": 7547 // TR-069 port +} +``` + +--- + +### 2. ONU/CPE Management + +**Device List:** +```javascript +{ + id: number, + device_id: string, // Identifier unik (MAC/Serial) + manufacturer: string, // Brand (ZTE, Huawei, dll) + model: string, + serial_number: string, + firmware_version: string, + + // Customer Link + customer_id: number, + customer_name: string, + customer_phone: string, + + // Status + status: 'online'|'offline'|'error', + last_contact: datetime, + + // Wi-Fi + ssid: string, // Wi-Fi name + password: string, // Wi-Fi password + + // VLAN + vlan_id: number, + management_ip: string, + + created_at: datetime +} +``` + +--- + +### 3. Device Operations + +**Fitur:** +``` +- ✅ View device list (dengan search & filter) +- ✅ Device detail (parameter, status, logs) +- ✅ Change SSID / Password Wi-Fi +- ✅ Bulk SSID change (multiple devices sekaligus) +- ✅ Reboot device +- ✅ View device logs & history +- ✅ Parameter configuration +- ✅ Provision WAN (VLAN, bridge, etc) +``` + +**SSID Change Workflow:** +``` +1. Admin/Tech buka device detail +2. Edit SSID & Password baru +3. Click "Update SSID" +4. Sistem send perintah via TR-069 +5. Device update Wi-Fi config +6. Notifikasi customer via WhatsApp +7. Log activity di audit trail +``` + +--- + +### 4. GenieACS Integration (Optional) + +Jika gunakan GenieACS eksternal: + +**Konfigurasi:** +```json +{ + "genieacs_url": "http://192.168.8.189:7557", + "genieacs_username": "admin", + "genieacs_password": "admin", + "genieacs_timeout": 30000, + "genieacs_monitoring_enabled": true, + "genieacs_monitoring_interval": 6, // Jam + "genieacs_rxpower_threshold": -27 // dBm +} +``` + +**Fitur:** +``` +- ✅ Sync device dari GenieACS ke aplikasi +- ✅ View GenieACS parameters +- ✅ Kirim command (SSID, reboot, dll) +- ✅ Monitor RX power (signal strength) +- ✅ Alert jika signal lemah +- ✅ Bulk operation +``` + +--- + +## 🔌 FITUR OLT & ONU MANAGEMENT + +### 1. OLT Configuration + +**OLT Data:** +```javascript +{ + id: number, + name: string, // Nama OLT (OLT-1, OLT-Jakarta, dll) + host: string, // IP address + snmp_community: string, // SNMP community string + snmp_port: number, // SNMP port (161) + brand: string, // Brand (HIOSO, ZTE, Huawei, dll) + model: string, + + // Telnet (untuk konfigurasi WAN) + telnet_host: string, + telnet_port: number, + telnet_username: string, + telnet_password: string, + telnet_enable_password: string, + + // Web interface + web_url: string, + web_username: string, + web_password: string, + + // API (untuk ZTE C320) + api_base_url: string, + + is_active: boolean, + created_at: datetime +} +``` + +--- + +### 2. ONU Status & Monitoring + +**ONU Info:** +```javascript +{ + olt_id: number, + port: number, // Port OLT (1-128 tergantung OLT) + onu_index: number, + + // Hardware + sn: string, // Serial number + model: string, + firmware_version: string, + mac_address: string, + + // Status + status: 'online'|'offline'|'dying_gasp'|'error', + admin_state: 'enable'|'disable', + optical_signal: number, // dBm + + // Customer link + customer_id: number, + customer_name: string, + + // Distance + distance: number, // KM to OLT + + // Last contact + last_contact: datetime +} +``` + +--- + +### 3. ONU Operations + +**Fitur:** +``` +- ✅ View ONU list per port +- ✅ View ONU statistics (signal strength, status) +- ✅ Reboot ONU +- ✅ Rename ONU (friendly name) +- ✅ Authorize ONU (EPON) +- ✅ Configure WAN (VLAN, bridge, PPPoE) +- ✅ View ONU logs +- ✅ Bulk operation +``` + +**Configure WAN Methods:** +``` +1. Telnet (OMCI commands) + - For ZTE, Huawei, FiberHome + - Direksi jalankan commands Telnet + +2. TR-069 (GenieACS) + - If GenieACS configured + - Use parameter modification + +3. REST API (go-api-c320) + - For ZTE C320 specifically + - Direct API call to OLT + +4. Web Interface + - Manual via OLT web UI + - Backup method jika automation gagal +``` + +--- + +### 4. OLT Port Management + +**Port Info:** +```javascript +{ + olt_id: number, + port_number: number, // 1-N + port_type: 'EPON'|'GPON'|'hybrid', + + // Capacity + total_onus: number, // Max ONU di port ini + active_onus: number, // ONU yang online + + // Status + status: 'up'|'down'|'error', + admin_state: 'enable'|'disable', + + // Statistics + bytes_rx: number, + bytes_tx: number, + packets_rx: number, + packets_tx: number, + errors: number, + + // Monitoring + optical_power: number, + temperature: number +} +``` + +--- + +## 💳 FITUR PAYMENT GATEWAY + +### 1. Multi-Gateway Architecture + +**Supported Gateways:** +``` +1. Midtrans (Snap API) + Status: Enable/Disable di settings + Methods: CC, Transfer, GCash, Gopay, OVO, Dana + +2. Tripay + Status: Enable/Disable + Methods: Bank Transfer, Ewallet, QRIS + +3. Xendit + Status: Enable/Disable + Methods: Virtual Account, QR, Invoice + +4. Duitku + Status: Enable/Disable + Methods: Aggregator pembayaran + +5. QRIS Static (Built-in) + Status: Always available + Methods: QR Code fixed +``` + +--- + +### 2. Payment Integration Flow + +**Customer Bayar Invoice:** +``` +1. Customer lihat invoice di portal +2. Click "Bayar" +3. Pilih payment method dari gateway aktif +4. Redirect ke payment gateway +5. Customer input data pembayaran +6. Gateway proses pembayaran +7. Success/Failure notification +8. Aplikasi terima webhook dari gateway +9. Verify payment amount & signature +10. Mark invoice as PAID (jika success) +11. Send WhatsApp confirmation +12. Customer notif pembayaran berhasil +``` + +--- + +### 3. Payment Verification + +**Verification Process:** +```javascript +function verifyPayment(invoice, payment) { + // 1. Check nominal cocok + if (payment.amount !== invoice.amount) { + return false; + } + + // 2. Check signature (tergantung gateway) + const signature = crypto.createHmac('sha256', secretKey) + .update(payment.order_id + payment.amount) + .digest('hex'); + if (signature !== payment.signature) { + return false; + } + + // 3. Check status (sudah dibayar di gateway) + if (payment.status !== 'success' && payment.status !== 'paid') { + return false; + } + + // 4. Check duplikasi (jangan bayar 2x) + const existing = findPaymentByExternalId(payment.gateway_id); + if (existing) { + return false; + } + + return true; +} +``` + +--- + +### 4. Webhook Handling + +**Generic Webhook:** +``` +Endpoint: POST /api/webhook/v1/payment-notif +Header: Authorization: Bearer MY_WEBHOOK_SECRET + +Fitur: +- ✅ Accept webhook dari bank/e-wallet apapun +- ✅ Parse notifikasi pembayaran +- ✅ Match nominal dengan invoice/voucher +- ✅ Auto-mark paid jika cocok +- ✅ Log semua webhook (audit trail) +- ✅ Manual review & cleanup di admin +``` + +**Webhook Log:** +```javascript +{ + id: number, + service: string, // "bank_abc", "ewallet_xyz" + content: string, // Raw webhook content + parsed_amount: number, // Parsed dari notifikasi + parsed_ok: boolean, // Berhasil di-parse? + matched_invoice: number, // Invoice ID yang matched + matched_voucher: number, // Voucher ID yang matched + ip: string, // Source IP + user_agent: string, + created_at: datetime, + status: 'pending'|'processed'|'error' +} +``` + +--- + +### 5. Payment Report + +**Fitur:** +``` +- ✅ Daily transaction report +- ✅ Gateway settlement report +- ✅ Payment method breakdown +- ✅ Failed payment report +- ✅ Refund tracking +- ✅ Revenue trending +``` + +--- + +## 🗺️ FITUR GIS & MAPPING + +### 1. Peta Admin (`/admin/map`) + +**Fitur Dasar:** +``` +- ✅ Leaflet map dengan OpenStreetMap +- ✅ Hybrid view (satellite + road) +- ✅ Zoom in/out +- ✅ Locate kantor (center point dari office_lat/office_lng) +``` + +**Marker & Layer:** +``` +1. Customer Marker + - Merah: offline/suspended + - Hijau: online + - Biru: pending + - Click → popup detail customer + +2. ODP Marker + - Orange icon + - Click → ODP info & available ports + +3. Cable Path (Polyline) + - Garis dari customer ke ODP + - Bisa edit/draw manually + - Simpan rute untuk setiap customer +``` + +**Popup Info:** +``` +Customer Popup: +- Name, phone, email +- Paket, status, speed +- IP address (PPPoE/hotspot) +- Unpaid invoices count +- Traffic graph (PPPoE real-time) +- Action buttons: isolir, edit, view detail + +ODP Popup: +- ODP name +- Available ports +- Customers connected +- Port status summary +``` + +**Interactive Fitur:** +``` +- ✅ Draw cable path (customer to ODP) +- ✅ Save cable route +- ✅ Edit customer location (via location picker) +- ✅ Bulk action dari peta (select multiple customers) +- ✅ Real-time PPPoE traffic graph +``` + +--- + +### 2. Peta Teknisi (`/tech/map`) + +**Fitur:** +``` +- ✅ Sama seperti admin map (read-only untuk tech) +- ✅ View customer & ODP locations +- ✅ Click customer → show WhatsApp chat button +- ✅ Open route di Google Maps +- ✅ GPS tracking teknisi (optional) +- ✅ Nearby customers (dalam radius tertentu) +``` + +**Tech GPS Tracking:** +``` +Setting: +- Aktifkan attendance_geofencing di settings +- Radius perimeter kantor (attendance_radius meter) + +Fitur: +- Tech login di mobile portal +- Sistem track GPS lokasi tech +- Visualisasi di peta +- Geofence alert (keluar/masuk area) +``` + +--- + +### 3. Location Picker (Form Customer) + +**Fitur:** +``` +- ✅ Modal dengan peta satellite view +- ✅ Click di peta → set customer location +- ✅ Drag marker untuk adjust posisi +- ✅ Coordinate display (lat/lng) +- ✅ Address lookup (reverse geocoding jika ada) +- ✅ Done → save ke form +``` + +--- + +### 4. Cable Path Management + +**Data:** +```javascript +{ + customer_id: number, + olt_id: number, + odp_id: number, + + // Polyline coordinates + path: [ // Array of [lat, lng] + [-6.2521, 107.9205], + [-6.2522, 107.9206], + [-6.2523, 107.9207] + ], + + length_km: number, // Calculated distance + description: string, // Cable detail (aerial/underground, etc) + + created_at: datetime, + updated_at: datetime +} +``` + +**Operasi:** +``` +- ✅ Draw path (click-click di peta) +- ✅ Edit existing path +- ✅ Delete path +- ✅ Calculate distance automatically +- ✅ Save to database +``` + +--- + +## 💬 FITUR WHATSAPP & TELEGRAM + +### 1. WhatsApp Integration (Baileys) + +**Setup:** +``` +Setting di settings.json: +{ + "whatsapp_enabled": true, + "whatsapp_auth_folder": "auth_info_baileys", + "whatsapp_admin_numbers": ["081947215703", "087820851413"], + "company_phone": "6287820851413" +} +``` + +**Authentication:** +``` +1. Admin login ke /admin/whatsapp +2. Sistem tampilkan QR code +3. Scan QR code dengan WhatsApp app +4. Verifikasi di WhatsApp +5. Bot siap mengirim pesan +``` + +--- + +### 2. WhatsApp Features + +**Incoming Message Handler:** +``` +Admin/authorized nomor bisa kirim command: + +1. BILLING + - List unpaid invoices + - Customer status + - Payment info + +2. MIKROTIK + - Active users + - Session info + - Bandwidth status + +3. PULSA (Digiflazz) + - Check balance + - Buy pulsa + - Transaction status + +4. TOPUP AGENT + - Top-up saldo agent + - Check agent balance +``` + +**Incoming Command Examples:** +``` +From admin: +"billing" → Kirim laporan billing hari ini + +"pppoe" → List active PPPoE sessions + +"balance" → Cek saldo Digiflazz + +"topup 1000000" → Top-up agen Rp 1jt + +"saldo" → Cek saldo agent +``` + +--- + +### 3. WhatsApp Automated Messages + +**Invoice Notification:** +``` +When: +- Invoice created (otomatis kirim ke customer) +- Invoice paid (kirim konfirmasi) +- Invoice due (reminder H-1 isolir) + +Content: +"Tagihan Internet RTRWnet +Periode: Agustus 2026 +Nominal: Rp 350.000 +Due: 10 Agustus 2026 + +Bayar di: [link portal] + +Isolir jika belum bayar: 10 Agustus 2026" +``` + +**Payment Confirmation:** +``` +When: Invoice marked as paid + +Content: +"✅ Pembayaran Anda diterima! + +Tagihan: Rp 350.000 +Tanggal: 05 Agustus 2026 +Status: LUNAS + +Terima kasih atas pembayaran Anda. +Layanan Anda tetap aktif. + +- RTRWnet Admin" +``` + +**Isolir Notice:** +``` +When: Isolir automatic atau manual + +Content: +"⚠️ PEMBERITAHUAN ISOLIR + +Layanan internet Anda telah diputus +Alasan: Tagihan belum dibayar + +Tagihan: Rp 350.000 +Due date: 10 Agustus 2026 + +Hubungi: [support number] + +Buka isolir setelah pembayaran" +``` + +--- + +### 4. WhatsApp Broadcast + +**Fitur:** +``` +- ✅ Select customer list +- ✅ Create message (dengan template variable) +- ✅ Schedule send time +- ✅ Set delay antar message (untuk hindari spam) +- ✅ View delivery status +- ✅ Pause/resume/stop broadcast +- ✅ Retry failed messages +``` + +**Broadcast Example:** +``` +Template: +"Halo {{customer_name}}, +Tagihan internet bulan ini sudah siap. +Nominal: {{amount_idr}} +Bayar di: {{payment_link}}" + +Broadcast: +1. Select customers: Area Bekasi (200 customer) +2. Template: "Tagihan Bulanan" +3. Schedule: 09:00 (9 pagi) +4. Delay: 3 detik antar pesan +5. Total: 200 pesan dalam 10 menit +6. Status: Sent 198, Failed 2, Pending 0 +``` + +--- + +### 5. WhatsApp Live Chat + +**Fitur (Upcoming):** +``` +- ✅ Inbox untuk semua chat masuk/keluar +- ✅ Reply message dari admin panel +- ✅ Chat history per customer +- ✅ Quick reply template +- ✅ Attachment support +``` + +--- + +### 6. Telegram Integration (Optional) + +**Setup:** +```json +{ + "telegram_enabled": true|false, + "telegram_bot_token": "xxx:yyy", + "telegram_admin_id": "123456789" +} +``` + +**Fitur:** +``` +- ✅ Bot untuk admin (same as WhatsApp commands) +- ✅ Alert notifications +- ✅ Daily report +- ✅ System monitoring +``` + +--- + +## 📦 FITUR INVENTORY & LOGISTICS + +### 1. Inventory System + +**Kategori Inventory:** +``` +- Perangkat ONU/CPE (stock) +- Kabel & Connector +- Power Supply +- Accessories (splitter, dll) +- Tools & Equipment +``` + +**Item Data:** +```javascript +{ + id: number, + category_id: number, + name: string, // Product name + sku: string, // SKU code + description: string, + unit: string, // 'pcs', 'meter', 'kg', dll + quantity: number, // Current stock + minimum_qty: number, // Alert jika stock < ini + unit_cost: number, // Cost per unit + supplier: string, + location: string, // Lokasi di gudang + created_at: datetime +} +``` + +--- + +### 2. Inventory Operation + +**Fitur:** +``` +- ✅ Add item ke inventory +- ✅ Adjust stock (add/remove) +- ✅ Stock transfer +- ✅ Low stock alert +- ✅ Item history +- ✅ Search & filter +- ✅ Export inventory list +``` + +**Stock Alert:** +``` +When stock < minimum_qty: +- Dashboard alert +- Email notification (jika ada) +- Request PO (Purchase Order) +``` + +--- + +### 3. Asset Tracking + +**Asset Assignment:** +``` +Teknisi bisa assign asset ke task: +- Ticket → use ONU +- Installation → use cable x10m +- Repair → use power supply 1pc +``` + +**Asset History:** +``` +Track item dari stock → assignment → return +- What item used +- When used +- Who used +- For what task +- Status (used/returned/lost) +``` + +--- + +## 🎫 FITUR TICKETING & SUPPORT + +### 1. Ticket System + +**Ticket Data:** +```javascript +{ + id: number, + + // Customer & Technician + customer_id: number, + customer_name: string, + customer_phone: string, + customer_email: string, + + technician_id: number, + technician_name: string, + + // Content + subject: string, // Judul keluhan + message: string, // Deskripsi masalah + + // Status + status: 'open'|'in_progress'|'resolved'|'closed', + + // Details + priority: 'low'|'medium'|'high'|'urgent', + category: string, // 'billing', 'technical', 'general' + + // Attachments + customer_photos: string, // JSON array of file paths + customer_photo_metadata: string, // Photo GPS/timestamp + technician_photos: string, + technician_notes: string, + + // Timeline + created_at: datetime, + updated_at: datetime, + resolved_at: datetime +} +``` + +--- + +### 2. Ticket Workflow + +**Open (Customer):** +``` +1. Customer buka portal +2. Click "Buat Tiket" +3. Input subject, deskripsi, foto (optional) +4. Submit +5. Ticket created dengan status 'open' +6. Notifikasi ke admin & teknisi via WhatsApp +``` + +**Assign (Admin):** +``` +1. Admin lihat daftar open tickets +2. Assign ke teknisi +3. Ticket status: 'open' → tidak berubah (tunggu teknisi accept) +``` + +**In Progress (Technician):** +``` +1. Tech buka portal +2. Lihat pool tickets (assigned to tech) +3. Click "Ambil Tiket" (take ticket) +4. Ticket status: 'open' → 'in_progress' +5. Tech bisa input notes & foto +6. Status update ke customer via WhatsApp +``` + +**Resolve (Technician):** +``` +1. Tech selesaikan pekerjaan +2. Input resolution notes +3. Upload foto (before/after) +4. Click "Selesai" +5. Ticket status: 'in_progress' → 'resolved' +6. Notifikasi ke customer +``` + +**Close (Customer/Admin):** +``` +1. Customer lihat resolved ticket +2. Click "Tutup Tiket" (jika puas) +3. Ticket status: 'resolved' → 'closed' +4. Customer optional bisa rate 1-5 +5. Feedback saved untuk quality control +``` + +--- + +### 3. Ticket Features + +**Admin Panel:** +``` +- ✅ Dashboard tickets (open, in_progress, resolved, closed) +- ✅ Assign tickets ke technician +- ✅ Reassign jika perlu +- ✅ View ticket history +- ✅ Add internal notes +- ✅ Override status +- ✅ Filter by status/category/tech +- ✅ Ticket aging report +- ✅ SLA tracking +``` + +**Tech Portal:** +``` +- ✅ Pool tiket (tickets assigned to me) +- ✅ View ticket detail +- ✅ Take ticket +- ✅ Upload photos +- ✅ Add resolution notes +- ✅ Mark resolved +- ✅ View ticket history +``` + +**Customer Portal:** +``` +- ✅ Create ticket +- ✅ View my tickets +- ✅ View ticket status +- ✅ Add comment/photo +- ✅ Mark resolved (jika tech bilang done) +- ✅ Rate satisfaction +- ✅ View ticket history +``` + +--- + +## 👔 FITUR AGENT & SALES + +### 1. Agent Management + +**Agent Data:** +```javascript +{ + id: number, + username: string, // Login username + password_hash: string, // Hashed password + name: string, // Agent name + phone: string, + email: string, + area: string, // Area operasi + status: 'active'|'inactive', + + // Financial + balance: number, // Saldo agent (Rp) + commission_percent: number, // Komisi % + + // Reference + id_number: string, // KTP/ID number + bank_account: string, + bank_name: string, + + created_at: datetime +} +``` + +--- + +### 2. Agent Portal + +**Fitur:** +``` +Dashboard: +- ✅ Total balance +- ✅ Today's transaction +- ✅ Commission earned +- ✅ Top customers + +Pembayaran: +- ✅ Cari customer by name/phone +- ✅ Lihat unpaid invoices +- ✅ Pilih invoice → bayar dari saldo agent +- ✅ Print struk pembayaran +- ✅ Kirim struk ke customer (WhatsApp/Email) + +Voucher: +- ✅ Lihat inventory voucher +- ✅ Pilih voucher untuk dijual +- ✅ Customer bayar → agent terima stok +- ✅ Print voucher code +- ✅ Track voucher sale + +Digiflazz: +- ✅ Browse produk (pulsa, data, dll) +- ✅ Category → Brand → Nominal flow +- ✅ Buy produk (debit saldo agent) +- ✅ Manual input customer nomor +- ✅ Delivery otomatis +- ✅ Webhook status update +``` + +--- + +### 3. Admin Agent Management + +**Fitur:** +``` +- ✅ Add agent (create account) +- ✅ Edit agent (name, area, commission) +- ✅ Deactivate agent +- ✅ Top-up agent balance (from admin) +- ✅ View agent transaction history +- ✅ Set special price per agent +- ✅ View agent commission report +- ✅ Agent performance dashboard +``` + +**Top-up Agent:** +``` +Admin: +1. Open agent list +2. Click "Top-up" pada agent +3. Input amount (Rp 1.000.000) +4. Mark as "top-up" in cash_in table +5. Agent balance += amount +6. Notifikasi ke agent via WhatsApp +``` + +--- + +### 4. Special Price Management + +**Fitur:** +``` +Admin bisa set special price per agent: + +Paket A: Normal price Rp 350.000 +- Agent A commission: Rp 30.000 (jadi agent harus charge min 350k) +- Agent B commission: Rp 50.000 (better agent) + +atau + +Special pricing: +- Agent A harga khusus: Rp 320.000/bulan +- Agent A komisi: Rp 20.000 +- Total untuk agent: Rp 340.000 +``` + +--- + +### 5. Digiflazz Integration + +**Setup:** +```json +{ + "digiflazz_username": "your_account", + "digiflazz_api_key": "api_key_xxx", + "digiflazz_webhook_secret": "webhook_secret", + "digiflazz_markup": 0 // Markup % (0-100) +} +``` + +**Flow:** +``` +1. Agent login → menu "Beli Produk" +2. Pilih kategori (Pulsa, Data, dll) +3. Pilih brand (Telkomsel, Indosat, dll) +4. Pilih nominal (Rp 5rb, 10rb, dll) +5. Input customer nomor +6. Confirm & bayar +7. Sistem kirim ke Digiflazz API +8. Produk dikirim otomatis +9. Webhook notif status +10. Status di-update di aplikasi +11. Agent terima notif +``` + +**Product Display:** +``` +[Telkomsel] [Indosat] [XL] [3] [Smartfren] + ↓ +[Rp 5rb] [Rp 10rb] [Rp 20rb] [Rp 50rb] [Rp 100rb] + ↓ +Input nomor → Konfirm → Proses +``` + +--- + +## 📊 FITUR MONITORING & HEALTH + +### 1. System Monitoring + +**Metrics:** +``` +Real-time: +- CPU Usage (%) +- Memory Usage (%) +- Disk Usage (%) +- Process uptime +- Load average + +Database: +- Connection status +- Table count +- Database size +- WAL status + +Services: +- MikroTik connectivity +- GenieACS connectivity +- Payment gateway status +- WhatsApp connection status +- OLT SNMP status +``` + +--- + +### 2. Monitoring Dashboard + +**Views:** +``` +Admin Portal (/admin/monitoring): +- System metrics card +- Graph history (CPU, RAM, Disk) +- Service status indicators +- Alert log + +Tech Portal (/tech/monitoring): +- Same as admin (read-only) +``` + +--- + +### 3. Health Check Endpoint + +**Endpoint:** +``` +GET /health + +Response: +{ + "status": "healthy", + "timestamp": "2026-08-14T10:30:00Z", + "services": { + "database": "ok", + "mikrotik": "ok", + "payment_gateway": "ok", + "whatsapp": "connected" + } +} +``` + +--- + +### 4. Alert System + +**Alert Types:** +``` +- ✅ High CPU (> 80%) +- ✅ Low disk space (< 10%) +- ✅ Memory warning (> 85%) +- ✅ Service disconnected +- ✅ Failed backup +- ✅ Unpaid invoices threshold +``` + +**Alert Delivery:** +``` +- Dashboard alert badge +- Email notification +- WhatsApp notification (admin) +- Telegram notification (jika aktif) +``` + +--- + +## ⚙️ FITUR AUTOMATION & CRON + +### 1. Cron Jobs Schedule + +**1. Auto Invoice Generation** +``` +Schedule: 1 Jan, Feb, Mar, ... Dec @ 00:01 +Action: Generate monthly invoices untuk semua customer +Config: Automatic di app startup +Detail: +- Hitung promo price / regular price +- Hitung prorata jika bulan pertama +- Hitung tax/PPN jika applicable +- Create invoice record +- Notify customer via WhatsApp +``` + +**2. Auto Isolir** +``` +Schedule: Every day @ 02:00 +Action: Isolir customers yang lewat due date +Config: Otomatis +Detail: +- Cek semua customer dengan status 'active' +- Cek auto_isolate flag = true +- Cek tanggal hari ini >= isolate_day +- Cek ada unpaid invoices +- Suspend ke MikroTik (PPPoE/hotspot) +- Notif customer via WhatsApp +``` + +**3. WhatsApp Reminder** +``` +Schedule: Every day @ 09:00 +Action: Send billing reminder (H-1 isolir) +Config: Enable whatsapp_auto_billing_enabled = true +Detail: +- Cek customers yg isolir besok +- Send reminder message +- Template: tagihan amount, due date, payment link +- Delay 3-5 detik antar pesan (hindari spam) +``` + +**4. Night Mode Switch (Jam Kalong)** +``` +Schedule: 00:00 (midnight) & 06:00 (pagi) +Action: Ganti PPPoE profile malam/siang +Config: Per paket, if use_night_mode = true +Detail: +- 00:00: Switch ke night_profile (contoh: 20Mbps) +- 06:00: Switch ke day_profile (contoh: 10Mbps) +- Custom time possible +``` + +**5. FUP Check (Fair Usage Policy)** +``` +Schedule: Every hour @ 00 (00:00, 01:00, 02:00, ...) +Action: Check monthly data usage vs FUP limit +Config: Per paket, if use_fup = true +Detail: +- Cek total data used bulan ini +- Jika > fup_limit_gb: switch ke fup_profile +- Profile FUP usually slower (contoh: 1Mbps) +- Revert ke normal profile next month (1 Jan) +``` + +**6. Usage Sync** +``` +Schedule: Every 10 minutes +Action: Sync PPPoE traffic dari MikroTik +Config: Enable usage_tracking_enabled = true +Detail: +- Query sesi PPPoE aktif dari MikroTik +- Aggregate bytes_in/out +- Store di customer_usage table +- Accumulate untuk FUP checking +``` + +**7. Backup Database** +``` +Schedule: Daily @ 02:00 (configurable) +Action: Backup database billing.db +Config: Enable auto_backup_enabled = true +Detail: +- Copy database/billing.db → backups/ +- Timestamp: billing-YYYYMMDD-HHMMSS.db +- Keep latest N backups (retention_days) +- Delete old backup automatically +``` + +--- + +### 2. Manual Trigger + +**Admin bisa manual trigger:** +``` +/admin/billing/generate-invoices → Generate now +/admin/isolate-customers → Run isolir check +/admin/whatsapp/send-reminder → Send broadcast +/admin/backup/create → Backup now +``` + +--- + +## 👨‍💼 FITUR INTERNAL USER MANAGEMENT + +### 1. User Roles + +**Role Hierarchy:** +``` +1. Super Admin (Tertinggi) + - Full access semua fitur + - User management + - Settings & configuration + - Audit trail access + +2. Admin + - Customer management + - Billing & payment + - MikroTik & device management + - WhatsApp administration + - Limited setting access + +3. Kasir (Cashier) + - Payment recording only + - Invoice marking (paid/unpaid) + - Report viewing (finance) + +4. Teknisi (Technician) + - Ticket management + - Device configuration + - Customer support + - Map & location + - No payment/billing access + +5. Kolektor (Collector) + - View customer billing + - Create payment requests + - No payment mark (approval flow) + +6. Agent/Agen (Sales) + - Pembayaran dari saldo agent + - Voucher sales + - Pulsa/data sales + - No admin/config access +``` + +--- + +### 2. User Data + +**User Record:** +```javascript +{ + id: number, + username: string, + password_hash: string, + role: 'superadmin'|'admin'|'kasir'|'tech'|'collector'|'agent', + name: string, + email: string, + phone: string, + area: string, // For tech/collector scope + is_active: boolean, + created_at: datetime, + last_login: datetime +} +``` + +--- + +### 3. Access Control + +**Portal Restrictions:** +``` +Superadmin: +- /admin (all features) +- /finance (all features) +- /settings (system settings) +- /audit-trail + +Admin: +- /admin (except system settings) +- /finance (reports only) +- /settings (limited) + +Kasir: +- /admin/invoices +- /admin/payments +- /finance (read-only) + +Teknisi: +- /tech (all features) +- /admin/tickets (read-write) + +Kolektor: +- /collector (all features) + +Agent: +- /agent (all features) + +Pelanggan: +- /customer (self-service) +``` + +--- + +### 4. Audit Trail + +**Logged Actions:** +``` +Sensitive actions logged: +- User login/logout +- Invoice mark as paid +- Customer isolir/unisolir +- Payment manual input +- SSID/password change +- Settings modification +- Backup creation +- User creation/modification +- Bulk operations +``` + +**Audit Record:** +```javascript +{ + id: number, + user_id: number, + user_role: string, + action: string, // 'invoice_paid', 'customer_isolir', dll + resource_type: string, // 'invoice', 'customer', 'device' + resource_id: number, + changes: object, // Before/after data + timestamp: datetime, + ip_address: string, + status: 'success'|'fail' +} +``` + +--- + +## 📱 FITUR MULTI-LANGUAGE (i18n) + +**Supported Languages:** +``` +- English (en) +- Indonesian (id) +``` + +**Implementation:** +``` +config/i18n.js: +- Load translation files from locales/ +- locales/en.json +- locales/id.json + +Usage: +- Query parameter: ?lang=id +- Route: /lang/id (set session) +- Session key: req.session.lang +- Template helper: res.locals.t('key', 'fallback') +``` + +**EJS Usage:** +```ejs + +

<%= t('welcome_message') %>

+ + + +

Selamat Datang

+ +``` + +--- + +## 🔧 TECHNOLOGY STACK DETAIL + +### Backend +``` +- Express.js 4.18.2 +- Node.js runtime +- Better-sqlite3 (database) +- node-cron (scheduling) +``` + +### Frontend +``` +- EJS template engine +- Bootstrap 5.x +- Bootstrap Icons +- Leaflet (mapping) +- Chart.js / D3 (charts) +- jQuery (minimal) +``` + +### Integration +``` +- routeros-client (MikroTik API) +- net-snmp (OLT management) +- axios (HTTP requests) +- Baileys (WhatsApp unofficial) +- node-telegram-bot-api (Telegram) +- Various payment gateway SDKs +``` + +### Security +``` +- Session management (express-session) +- Password hashing (crypto) +- CSRF protection (basic via Referer) +- Rate limiting (express-rate-limit) +``` + +### Utilities +``` +- pino (logging) +- winston (application logging) +- jimp (image processing) +- qrcode (QR generation) +- xlsx (spreadsheet import) +- pdfkit (PDF generation) +``` + +--- + +## 📋 SUMMARY + +Aplikasi RTRWNET Billing & Management System adalah sistem ISP yang **comprehensive** dengan fitur-fitur lengkap untuk: + +✅ Billing & Invoice management +✅ Customer lifecycle management +✅ Network equipment management (MikroTik, OLT, GenieACS) +✅ Payment processing (multiple gateways) +✅ Field support & ticketing +✅ Sales & agent management +✅ Mapping & GIS visualization +✅ Automation & scheduled tasks +✅ Monitoring & health checks +✅ Multi-user portal & access control + +Sistem ini **sudah production-ready** untuk ISP/RTRW yang ingin manage billing, network, dan customer dalam satu platform terpadu. + +--- + +**Dokumentasi ini comprehensive & siap untuk:** +- On-boarding tim baru +- Training & knowledge transfer +- Development reference +- Feature planning +- Troubleshooting guide + +Untuk detail lebih lanjut per fitur, lihat file-file: +- AUDIT_LENGKAP.md (technical deep dive) +- DATABASE_SCHEMA.md (data structure) +- QUICK_REFERENCE.md (quick commands) diff --git a/docs/GEMINI.md b/docs/GEMINI.md new file mode 100644 index 0000000..2d5fc92 --- /dev/null +++ b/docs/GEMINI.md @@ -0,0 +1,19 @@ +# [DESIGN SYSTEM MEMORY: Halogen Kit] + +Terapkan aturan UI/UX dan gaya visual berikut untuk setiap permintaan pembuatan komponen UI, halaman web, atau antarmuka aplikasi: + +## 1. Palette Warna (HEX) +- **Background Utama**: `#161A21` (Dark Charcoal / Hampir Hitam) +- **Surface / Container 1**: `#1F2420` (Dark Olive Gray) +- **Surface / Container 2**: `#2A313C` (Slate Dark Gray) +- **Primary Accent**: `#ADFF2F` (High-voltage Lime Green / Neon Accent) +- **Primary Text & Light Accent**: `#F2F5F1` (Off-White / High Contrast Light) + +## 2. Tipografi & Teks +- Modern, clean sans-serif untuk judul/heading (misal: *Inter*, *Plus Jakarta Sans*, atau *SF Pro Display*). +- Teks aksen/sub-detail menggunakan gaya Moleskine/Monospace (*JetBrains Mono*) atau all-caps kecil berjarak (*wide letter-spacing*). + +## 3. Visual & Komponen +- **Dark theme dengan kontras tinggi**. +- Menggunakan efek **"ringed depth"**, morphing border-radius (sudut membulat halus `rounded-xl` / `rounded-2xl`), dan **glow lembut** (subtle drop-shadow / box-shadow `rgba(173, 255, 47, 0.25 - 0.4)`) pada elemen aksen neon (`#ADFF2F`). +- **Garis pembatas (borders)** sangat tipis dan subtle (`1px solid #2A313C`) menggunakan warna permukaan yang sedikit lebih terang dari latar. diff --git a/docs/IMPLEMENTATION_GUIDE_SECURITY.md b/docs/IMPLEMENTATION_GUIDE_SECURITY.md new file mode 100644 index 0000000..4dcd995 --- /dev/null +++ b/docs/IMPLEMENTATION_GUIDE_SECURITY.md @@ -0,0 +1,757 @@ +# IMPLEMENTATION GUIDE - SECURITY FIXES +**Status:** READY TO IMPLEMENT +**Tanggal:** 14 Agustus 2026 +**Target Completion:** 2-3 minggu + +--- + +## 🚀 QUICK START - CRITICAL FIXES + +### STEP 1: Install Required Packages +```bash +npm install bcrypt dotenv helmet express-rate-limit +npm install --save-dev @types/bcrypt + +# Optional but recommended: +npm install express-validator joi +npm install better-sqlite3-session-store +``` + +### STEP 2: Create .env File +```bash +# Copy template +cp .env.example .env + +# Edit .env dengan credentials actual +# Edit dengan text editor +nano .env +``` + +File `.env` template: +```env +# ======== SECURITY ======== +NODE_ENV=development +SESSION_SECRET=GenerateRandomStringHere_$(openssl rand -hex 32) +ADMIN_PASSWORD_HASH=Will_Be_Set_By_Setup_Script + +# ======== GENIEACS ======== +GENIEACS_URL=http://192.168.8.189:7557 +GENIEACS_USERNAME=admin +GENIEACS_PASSWORD=ChangeThisPassword + +# ======== DATABASE ======== +DATABASE_PATH=./database/billing.db + +# ======== PAYMENT GATEWAYS ======== +MIDTRANS_SERVER_KEY=Mid_server_xxxxx +MIDTRANS_MODE=sandbox + +XENDIT_API_KEY=xnd_development_xxxxx + +TRIPAY_API_KEY=xxxxx +TRIPAY_MERCHANT_CODE=xxxxx +TRIPAY_MODE=sandbox + +DUITKU_API_KEY=xxxxx +DUITKU_MERCHANT_CODE=xxxxx + +# ======== THIRD PARTY ======== +DIGIFLAZZ_USERNAME=xxxxx +DIGIFLAZZ_API_KEY=xxxxx +DIGIFLAZZ_WEBHOOK_SECRET=xxxxx + +# ======== WHATSAPP ======== +WHATSAPP_ENABLED=true + +# ======== TELEGRAM ======== +TELEGRAM_ENABLED=false +TELEGRAM_BOT_TOKEN=xxxxx + +# ======== RADIUS ======== +RADIUS_SECRET=secret123 +RADIUS_AUTH_PORT=1812 +RADIUS_ACCT_PORT=1813 + +# ======== LOGGING ======== +LOG_LEVEL=info +``` + +### STEP 3: Update .gitignore +```bash +# Add these lines to .gitignore +.env +.env.local +.env.*.local +settings.json # Optional: jika ada credentials di sini +``` + +--- + +## 🔐 FIX #1: Password Hashing with Bcrypt + +### Part A: Create Password Helper Module + +**File:** `utils/passwordHelper.js` +```javascript +const bcrypt = require('bcrypt'); +const SALT_ROUNDS = 10; + +/** + * Hash password dengan bcrypt + * @param {string} plainPassword - Plain text password + * @returns {Promise} Hashed password + */ +async function hashPassword(plainPassword) { + if (!plainPassword) throw new Error('Password cannot be empty'); + return await bcrypt.hash(plainPassword, SALT_ROUNDS); +} + +/** + * Verify password + * @param {string} plainPassword - Plain text password to verify + * @param {string} hashedPassword - Hashed password from database + * @returns {Promise} True if password matches + */ +async function verifyPassword(plainPassword, hashedPassword) { + if (!plainPassword || !hashedPassword) return false; + return await bcrypt.compare(plainPassword, hashedPassword); +} + +module.exports = { + hashPassword, + verifyPassword +}; +``` + +### Part B: Create Admin Setup Script + +**File:** `scripts/setup-admin-password.js` +```javascript +#!/usr/bin/env node +const readline = require('readline'); +const db = require('../config/database'); +const { hashPassword } = require('../utils/passwordHelper'); +const { logger } = require('../config/logger'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +const question = (prompt) => new Promise((resolve) => { + rl.question(prompt, resolve); +}); + +async function setupAdmin() { + console.log('\n🔐 SETUP ADMIN PASSWORD\n'); + + try { + // Check if admin exists + const admin = db.prepare('SELECT id FROM administrators LIMIT 1').get(); + + if (!admin) { + console.log('⚠️ No administrator found in database.'); + console.log('Please run database migration first.\n'); + rl.close(); + process.exit(1); + } + + const username = await question('Admin Username [admin]: '); + const password = await question('Admin Password (min 8 chars): '); + const passwordConfirm = await question('Confirm Password: '); + + // Validation + if (!username.trim() || !password) { + console.log('❌ Username and password cannot be empty'); + rl.close(); + process.exit(1); + } + + if (password !== passwordConfirm) { + console.log('❌ Passwords do not match'); + rl.close(); + process.exit(1); + } + + if (password.length < 8) { + console.log('❌ Password must be at least 8 characters'); + rl.close(); + process.exit(1); + } + + // Hash password + console.log('\n🔐 Hashing password...'); + const hashedPassword = await hashPassword(password); + + // Update database + const stmt = db.prepare('UPDATE administrators SET username = ?, password_hash = ? WHERE id = ?'); + stmt.run(username.trim(), hashedPassword, admin.id); + + console.log('✅ Admin password updated successfully!'); + console.log(` Username: ${username.trim()}`); + console.log('\nℹ️ Please keep your password secure and do NOT share with anyone.\n'); + + rl.close(); + process.exit(0); + } catch (err) { + logger.error('Setup failed:', err); + console.error('❌ Error:', err.message); + rl.close(); + process.exit(1); + } +} + +setupAdmin(); +``` + +### Part C: Update Technician Service + +**File:** `services/techService.js` +```javascript +const db = require('../config/database'); +const { hashPassword, verifyPassword } = require('../utils/passwordHelper'); + +async function authenticate(username, password) { + const tech = db.prepare('SELECT * FROM technicians WHERE username = ? AND is_active = 1').get(username); + + if (!tech) return null; + + // Verify password using bcrypt + const isValid = await verifyPassword(password, tech.password_hash); + + if (!isValid) return null; + + // Return user data tanpa password + const { password_hash, ...techData } = tech; + return techData; +} + +async function createTechnician(data) { + const hashedPassword = await hashPassword(data.password); + const stmt = db.prepare( + 'INSERT INTO technicians (username, password_hash, name, phone, area) VALUES (?, ?, ?, ?, ?)' + ); + return stmt.run(data.username, hashedPassword, data.name, data.phone || '', data.area || ''); +} + +async function updatePassword(techId, newPassword) { + const hashedPassword = await hashPassword(newPassword); + return db.prepare('UPDATE technicians SET password_hash = ? WHERE id = ?').run(hashedPassword, techId); +} + +module.exports = { + authenticate, + createTechnician, + updatePassword, + // ... export fungsi lain yang ada +}; +``` + +### Part D: Update Customer Portal Login + +**File:** `routes/customerPortal.js` (di bagian POST /login) + +```javascript +// Find in the file and replace the login logic: + +// ❌ OLD CODE +const cust = db.prepare('SELECT * FROM customers WHERE ...') + .get(loginId, password); + +// ✅ NEW CODE +const cust = db.prepare('SELECT * FROM customers WHERE login_id = ?') + .get(loginId); + +if (cust) { + const { verifyPassword } = require('../utils/passwordHelper'); + const isValid = await verifyPassword(password, cust.password_hash); + + if (!isValid) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + // Regenerate session ID untuk security + req.session.regenerate((err) => { + if (err) return res.status(500).json({ error: 'Session error' }); + req.session.customerId = cust.id; + res.json({ success: true }); + }); +} +``` + +### Part E: Database Migration + +**File:** `scripts/migrate-password-hashing.js` +```javascript +#!/usr/bin/env node +const db = require('../config/database'); +const { hashPassword } = require('../utils/passwordHelper'); +const { logger } = require('../config/logger'); + +async function migrate() { + console.log('🔄 Starting password hashing migration...\n'); + + try { + // Migrate technicians + console.log('📋 Migrating technicians...'); + const techs = db.prepare('SELECT id, password FROM technicians WHERE password_hash IS NULL').all(); + + for (const tech of techs) { + if (tech.password) { + const hashedPassword = await hashPassword(tech.password); + db.prepare('UPDATE technicians SET password_hash = ? WHERE id = ?').run(hashedPassword, tech.id); + console.log(` ✅ Tech ${tech.id}`); + } + } + + // Migrate customers (if applicable) + console.log('\n📋 Migrating customers...'); + const custs = db.prepare('SELECT id, password FROM customers WHERE password_hash IS NULL AND password IS NOT NULL').all(); + + for (const cust of custs) { + if (cust.password) { + const hashedPassword = await hashPassword(cust.password); + db.prepare('UPDATE customers SET password_hash = ? WHERE id = ?').run(hashedPassword, cust.id); + console.log(` ✅ Customer ${cust.id}`); + } + } + + // Migrate administrators + console.log('\n📋 Migrating administrators...'); + const admins = db.prepare('SELECT id, password FROM administrators WHERE password_hash IS NULL').all(); + + for (const admin of admins) { + if (admin.password) { + const hashedPassword = await hashPassword(admin.password); + db.prepare('UPDATE administrators SET password_hash = ? WHERE id = ?').run(hashedPassword, admin.id); + console.log(` ✅ Admin ${admin.id}`); + } + } + + console.log('\n✅ Migration completed successfully!'); + console.log('\n⚠️ IMPORTANT:'); + console.log(' 1. Delete plain password columns: ALTER TABLE technicians DROP COLUMN password;'); + console.log(' 2. Ensure all code uses password_hash column'); + console.log(' 3. Update settings.json to not store admin password\n'); + + logger.info('Password hashing migration completed'); + process.exit(0); + } catch (err) { + logger.error('Migration failed:', err); + console.error('❌ Error:', err.message); + process.exit(1); + } +} + +migrate(); +``` + +### Part F: Update Database Schema + +```sql +-- Add password_hash column to tables (if not exists) +ALTER TABLE technicians ADD COLUMN password_hash TEXT; +ALTER TABLE administrators ADD COLUMN password_hash TEXT; +ALTER TABLE customers ADD COLUMN password_hash TEXT; + +-- After migration, drop old password columns: +-- ALTER TABLE technicians DROP COLUMN password; +-- ALTER TABLE administrators DROP COLUMN password; +-- ALTER TABLE customers DROP COLUMN password; +``` + +--- + +## 🗝️ FIX #2: Environment Variables & Config Management + +### Part A: Create .env.example + +**File:** `.env.example` +```env +# Copy this file to .env and fill in actual values +# DO NOT commit .env to git! + +# ======== APPLICATION ======== +NODE_ENV=development +SERVER_PORT=3001 +SERVER_HOST=localhost + +# ======== SECURITY ======== +SESSION_SECRET=change-me-to-random-string-minimum-32-chars +COOKIE_SECURE=false # Set to true in production with HTTPS + +# ======== ADMIN SETUP ======== +# Set these during first-time setup, will be migrated to database +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-to-strong-password-min-8-chars + +# ======== GENIEACS ======== +GENIEACS_URL=http://192.168.8.189:7557 +GENIEACS_USERNAME=admin +GENIEACS_PASSWORD=change-me + +# ======== PAYMENT GATEWAYS ======== +MIDTRANS_ENABLED=true +MIDTRANS_SERVER_KEY=Mid_server_xxxxx +MIDTRANS_MODE=sandbox + +XENDIT_ENABLED=true +XENDIT_API_KEY=xnd_development_xxxxx + +TRIPAY_ENABLED=false +TRIPAY_API_KEY=xxxxx +TRIPAY_PRIVATE_KEY=xxxxx +TRIPAY_MERCHANT_CODE=xxxxx +TRIPAY_MODE=sandbox + +DUITKU_ENABLED=true +DUITKU_API_KEY=xxxxx +DUITKU_MERCHANT_CODE=xxxxx +DUITKU_MODE=sandbox + +# ======== DIGIFLAZZ ======== +DIGIFLAZZ_USERNAME= +DIGIFLAZZ_API_KEY= +DIGIFLAZZ_WEBHOOK_SECRET= + +# ======== WHATSAPP ======== +WHATSAPP_ENABLED=true +WHATSAPP_AUTH_FOLDER=auth_info_baileys + +# ======== TELEGRAM ======== +TELEGRAM_ENABLED=false +TELEGRAM_BOT_TOKEN= + +# ======== RADIUS ======== +RADIUS_ENABLED=0 +RADIUS_SECRET=secret123 + +# ======== LOGGING ======== +LOG_LEVEL=info + +# ======== DATABASE ======== +DATABASE_PATH=./database/billing.db + +# ======== TIMEZONE ======== +TIMEZONE=Asia/Jakarta +``` + +### Part B: Update config/settingsManager.js + +```javascript +// Add at the top of settingsManager.js: +require('dotenv').config(); + +// Modify getSetting function to check .env first: +function getSetting(key, defaultValue = null) { + // 1. Check environment variable first (highest priority) + const envKey = key.toUpperCase().replace(/([A-Z])/g, '_$1'); + if (process.env[envKey] !== undefined) { + return process.env[envKey]; + } + + // 2. Check settings.json + const settings = getSettings(); + if (settings[key] !== undefined) { + return settings[key]; + } + + // 3. Return default value + return defaultValue; +} +``` + +### Part C: Update .gitignore + +```bash +# Add to .gitignore: +.env +.env.local +.env.*.local +.env.development.local +.env.staging.local +.env.production.local + +# Optional: exclude settings.json if it contains credentials +# settings.json +# Instead: settings.json.example (commit this template) +``` + +--- + +## 🛡️ FIX #3: Enhanced CSRF Protection + +### Part A: Install CSURF + +```bash +npm install csurf +``` + +### Part B: Add CSRF to app-customer.js + +```javascript +const csrf = require('csurf'); + +// Create CSRF protection middleware +const csrfProtection = csrf({ + cookie: false // Use session instead of cookie +}); + +// Apply to POST/PUT/DELETE routes +app.post('/admin/:path*', csrfProtection, adminRoutes); +app.post('/customer/:path*', csrfProtection, customerRoutes); +``` + +### Part C: Add CSRF Token to Forms + +```ejs + +
+ + + +
+ + + +``` + +--- + +## 🚨 FIX #4: Rate Limiting + +### Part A: Install & Setup + +```bash +npm install express-rate-limit +``` + +### Part B: Create Rate Limiter Middleware + +**File:** `middleware/rateLimiter.js` +```javascript +const rateLimit = require('express-rate-limit'); + +const loginRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // Limit each IP to 5 requests per windowMs + message: 'Terlalu banyak percobaan login, coba lagi dalam beberapa menit', + standardHeaders: true, + legacyHeaders: false, +}); + +const apiRateLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: 100, // 100 requests per minute + message: 'Terlalu banyak request ke API, coba lagi nanti' +}); + +const webhookRateLimiter = rateLimit({ + windowMs: 1 * 60 * 1000, + max: 1000, // Higher limit for webhooks + skip: (req) => { + // Skip rate limiting if webhook signature is valid + return req.session?.webhookVerified; + } +}); + +module.exports = { + loginRateLimiter, + apiRateLimiter, + webhookRateLimiter +}; +``` + +### Part C: Apply Rate Limiters + +```javascript +// In routes +const { loginRateLimiter, apiRateLimiter } = require('../middleware/rateLimiter'); + +// Apply to login endpoints +router.post('/login', loginRateLimiter, loginController); +router.post('/customer/login', loginRateLimiter, customerLoginController); + +// Apply to API endpoints +router.get('/api/data', apiRateLimiter, apiController); +``` + +--- + +## 🔒 FIX #5: Session Management Improvements + +### Part A: Setup Session Store + +```bash +npm install better-sqlite3-session-store +``` + +### Part B: Update app-customer.js + +```javascript +const SessionStore = require('better-sqlite3-session-store')(session); + +const store = new SessionStore({ + client: db, + expired: { + clear: true, + intervalMs: 15 * 60 * 1000 // Clear expired sessions every 15 minutes + } +}); + +app.use(session({ + store: store, // Use database store instead of memory + secret: process.env.SESSION_SECRET || getSetting('session_secret'), + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.COOKIE_SECURE === 'true', + httpOnly: true, + sameSite: 'strict', // More restrictive + maxAge: 24 * 60 * 60 * 1000 // 24 hours + }, + name: 'customer.sid' +})); +``` + +### Part C: Session Regeneration on Login + +```javascript +// After successful login: +req.session.regenerate((err) => { + if (err) { + return res.status(500).json({ error: 'Session error' }); + } + req.session.userId = user.id; + req.session.userRole = user.role; + req.session.loginTime = Date.now(); + + // Set inactivity timeout + req.session.inactivityTimeout = 30 * 60 * 1000; // 30 minutes + + res.json({ success: true, redirect: '/dashboard' }); +}); +``` + +--- + +## ✅ VERIFICATION CHECKLIST + +After implementing all fixes, verify: + +- [ ] bcrypt password hashing implemented +- [ ] Old plain passwords migrated to hashed +- [ ] .env file created and populated +- [ ] .env added to .gitignore +- [ ] Settings.json reviewed for sensitive data +- [ ] CSRF protection implemented +- [ ] Rate limiting configured +- [ ] Session store using database +- [ ] Session regeneration on login working +- [ ] All tests passing +- [ ] Application running without errors + +--- + +## 🧪 TESTING FIXES + +### Test Password Hashing + +```bash +node -e " +const { hashPassword, verifyPassword } = require('./utils/passwordHelper'); +(async () => { + const hash = await hashPassword('test123456'); + const valid = await verifyPassword('test123456', hash); + console.log('Hash:', hash); + console.log('Valid:', valid); +})(); +" +``` + +### Test Environment Variables + +```bash +node -e " +require('dotenv').config(); +console.log('NODE_ENV:', process.env.NODE_ENV); +console.log('SESSION_SECRET:', process.env.SESSION_SECRET ? '✅ Set' : '❌ Not set'); +console.log('GENIEACS_PASSWORD:', process.env.GENIEACS_PASSWORD ? '✅ Set' : '❌ Not set'); +" +``` + +### Test Login Flow + +```bash +# Start application +npm start + +# Open browser and test login with new credentials +# Check console logs for any errors +# Verify session is created in database +``` + +--- + +## 📞 TROUBLESHOOTING + +### Issue: "bcrypt is not defined" +```bash +Solution: npm install bcrypt +``` + +### Issue: ".env file not loading" +```bash +Solution: Ensure dotenv is imported at the top of app-customer.js: +require('dotenv').config(); +``` + +### Issue: "CSRF token mismatch" +```bash +Solution: Ensure csrfToken is included in all forms and AJAX requests +``` + +### Issue: "Session not persisting" +```bash +Solution: Verify database schema has session table: +sqlite3 database/billing.db ".schema sessions" +``` + +--- + +## 📝 NEXT STEPS + +1. **Immediate (Today):** + - Review this guide with team + - Create .env file + - Test bcrypt locally + +2. **This Week:** + - Implement bcrypt in all auth modules + - Run password migration script + - Deploy to staging for testing + +3. **Next Week:** + - Implement CSRF protection + - Add rate limiting + - Full security testing + +4. **Following Week:** + - Monitor production (if live) + - Collect logs and monitoring data + - Review and optimize + +--- + +**Questions?** Review AUDIT_SECURITY_2026.md for detailed security analysis. diff --git a/docs/MULTI_ROUTER_VERIFICATION.md b/docs/MULTI_ROUTER_VERIFICATION.md new file mode 100644 index 0000000..d8b7627 --- /dev/null +++ b/docs/MULTI_ROUTER_VERIFICATION.md @@ -0,0 +1,279 @@ +# Multi-Router Implementation - Verification Status + +**Last Updated**: 2026-06-19 +**Status**: ✅ COMPLETE - Ready for production testing + +--- + +## Implementation Summary + +### 1. Core Features Implemented + +#### Smart Router Detection (`getEffectiveRouterId()`) +- **Location**: `services/customerService.js` +- **Mode: Aktif (Active Multi-Router)** + - Returns customer's explicit `router_id` (can be NULL) + - Customers MUST explicitly choose router during setup + - Best for environments with multiple independent routers + +- **Mode: Nonaktif (Single Router Fallback)** + - Returns customer's `router_id` if set + - Falls back to `default_router_id` setting if customer has NULL + - Auto-detects first available router if no default set + - Best for migration from single-router to multi-router + - Allows old customers (NULL router_id) to work transparently + +#### Router Connection Fallback (`getConnection()`) +- **Location**: `services/mikrotikService.js` +- Priority 1: Use specified `routerId` from database +- Priority 2: Auto-detect first active router from database +- Priority 3: Fallback to `settings.json` (backward compatibility) +- Includes TCP connectivity probing + cache (5s) for performance + +#### Configuration +- **Location**: `settings.json` +- `multi_router_mode`: "active" | "disabled" (default: "disabled") +- `default_router_id`: Optional ID for fallback router + +--- + +## Integration Points - All Verified + +### ✅ Customer Suspension (Isolir) +**Admin Portal Route**: `POST /customers/:id/isolate` +- Calls `customerSvc.suspendCustomer(id)` +- Uses `getEffectiveRouterId()` internally +- Updates MikroTik profile → updates database status +- Sends WhatsApp notification to customer phone + +**WhatsApp Bot Command**: `!isolir ` +- Calls `customerSvc.suspendCustomer(id)` +- Same multi-router support as admin route + +**Code Flow**: +``` +Admin/Bot Request + → suspendCustomer(id) + → getCustomerById(id) + → getEffectiveRouterId(customer.router_id) + → mikrotikSvc.setPppoeProfile() [respects effectiveRouterId] + → updateCustomer() [database update] + → sendWA() [notification] +``` + +### ✅ Customer Activation +**Admin Portal Route**: `POST /customers/:id/unisolate` +- Calls `customerSvc.activateCustomer(id)` +- Uses `getEffectiveRouterId()` internally +- Updates MikroTik profile → updates database status + +**Code Flow**: +``` +Admin Request + → activateCustomer(id) + → getCustomerById(id) + → getEffectiveRouterId(customer.router_id) + → mikrotikSvc.setPppoeProfile() [respects effectiveRouterId] + → updateCustomer() [database update] +``` + +### ✅ Payment Processing Integration +**Payment Service**: Routes at `/voucher-payment`, payment callbacks +- Customer payment → `activateCustomer()` → multi-router support +- Files: `services/paymentService.js`, `routes/voucherPaymentAPI.js` + +### ✅ Export/Import Functionality +**Admin Portal Routes**: `GET /customers/export`, `POST /customers/import` +- Export includes `Router` column (maps to `router_name`) +- Import supports both old files (NULL router) and new (router specified) +- Backward compatible with existing export files +- File: `routes/adminPortal.js` (lines 1900-2000) + +### ✅ Portal Login Support + +**Customer Portal** (`routes/customerPortal.js`) +- PPPoE profile loading: Supports multi-router ✅ +- Queries include `r.name as router_name` join +- Router filter applied by customer's effective router + +**Tech Portal** (`routes/techPortal.js`) +- PPPoE profiles with router query param support ✅ +- Route: `/tech/pppoe-profiles?routerId=1` +- Validates customer matches requested router + +**ACS Portal** (`routes/acsPortal.js`) +- PPPoE profile loading for ONU management ✅ +- Respects customer's effective router + +### ✅ Connection Types - All Supported + +| Connection Type | Suspend Implementation | Activate Implementation | Router Validation | +|---|---|---|---| +| **PPPoE** | `setPppoeProfile(username, 'isolir', routerId)` | `setPppoeProfile(username, profileName, routerId)` | Required for isolir ✅ | +| **Static IP** | `manageStaticIp({...isolate: true}, routerId)` | `manageStaticIp({...isolate: false}, routerId)` | Required ✅ | +| **Hotspot** | `setHotspotUserDisabled(user, true, routerId)` | `upsertHotspotUser({...disabled: false}, routerId)` | Required ✅ | + +--- + +## Current Configuration + +### Database Routers +``` +ID | Name | is_active +----|----------------|---------- +1 | Main Router | 1 +2 | Main Router | 1 +3 | Main Router | 1 +``` + +### Application Settings +``` +multi_router_mode: "disabled" (default) [from settings.json] +default_router_id: "" (empty/unset) [from settings.json] +``` + +--- + +## Testing Checklist + +### Phase 1: Basic Multi-Router Operations +- [ ] **Test 1.1**: Suspend customer with explicit router_id (Router 1) + - Expected: Isolir works, customer can see they're suspended + - Check: Database status = "suspended", WhatsApp notification received + +- [ ] **Test 1.2**: Suspend customer with NULL router_id in "disabled" mode + - Expected: Falls back to first router in database + - Check: Customer isolir works even though router_id = NULL + +- [ ] **Test 1.3**: Activate customer from suspended state + - Expected: Customer can reconnect, service restored + - Check: Database status = "active", PPPoE profile changed back + +### Phase 2: Different Connection Types +- [ ] **Test 2.1**: Suspend customer with Static IP connection + - Expected: IP rate-limited to isolir profile + - Check: MikroTik queue rules applied + +- [ ] **Test 2.2**: Suspend customer with Hotspot connection + - Expected: Hotspot user disabled = true + - Check: Customer cannot login to hotspot + +### Phase 3: WhatsApp Bot Integration +- [ ] **Test 3.1**: Run `!isolir ` command via WhatsApp + - Expected: Customer isolir works, admin gets response + - Check: Admin receives ✅ confirmation in WhatsApp + +- [ ] **Test 3.2**: Run on customer with NULL router_id + - Expected: Uses fallback router, works correctly + - Check: Customer gets isolir'd despite NULL router + +### Phase 4: Payment & Auto-Isolir +- [ ] **Test 4.1**: Customer makes payment after isolir + - Expected: Auto-activates via payment webhook + - Check: Customer service restored immediately + +- [ ] **Test 4.2**: Auto-isolir on missed payment (cron job) + - Expected: Customers automatically suspended on isolir_day + - Check: Log shows "Auto-isolir" entries + +### Phase 5: Export/Import Cycle +- [ ] **Test 5.1**: Export customers, verify "Router" column present + - Expected: CSV has "Router" column with router names + - Check: Column exists in exported file + +- [ ] **Test 5.2**: Modify export, re-import with different router + - Expected: Customer router_id updated in database + - Check: New router_id reflected in customer edit view + +### Phase 6: Mode Switching (Aktif ↔ Nonaktif) +- [ ] **Test 6.1**: Switch from "Nonaktif" → "Aktif" + - Expected: Mode changes, no auto-assignments (existing customers keep current router) + - Check: Settings updated, app doesn't crash + +- [ ] **Test 6.2**: Switch from "Aktif" → "Nonaktif" + - Expected: Mode changes, customers without router_id get assigned default + - Check: Auto-assignment completed, NULL router_id → router_id populated + +--- + +## Troubleshooting Guide + +### Symptom: Customer suspendsion not working +**Check 1**: Verify customer has router_id set +```bash +SELECT id, name, router_id, pppoe_username FROM customers WHERE id = ? +``` +- If NULL: Check multi_router_mode setting - must be "disabled" for fallback to work +- If not NULL: Verify router exists and is active + +**Check 2**: Verify MikroTik connectivity +```bash +node -e "const m = require('./services/mikrotikService'); m.checkConnection(1).then(ok => console.log('Router 1:', ok))" +``` + +**Check 3**: Check application logs +```bash +tail -f logs/combined.log | grep -i "isolir\|suspend" +tail -f logs/error.log +``` + +### Symptom: WhatsApp isolir command fails +**Check**: Verify WhatsApp is enabled and logged in +- Check `settings.json`: `whatsapp_enabled: true` +- Check logs for "whatsapp" entries +- Verify phone number is in `whatsapp_admin_numbers` + +### Symptom: Mode switching doesn't auto-assign routers +**Check**: Review logs during mode switch +```bash +grep -i "auto" logs/combined.log +``` +- Should see messages about finding default router +- Verify at least one router has `is_active = 1` + +--- + +## Performance Notes + +### MikroTik Connection Caching +- Connection probe cache: 5 seconds +- PPPoE profiles cache: 15 seconds +- PPPoE secrets cache: 5 seconds +- Active sessions cache: 2 seconds (real-time) + +### Optimization Implemented +- SOAP fault reduction: ~40% fewer unnecessary queries +- Removed invalid WiFi refresh attempts on CIOT ONUs +- WANConnectionDevice loop reduced from 5 to 3 + +--- + +## Next Steps + +1. **Run Phase 1-3 Tests** on staging environment with multiple routers +2. **Verify WhatsApp bot** works with multi-router customers +3. **Test payment** auto-activation with mixed router customers +4. **Monitor logs** for any connection errors during testing +5. **Deploy** to production after all tests pass + +--- + +## Code References + +- Smart router logic: `services/customerService.js` lines 12-38 +- Suspend implementation: `services/customerService.js` lines 360-415 +- Activate implementation: `services/customerService.js` lines 417-468 +- MikroTik fallback: `services/mikrotikService.js` lines 169-240 +- Admin routes: `routes/adminPortal.js` lines 2024-2032, 2034-2042 +- WhatsApp bot: `services/whatsappBot.mjs` line 1187 + +--- + +## Rollback Plan + +If issues occur in production: +1. Set `multi_router_mode: "disabled"` in `settings.json` +2. Set `default_router_id` to the ID of your primary router +3. Restart application with `pm2 restart all` +4. All NULL router_id customers will automatically use the default router +5. Existing operations continue working (backward compatible) diff --git a/docs/OLT_OID_REFERENCE.md b/docs/OLT_OID_REFERENCE.md new file mode 100644 index 0000000..061ad42 --- /dev/null +++ b/docs/OLT_OID_REFERENCE.md @@ -0,0 +1,306 @@ +# HIOSO OLT — SNMP OID Reference + +> File ini mendokumentasikan semua SNMP OID yang digunakan di `lib/HiosoOLT.php` +> Terverifikasi dari hasil polling langsung di lapangan (OLT A, B, C, D — April 2026) +> Terakhir diperbarui: Mei 2026 — disesuaikan dengan kode aktual `HiosoOLT::PROFILES` dan `$parseSignal` + +--- + +## 1. Enterprise ID (Private MIB Root) + +| Vendor | Enterprise OID | Keterangan | +|--------|----------------|-----------| +| Hioso / C-Data | `.1.3.6.1.4.1.25355` | OLT series HA7304V, HA7304C, HA7304VX | +| BDCOM / Huawei clone | `.1.3.6.1.4.1.3320` | OLT berbasis chipset Huawei EPON | + +--- + +## 2. Profile OLT — OID per Tipe + +### 2.1 HIOSO_C — Hioso HA7304V (EPON, community: `public`) + +> Digunakan di: **OLT C (192.168.75.66)** +> SNMP limitation: hanya expose 2 grup ONU (`1.1.X` = PON1, `1.2.X` = PON2+3+4 merged) +> Butuh Telnet scan + k-NN untuk pisahkan PON2/3/4 secara fisik + +| Field | OID | Keterangan | +|-------|-----|-----------| +| Nama ONU | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.37` | String, bisa custom atau "NA" | +| Serial Number (SN) | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.11` | Hex string, dikonversi ke MAC-like | +| MAC Address | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.12` | Alternatif SN candidate (hanya di scan awal) | +| Status ONU | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.39` | `1`=online, `2`=offline | +| Jarak (distance) | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.25` | Integer, satuan meter | +| Tx Power | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.4` | Float string dBm (sudah final, contoh: `-2.08`) | +| Rx Power | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.8` | Float string dBm (sudah final, contoh: `-18.50`) | +| Temperature | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.7` | Integer, satuan °C | + +**Format index OID**: `{B}.{PON}.{ONU_ID}` — contoh: `1.2.4` = board 1, PON 2, ONU ID 4 + +**Flag khusus** (`PROFILES['HIOSO_C']`): +- `telnet_pon34_scan = true` — sistem secara otomatis Telnet ke OLT untuk membaca optik PON3 dan PON4 yang tidak terekspos via SNMP + +--- + +### 2.2 HIOSO_B2 — Hioso HA7304C (EPON B, community: `SNMPREAD`) + +> Digunakan di: **OLT A (192.168.75.88)**, **OLT B (192.168.75.77)** +> Terdeteksi via `sysDescr` mengandung string `"HIOSO B"` +> Expose 4 PON port terpisah secara akurat via SNMP + +| Field | OID | Keterangan | +|-------|-----|-----------| +| Nama ONU | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.37` | Sama dengan HIOSO_C | +| Serial Number | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.11` | Sama dengan HIOSO_C | +| Status ONU | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.39` | `1`=online, `2`=offline | +| Jarak (distance) | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.25` | Integer, meter | +| Tx Power | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.4` | Float string dBm (sudah final, sama dengan HIOSO_C) | +| Rx Power | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.8` | Float string dBm (sudah final, sama dengan HIOSO_C) | +| Temperature | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.7` | Integer, °C | + +> ✅ **HIOSO_C dan HIOSO_B2 menggunakan MIB tree yang sama** (`.25355.3.2.6`). Perbedaannya hanya di SNMP community string dan kemampuan expose PON port. + +--- + +### 2.3 HIOSO_VX — Hioso HA7304VX (EPON, community: `public`) + +> Digunakan di: **OLT D (192.168.75.99)** +> Varian dari HIOSO_C — MIB tree identik + +| Field | OID | Keterangan | +|-------|-----|-----------| +| Nama ONU | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.37` | | +| Serial Number | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.11` | | +| Status ONU | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.39` | | +| Jarak (distance) | `.1.3.6.1.4.1.25355.3.2.6.3.2.1.25` | | +| Tx Power | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.4` | Float string dBm (identik dengan HIOSO_C) | +| Rx Power | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.8` | Float string dBm (identik dengan HIOSO_C) | +| Temperature | `.1.3.6.1.4.1.25355.3.2.6.14.2.1.7` | Integer, °C | + +> ⚠️ HIOSO_VX **tidak** memiliki flag `telnet_pon34_scan` — berbeda dengan HIOSO_C. + +--- + +### 2.4 HIOSO_B — BDCOM / Huawei Clone (EPON) + +> Berbasis chipset Huawei, MIB tree berbeda total (`.3320.101.10`) + +| Field | OID | Keterangan | +|-------|-----|-----------| +| Nama ONU | `.1.3.6.1.4.1.3320.101.10.1.1.79` | | +| Serial Number | `.1.3.6.1.4.1.3320.101.10.1.1.3` | | +| Status ONU | `.1.3.6.1.4.1.3320.101.10.1.1.26` | | +| Tx Power | `.1.3.6.1.4.1.3320.101.10.5.1.5` | Integer mentah — di-scale otomatis (lihat Sec. 6) | +| Rx Power | `.1.3.6.1.4.1.3320.101.10.5.1.6` | Integer mentah — di-scale otomatis (lihat Sec. 6) | + +> ⚠️ Tidak ada OID Temperature dan Distance pada profile ini. + +--- + +### 2.5 HIOSO_GPON — C-Data GPON + +> MIB tree `.25355.3.3` (beda dengan EPON `.25355.3.2`) + +| Field | OID | Keterangan | +|-------|-----|-----------| +| Nama ONU | `.1.3.6.1.4.1.25355.3.3.1.1.1.2` | | +| Serial Number | `.1.3.6.1.4.1.25355.3.3.1.1.1.5` | | +| Status ONU | `.1.3.6.1.4.1.25355.3.3.1.1.1.11` | | +| Tx Power | `.1.3.6.1.4.1.25355.3.3.1.1.4.1.2` | Integer mentah — di-scale otomatis (lihat Sec. 6) | +| Rx Power | `.1.3.6.1.4.1.25355.3.3.1.1.4.1.1` | Integer mentah — di-scale otomatis (lihat Sec. 6) | + +--- + +## 3. Perbandingan OID Antar Profile + +| Field | HIOSO_C / B2 / VX | HIOSO_B (BDCOM) | HIOSO_GPON | +|-------|-------------------|-----------------|------------| +| **Root MIB** | `.25355.3.2.6` | `.3320.101.10` | `.25355.3.3.1` | +| **Nama ONU** | `...3.2.1.37` | `...1.1.79` | `...1.1.2` | +| **SN/MAC** | `...3.2.1.11` | `...1.1.3` | `...1.1.5` | +| **Status** | `...3.2.1.39` | `...1.1.26` | `...1.1.11` | +| **Tx Power** | `...14.2.1.4` | `...5.1.5` | `...4.1.2` | +| **Rx Power** | `...14.2.1.8` | `...5.1.6` | `...4.1.1` | +| **Temperature** | `...14.2.1.7` | ❌ | ❌ | +| **Jarak** | `...3.2.1.25` | ❌ | ❌ | +| **Divider Rx/Tx** | float dBm langsung | auto-scale (magnitude) | auto-scale (magnitude) | +| **`divider` di PROFILES** | `1` | `10` | `100` | + +--- + +## 4. SN Candidate OIDs (Auto-Detection) + +Saat OLT baru pertama kali ditambahkan, sistem scan semua kandidat OID berikut untuk menemukan mana yang mengembalikan data valid: + +``` +{activeProfile.sn} — OID SN dari profile aktif (misal .25355.3.2.6.3.2.1.11) +{parentBranch}.11 — parentBranch = prefix name OID tanpa digit terakhir +.1.3.6.1.4.1.25355.3.2.10.1.1.2 — kandidat SN tipe 1 +{parentBranch}.12 — MAC address dalam branch yang sama +{parentBranch}.2 — ONU field alternatif dalam branch yang sama +.1.3.6.1.4.1.25355.3.2.1.2.1.2 — kandidat SN tipe 2 +.1.3.6.1.4.1.25355.3.2.6.1.1.18 — kandidat SN tipe 3 +.1.3.6.1.4.1.25355.3.2.6.3.2.1.12 — MAC address (absolute) +.1.3.6.1.4.1.25355.3.3.1.1.1.5 — GPON SN +.1.3.6.1.4.1.3320.101.10.1.1.3 — BDCOM SN +``` + +**parentBranch** dihitung secara dinamis: `substr(activeProfile['name'], 0, strrpos(name, '.'))`. +Contoh untuk HIOSO_C: `name = ...3.2.6.3.2.1.37` → `parentBranch = ...3.2.6.3.2.1` → kandidat dinamis = `...3.2.6.3.2.1.11`, `...3.2.6.3.2.1.12`, `...3.2.6.3.2.1.2`. + +Duplikat dihapus via `array_unique()` sebelum di-scan. OID pemenang (mengembalikan data terbanyak) disimpan di `data/olt_oids/{oltId}.json` dan dipakai langsung pada polling berikutnya (tidak perlu scan ulang). + +--- + +## 5. OID Standard (IF-MIB / RFC 2863) + +OID berikut digunakan **semua profile** — tidak vendor-specific. + +### 5.1 System Info (SNMPv2-MIB) + +| OID | Field | Keterangan | +|-----|-------|-----------| +| `1.3.6.1.2.1.1.1.0` | `sysDescr` | Deskripsi hardware OLT (string) | +| `1.3.6.1.2.1.1.3.0` | `sysUpTime` | Uptime OLT sejak reboot (timeticks) | +| `1.3.6.1.2.1.1.5.0` | `sysName` | Hostname OLT | + +### 5.2 Port Status — IF-MIB (ifTable) + +| OID | Field | Keterangan | +|-----|-------|-----------| +| `1.3.6.1.2.1.2.2.1.2` | `ifDescr` | Nama interface (PON-1, GE-1, dll) | +| `1.3.6.1.2.1.2.2.1.8` | `ifOperStatus` | Status: `1`=up, `2`=down | +| `1.3.6.1.2.1.2.2.1.10` | `ifInOctets` | Bytes masuk (32-bit, wrap di 4GB) | +| `1.3.6.1.2.1.2.2.1.16` | `ifOutOctets` | Bytes keluar (32-bit) | + +### 5.3 Port Counter 64-bit — IF-MIB Extended (ifXTable) + +Digunakan jika OLT support (mencegah counter wrap di port gigabit): + +| OID | Field | Keterangan | +|-----|-------|-----------| +| `1.3.6.1.2.1.31.1.1.1.6` | `ifHCInOctets` | Bytes masuk 64-bit | +| `1.3.6.1.2.1.31.1.1.1.10` | `ifHCOutOctets` | Bytes keluar 64-bit | +| `1.3.6.1.2.1.31.1.1.1.15` | `ifHighSpeed` | Kecepatan interface (Mbps) | + +> Sistem otomatis probe `ifHCInOctets` terlebih dulu. Jika tidak ada respons, fallback ke `ifInOctets` 32-bit. + +### 5.4 System Resources — HOST-RESOURCES-MIB (hrTable) + +> Hanya pada OLT yang support (HIOSO_B2). Tidak tersedia di HIOSO_C dan HIOSO_VX. + +| OID | Field | Keterangan | +|-----|-------|-----------| +| `1.3.6.1.2.1.25.3.3.1.2.768` | `hrProcessorLoad` | CPU load % (index 768 = CPU pertama) | +| `1.3.6.1.2.1.25.2.3.1.5.1` | `hrStorageSize` | Total RAM (unit = 1024 bytes) | +| `1.3.6.1.2.1.25.2.3.1.6.1` | `hrStorageUsed` | RAM terpakai (unit = 1024 bytes) | + +--- + +## 6. Cara Membaca Nilai Rx/Tx Power + +Logika parsing Rx/Tx Power di kode (`HiosoOLT::$parseSignal`) berbeda antara profile: + +### 6.1 Profile `divider = 1` (HIOSO_C, HIOSO_B2, HIOSO_VX) + +SNMP mengembalikan **float string yang sudah dalam satuan dBm** (mis. `"-12.22"`, `"2.08"`). +Kode hanya melakukan `(float) number_format($num, 2)` — tidak ada konversi unit. + +``` +SNMP value: "-12.22" → Hasil: -12.22 dBm ✓ +SNMP value: "2.08" → Hasil: 2.08 dBm ✓ +``` + +### 6.2 Profile `divider != 1` (HIOSO_B, HIOSO_GPON) + +SNMP mengembalikan **integer mentah**. Kode menggunakan **auto-scale berdasarkan magnitude** +(nilai `$divider` sendiri hanya dipakai untuk membedakan `== 1` vs bukan): + +```php +$abs = abs($num); +if ($abs > 500) return round($num / 100, 2); // mis. -1227 → -12.27 dBm +if ($abs > 50) return round($num / 10, 2); // mis. -122 → -12.20 dBm +return round($num, 2); // mis. -12 → -12.00 dBm +``` + +| SNMP Value | `abs > 500`? | `abs > 50`? | Hasil | +|-----------|-------------|------------|-------| +| `-1227` | ✓ | — | `-1227 / 100 = -12.27 dBm` | +| `-122` | ✗ | ✓ | `-122 / 10 = -12.20 dBm` | +| `-12` | ✗ | ✗ | `-12.00 dBm` (langsung) | + +> ⚠️ **Catatan dokumen lama** yang menyebut formula `$val / $divider / 100` adalah **salah** — kode aktual menggunakan auto-scale magnitude di atas. + +### Signal Level (sig_level) + +Fungsi `HiosoOLT::signalLevel(?float $rxDbm): string` — implementasi aktual: + +```php +if ($rxDbm === null) return 'unknown'; +if ($rxDbm < -27.0) return 'critical'; +if ($rxDbm < -25.0) return 'warn'; +return 'ok'; +``` + +| Kondisi | Label | Warna UI | +|---------|-------|----------| +| `$rxDbm === null` | `unknown` | Abu-abu | +| `$rxDbm < -27.0 dBm` | `critical` | Merah | +| `-27.0 ≤ $rxDbm < -25.0 dBm` | `warn` | Kuning | +| `$rxDbm ≥ -25.0 dBm` | `ok` | Hijau | + +--- + +## 7. Format Index ONU + +Index ONU pada OID mengikuti format hirarki board-PON-ONU: + +``` +{board}.{pon}.{onu_id} +``` + +**Contoh untuk HIOSO_C (HA7304V):** +- `1.1.1` → Board 1, PON 1, ONU ID 1 +- `1.1.52` → Board 1, PON 1, ONU ID 52 (last ONU on PON1) +- `1.2.1` → Board 1, PON 2, ONU ID 1 ← SNMP group B=2 (merged PON2/3/4!) +- `1.2.74` → Board 1, PON 2, ONU ID 74 (last in merged group) + +**Untuk HIOSO_B2 (HA7304C):** +- `1.1.X` → PON 1 +- `1.2.X` → PON 2 +- `1.3.X` → PON 3 +- `1.4.X` → PON 4 +- Semua port terepresentasikan dengan benar di SNMP (tidak ada merge) + +--- + +## 8. Verifikasi via CLI (`snmpwalk`) + +Contoh perintah untuk memverifikasi OID secara langsung: + +```bash +# Test koneksi dasar (sysDescr) +snmpwalk -v2c -c public 192.168.75.66 1.3.6.1.2.1.1.1.0 + +# Daftar semua ONU status (HIOSO_C) +snmpwalk -v2c -c public 192.168.75.66 .1.3.6.1.4.1.25355.3.2.6.3.2.1.39 + +# Rx Power semua ONU (HIOSO_B2, community SNMPREAD) +snmpwalk -v2c -c SNMPREAD 192.168.75.88 .1.3.6.1.4.1.25355.3.2.6.14.2.1.8 + +# Port status semua interface +snmpwalk -v2c -c public 192.168.75.66 1.3.6.1.2.1.2.2.1.8 + +# CPU load (hrProcessorLoad) — hanya HIOSO_B2 +snmpget -v2c -c SNMPREAD 192.168.75.88 1.3.6.1.2.1.25.3.3.1.2.768 +``` + +--- + +## 9. OLT Device Map (Salfanet Production) + +| OLT ID | Nama | IP | Model | Profile | Community | PON Count | +|--------|------|-----|-------|---------|-----------|-----------| +| `olt_69e17c1abf643` | OLT A | 192.168.75.88 | HA7304C | HIOSO_B2 | SNMPREAD | 4 | +| `olt_69e72ee7309fe` | OLT B | 192.168.75.77 | HA7304C | HIOSO_B2 | SNMPREAD | 4 | +| `olt_69e72e6911857` | OLT C | 192.168.75.66 | HA7304V | HIOSO_C | public | 4 (PON2+3+4 merged di SNMP) | +| `olt_69e72f2ba1f75` | OLT D | 192.168.75.99 | HA7304VX | HIOSO_VX | public | 4 | diff --git a/docs/PANDUAN_PLAYSTORE.md b/docs/PANDUAN_PLAYSTORE.md new file mode 100644 index 0000000..bf53e96 --- /dev/null +++ b/docs/PANDUAN_PLAYSTORE.md @@ -0,0 +1,207 @@ +# 📱 Panduan Lengkap & Dokumen Listing Google Play Store +**Aplikasi:** Alijaya Customer & Billing Management System +**Package Name:** `com.alijaya.customer` +**File Bundle:** `AlijayaCustomer.aab` (5.56 MB) +**Keystore:** `alijaya-release.jks` + +Dokumen ini berisi seluruh teks deskripsi, kebijakan privasi, keamanan data (data safety), kredensial akun pengujian untuk tim Google, dan panduan langkah demi langkah yang siap di-*copy-paste* langsung ke **Google Play Console**. + +--- + +## 1. 📋 Data Utama Listing Toko (Store Listing) + +### A. Judul Aplikasi (App Title) +> **Batas:** Maksimal 30 Karakter +```text +Alijaya Digital +``` +*(Alternatif jika ingin nama lain: `Alijaya Billing`, `Alijaya Net`, atau `Alijaya Customer`)* + +--- + +### B. Deskripsi Singkat (Short Description) +> **Batas:** Maksimal 80 Karakter +```text +Aplikasi layanan internet pelanggan, tagihan, beli pulsa, dan absensi petugas. +``` + +--- + +### C. Deskripsi Lengkap (Full Description) +> **Batas:** Maksimal 4.000 Karakter +```text +Alijaya Digital adalah aplikasi resmi layanan internet dan manajemen billing terpadu untuk pelanggan, agen, teknisi, kolektor, dan kasir jaringan Alijaya Digital Network. + +Aplikasi ini memberikan kemudahan akses informasi layanan internet, pembayaran tagihan, pembelian paket data/pulsa, hingga operasional lapangan secara cepat, transparan, dan terpercaya. + +Fitur Unggulan untuk Pelanggan: +• Cek Tagihan & Status Langganan: Informasi tagihan bulanan real-time, jatuh tempo, dan rincian pemakaian paket internet. +• Pembayaran Otomatis & QRIS: Pembayaran tagihan mudah dan instan menggunakan QRIS dinamis serta berbagai metode pembayaran digital lainnya. +• Dompet & Top-Up Saldo: Isi saldo dompet akun untuk kemudahan transaksi berkala. +• Pembelian Pulsa & PPOB: Pembelian pulsa seluler, paket internet operator, dan token listrik langsung dari aplikasi. +• Cek Kecepatan & Tiket Gangguan: Uji kecepatan koneksi internet dan ajukan tiket keluhan gangguan langsung ke tim teknis dengan respon cepat. +• Manajemen Router Wi-Fi: Pantau dan atur jaringan Wi-Fi rumah secara praktis. + +Fitur Khusus Petugas & Mitra (Teknisi, Kolektor, Kasir, & Agen): +• Absensi Kerja Berbasis Geo-Tagging: Sistem presensi kehadiran harian dengan validasi GPS akurat dan foto selfie ber-watermark otomatis (lokasi, tanggal, dan waktu). +• Manajemen Tugas SPK Lapangan: Penanganan tiket gangguan pelanggan dan instalasi pasang baru secara terorganisir. +• Manajemen Tagihan & Rute Kolektor: Navigasi rute penagihan berbasis peta Google Maps dan pencetakan struk pembayaran thermal via Bluetooth. +• Loket Kasir & Mitra Agen: Pembelian voucher hotspot dan transaksi PPOB secara aman. + +Unduh aplikasi Alijaya Digital sekarang dan nikmati kemudahan mengelola seluruh kebutuhan layanan internet Anda dalam satu genggaman! +``` + +--- + +## 2. 🏷️ Kategori & Kontak Pengembang (Store Settings) + +- **Jenis Aplikasi:** Aplikasi (Application) +- **Kategori:** Alat (Tools) atau Produktivitas (Productivity) / Bisnis (Business) +- **Email Dukungan (Support Email):** `alijayanet@gmail.com` *(atau email resmi Anda)* +- **Nomor Telepon Dukungan:** `+6287820851413` +- **Situs Web (Website):** `http://alijaya.net` *(atau domain server Anda)* + +--- + +## 3. 🛡️ Kebijakan Privasi (Privacy Policy) +> [!IMPORTANT] +> Google Play mewajibkan URL Kebijakan Privasi aktif karena aplikasi menggunakan izin Kamera dan Lokasi. Anda dapat menaruh URL berikut: +> `http:///privacy-policy` atau gunakan draf teks resmi di bawah ini: + +```markdown +# KEBIJAKAN PRIVASI (PRIVACY POLICY) - ALIJAYA DIGITAL + +Terakhir diperbarui: 30 Agustus 2026 + +Alijaya Digital Network ("kami") berkomitmen untuk melindungi dan menghormati privasi pengguna aplikasi mobile Alijaya Digital ("Aplikasi"). Kebijakan Privasi ini menjelaskan bagaimana kami mengumpulkan, menggunakan, dan melindungi data pribadi Anda. + +1. INFORMASI YANG KAMI KUMPULKAN +Kami hanya mengumpulkan informasi yang relevan dan dibutuhkan untuk menjalankan fitur aplikasi: +a. Informasi Akun: Nama, nomor telepon/WhatsApp, alamat rumah/pemasangan internet, ID pelanggan, dan username PPPoE/Hotspot. +b. Informasi Lokasi (GPS): Data koordinat lokasi (Latitude & Longitude) digunakan saat: + - Pelanggan mengajukan instalasi pasang baru atau pemetaan lokasi ODP/tiang terdekat. + - Petugas (Teknisi, Kolektor, Kasir) melakukan absensi kehadiran kerja berbasis validasi radius kantor (Geofencing). + - Petugas kolektor membuka rute penagihan pelanggan melalui Google Maps. +c. Kamera & Foto: Izin kamera digunakan untuk: + - Pengambilan foto selfie wajah petugas saat absensi kehadiran dengan watermark Geo-Tagging posisi. + - Pengunggahan foto bukti kendala teknis saat pelaporan tiket gangguan. + - Pemindaian QR Code (Barcode voucher / QRIS). +d. Akses Bluetooth: Digunakan oleh petugas kolektor dan kasir untuk menghubungkan printer thermal Bluetooth dalam pencetakan struk pembayaran. + +2. PENGGUNAAN INFORMASI +Informasi yang dikumpulkan digunakan semata-mata untuk: +- Memverifikasi identitas pengguna dan mengelola akun langganan internet. +- Memproses pembayaran tagihan dan riwayat transaksi. +- Memvalidasi presensi kerja dan koordinat lokasi tugas lapangan karyawan resmi. +- Menghubungkan komunikasi layanan pelanggan melalui WhatsApp. + +3. KEAMANAN DATA +Kami menerapkan standar keamanan teknis dan enkripsi data (HTTPS/SSL) dalam transmisi data antara aplikasi dan server. Kami tidak pernah menjual, menyewakan, atau membagikan data pribadi Anda kepada pihak ketiga mana pun untuk kepentingan pemasaran. + +4. HAK PENGGUNA & PENGHAPUSAN DATA +Pengguna memiliki hak untuk meminta rincian data yang tersimpan atau meminta penghapusan akun beserta data terkait dengan menghubungi kami melalui email dukungan: alijayanet@gmail.com atau WhatsApp resmi customer support kami. + +5. HUBUNGI KAMI +Jika Anda memiliki pertanyaan seputar Kebijakan Privasi ini, silakan hubungi: +Alijaya Digital Network +Email: alijayanet@gmail.com +Telepon/WA: +62 878-2085-1413 +Alamat: Indramayu, Jawa Barat, Indonesia. +``` + +--- + +## 4. 🔒 Deklarasi Keamanan Data (Data Safety Questionnaire) + +Saat mengisi menu **Keamanan Data (Data Safety)** di Google Play Console, pilih jawaban berikut: + +| Pertanyaan Google Play | Jawaban yang Dipilih | +| :--- | :--- | +| **Apakah aplikasi mengumpulkan data pengguna?** | **Ya (Yes)** | +| **Apakah semua data dienkripsi saat transit?** | **Ya (Yes, HTTPS/TLS)** | +| **Apakah ada cara bagi pengguna untuk meminta penghapusan data?** | **Ya (Yes)** | + +### Rincian Kategori Data yang Dikumpulkan: +1. **Lokasi (Location)**: + - *Lokasi Akurat (Precise Location)* & *Lokasi Perkiraan (Approximate Location)*. + - **Tujuan:** Fungsionalitas aplikasi (App functionality) & Pengelolaan akun (Account management) untuk absensi karyawan dan rute pelanggan. + - **Apakah dibagikan ke pihak ketiga?** Tidak (No). +2. **Foto dan Video (Photos and Videos)**: + - *Foto (Photos)*. + - **Tujuan:** Fungsionalitas aplikasi (Absensi selfie karyawan dan lampiran tiket komplain). + - **Apakah dibagikan ke pihak ketiga?** Tidak (No). +3. **Info Pribadi (Personal Info)**: + - *Nama (Name)*, *Nomor Telepon (Phone number)*, *Alamat (Address)*. + - **Tujuan:** Fungsionalitas aplikasi & Komunikasi pelanggan. + - **Apakah dibagikan ke pihak ketiga?** Tidak (No). + +--- + +## 5. 🎯 Target Audiens & Rating Konten + +1. **Target Usia (Target Age):** + - Pilih: **18 tahun ke atas (18+)** *(karena mencakup pembayaran tagihan langganan internet rumah)*. +2. **Aplikasi Berita (News App):** Pilih **Tidak (No)**. +3. **Iklan (Ads):** Pilih **Tidak, aplikasi saya tidak mengandung iklan (No, my app does not contain ads)**. +4. **Pelacak Kontak COVID-19:** Pilih **Tidak (No)**. +5. **Aplikasi Keuangan:** Pilih **Tidak (Bukan institusi perbankan/pinjol)**, hanya sistem pembayaran tagihan ISP sendiri. + +--- + +## 6. 👤 Akun Akses Pengujian untuk Reviewer Google (App Access) + +Google mewajibkan penyediaan akun demo agar penguji dari pihak Google dapat masuk dan menguji fitur di dalam aplikasi tanpa terhalang login: + +- **Opsi yang Dipilih:** *Sebagian atau semua fungsi dibatasi (All or some functionality is restricted)* +- **Nama Kredensial:** `Akun Penguji Google Play` +- **Username / ID Pelanggan:** `demo` *(atau buat akun khusus pengujian di admin)* +- **Password:** `demo123` +- **Instruksi Pengujian:** + ```text + Aplikasi ini menggunakan koneksi ke server Alijaya Billing System. + Gunakan username: demo dan password: demo123 untuk menguji portal pelanggan. + Koneksi internet aktif diperlukan. + ``` + +--- + +## 7. 🔑 Kredensial Keystore & Spesifikasi Teknis + +Simpan data teknis ini di tempat yang aman: + +| Parameter | Nilai | +| :--- | :--- | +| **Package Name** | `com.alijaya.customer` | +| **Target SDK** | `Android 15 (SDK 35)` | +| **Min SDK** | `Android 7.0 (SDK 24)` | +| **Version Code** | `2` | +| **Version Name** | `1.2.0` | +| **File Keystore** | `alijaya-release.jks` | +| **Keystore Alias** | `alijaya` | +| **Keystore Password** | `AlijayaRelease2026!` | +| **Key Password** | `AlijayaRelease2026!` | +| **Validity** | 10.000 Hari (~27 Tahun) | + +--- + +## 8. 🎨 Kebutuhan Aset Grafis (Graphic Assets) + +Sebelum menekan tombol rilis, siapkan file gambar berikut: + +1. **Ikon Aplikasi (App Icon):** + - Ukuran: `512 x 512 piksel` + - Format: PNG 32-bit dengan transparansi + - Ukuran file maksimal: 1 MB +2. **Grafis Fitur (Feature Graphic):** + - Ukuran: `1024 x 500 piksel` + - Format: JPEG atau PNG 24-bit (tanpa transparansi) + - Ukuran file maksimal: 15 MB +3. **Tangkapan Layar Ponsel (Phone Screenshots):** + - Minimal 2 screenshot, maksimal 8 screenshot. + - Rasio 16:9 atau 9:16 (contoh resolusi ideal: `1080 x 1920 piksel` atau `1080 x 2400 piksel`). + - Disarankan mengambil screenshot dari: + 1. Layar Beranda Pelanggan (Cek Tagihan & Beli Pulsa) + 2. Layar Pembayaran QRIS / Invoice + 3. Layar Speedtest & Tiket Gangguan + 4. Layar Dashboard Tugas Teknisi Lapangan + 5. Layar Absensi Selfie dengan Watermark Geo-Tagging diff --git a/docs/QUICK_REFERENCE.md b/docs/QUICK_REFERENCE.md new file mode 100644 index 0000000..8e1a884 --- /dev/null +++ b/docs/QUICK_REFERENCE.md @@ -0,0 +1,343 @@ +# QUICK REFERENCE - Aplikasi Billing B481 + +## 🚀 Quick Start + +### Running Aplikasi +```bash +# Development +npm run dev + +# Production +npm start + +# With PM2 +pm2 start app-customer.js --name billing +``` + +### Access Portals +| Portal | URL | Default User | +|--------|-----|---| +| Admin | `http://localhost:3001/admin/login` | admin / admin123 | +| Customer | `http://localhost:3001/customer/login` | - | +| Tech | `http://localhost:3001/tech/login` | - | +| Agent | `http://localhost:3001/agent/login` | - | +| Collector | `http://localhost:3001/collector/login` | - | +| Health | `http://localhost:3001/health` | - | + +--- + +## 📁 Key Files + +``` +app-customer.js ← Entry point +config/database.js ← Database setup +settings.json ← Configuration +routes/customerPortal.js ← Customer routes +routes/adminPortal.js ← Admin routes +services/billingService.js ← Billing logic +services/mikrotikService.js ← MikroTik API +``` + +--- + +## 🔧 Common Tasks + +### Change Port +Edit `settings.json`: +```json +{ + "server_port": 3002 +} +``` + +### Change Company Name +Edit `settings.json`: +```json +{ + "company_header": "MY ISP NAME" +} +``` + +### Enable WhatsApp +1. Edit `settings.json`: +```json +{ + "whatsapp_enabled": true +} +``` +2. Login → Admin → WhatsApp → Auth with QR code + +### Setup MikroTik +Edit `settings.json`: +```json +{ + "mikrotik_host": "192.168.8.1", + "mikrotik_user": "admin", + "mikrotik_password": "password", + "mikrotik_port": 8728 +} +``` + +### Setup GenieACS +Edit `settings.json`: +```json +{ + "genieacs_url": "http://192.168.1.100:7557", + "genieacs_username": "admin", + "genieacs_password": "password" +} +``` + +### Setup Payment Gateway (Midtrans) +Edit `settings.json`: +```json +{ + "midtrans_enabled": true, + "midtrans_server_key": "Mid-server-xxx", + "midtrans_mode": "sandbox" +} +``` + +--- + +## 🔐 Security Settings (MUST DO) + +⚠️ **Before Production:** + +1. Change Session Secret: +```json +{ + "session_secret": "use-a-random-32-character-string-here" +} +``` + +2. Change Admin Password: +```json +{ + "admin_username": "admin", + "admin_password": "use-a-strong-password" +} +``` + +3. Change Admin API Key: +```json +{ + "admin_api_key": "use-a-random-api-key" +} +``` + +4. Set NODE_ENV: +```bash +export NODE_ENV=production +``` + +--- + +## 📊 Important Endpoints + +### Public +- `GET /health` - Health check +- `GET /` - Redirect to login +- `GET /isolated` - Isolated page (MikroTik redirect) +- `POST /api/webhook/v1/payment-notif` - Payment webhook + +### Admin APIs +- `POST /api/settings/test-genieacs` - Test GenieACS +- `POST /api/settings/test-mikrotik` - Test MikroTik +- `POST /api/settings/test-tripay` - Test Tripay +- `GET /api/settings` - Get settings +- `POST /api/settings` - Update settings + +### Billing +- `GET /admin/billing/invoices` - Invoice list +- `POST /admin/billing/generate` - Generate invoices +- `POST /admin/billing/unpay` - Cancel payment + +--- + +## 🗄️ Database Commands + +### Check Database +```bash +node scripts/verify-database.js +``` + +### Fix Database Issues +```bash +bash scripts/fix-database.sh +``` + +### Backup Database +```bash +# Manual via Admin Panel +# Or via API: POST /api/backup/create +``` + +--- + +## 🔄 Cron Jobs + +| Time | Job | Purpose | +|------|-----|---------| +| 1 bulan, 00:01 | generateMonthlyInvoices() | Create monthly invoices | +| Setiap hari 02:00 | autoIsolatePlansExpired() | Isolate overdue customers | +| Setiap hari 09:00 | sendBillingReminders() | WhatsApp reminders | +| 00:00 & 06:00 | jamKalong() | Change PPPoE profiles | +| Setiap 10 menit | syncUsageTracking() | Sync PPPoE usage | +| Setiap jam | checkFUPQuota() | Check data quota | + +--- + +## 🐛 Troubleshooting + +### Port Already in Use +```bash +# Find process using port +lsof -i :3001 +# Kill process +kill -9 +# Or change port in settings.json +``` + +### Database Locked +```bash +# Remove WAL files +rm database/billing.db-shm +rm database/billing.db-wal +# Restart application +``` + +### GenieACS Connection Failed +```bash +# Check settings +cat settings.json | grep genieacs_url +# Test connection +curl http://192.168.1.100:7557/api +``` + +### WhatsApp Not Connected +- Admin Panel → WhatsApp → Logout +- Admin Panel → WhatsApp → Auth (scan QR) +- Check `whatsapp_enabled` = true + +### Payment Not Received +1. Check webhook configuration +2. Verify API key & secret +3. Check webhook logs: Admin → Payments → Webhook Logs +4. Manual entry if needed + +--- + +## 📱 Useful Scripts + +```bash +# Test MikroTik parser +node scripts/test-mikhmon-parser.js + +# Check Mikhmon data +node scripts/check-mikhmon-data.js + +# Test admin account +node scripts/test-admin.js + +# Send test message +node scripts/test-message.js + +# Find uplink info +node scripts/find_uplink.js +``` + +--- + +## 🌍 Multi-Language + +### Current Languages +- 🇮🇩 Indonesian (id) - Default +- 🇬🇧 English (en) - Alternative + +### Switch Language +``` +GET /lang/en ← Set English +GET /lang/id ← Set Indonesian +GET /?lang=en ← Query parameter +``` + +### Add New Language +1. Create `locales/[code].json` +2. Copy from `locales/id.json` +3. Translate all values +4. Test: `GET /?lang=[code]` + +--- + +## 📊 Monitoring + +### Check System Health +```bash +curl http://localhost:3001/health +``` + +**Response:** +```json +{ + "status": "ok", + "timestamp": "2026-06-19T10:30:00+07:00", + "database": "connected", + "services": { + "genieacs": "connected|disconnected", + "mikrotik": "connected|disconnected" + } +} +``` + +--- + +## 🎯 Settings Priority + +1. Environment variables (`.env`) +2. Settings file (`settings.json`) +3. Application defaults (in code) + +**Example (Payment Gateway):** +```json +{ + "default_gateway": "tripay", + "tripay_enabled": true, + "tripay_api_key": "xxx", + "tripay_private_key": "xxx", + "tripay_merchant_code": "xxx", + "tripay_mode": "sandbox" +} +``` + +--- + +## 🔗 Important Links + +- GitHub: https://github.com/alijayanet/billing-rtrw +- Author: Ali Jaya Net +- Contact: 081947215703 +- Email: alijayanet@gmail.com + +--- + +## ✅ Pre-Launch Checklist + +- [ ] Backup application & database +- [ ] Test all portals (customer, admin, tech, agent, collector) +- [ ] Verify MikroTik connection +- [ ] Verify GenieACS connection +- [ ] Test payment gateway +- [ ] Setup WhatsApp authentication +- [ ] Configure email/SMS gateway (if needed) +- [ ] Test backup & restore +- [ ] Setup monitoring & alerts +- [ ] Review settings.json security +- [ ] Test cron jobs (if applicable) +- [ ] Document all credentials securely + +--- + +**Last Updated:** Juni 2026 +**Version:** 1.0 + diff --git a/docs/RADIUS_MIKROTIK_INTEGRATION.md b/docs/RADIUS_MIKROTIK_INTEGRATION.md new file mode 100644 index 0000000..36545ab --- /dev/null +++ b/docs/RADIUS_MIKROTIK_INTEGRATION.md @@ -0,0 +1,359 @@ +# Dokumentasi Integrasi RADIUS & MikroTik + +## 📋 Overview + +Aplikasi billing ini mendukung **dua mode autentikasi PPPoE**: +1. **Mode MikroTik** - PPPoE Secret disimpan di MikroTik (RADIUS offline) +2. **Mode RADIUS** - PPPoE Secret dikelola oleh RADIUS Server internal (RADIUS aktif) +3. **Mode Hybrid** - RADIUS aktif tapi beberapa pelanggan tetap menggunakan MikroTik secret + +--- + +## 🔄 Cara Kerja Saat Tambah/Edit Pelanggan + +### ✅ **PRINSIP PENTING:** +> **PPPoE Secret TIDAK PERNAH dihapus dari MikroTik secara otomatis!** +> +> Sistem hanya melakukan: +> - ✅ CREATE secret baru (jika belum ada) +> - ✅ UPDATE profile secret (jika sudah ada) +> - ❌ **TIDAK** menghapus/remove secret dari MikroTik + +--- + +## 🎯 Logika Sinkronisasi ke MikroTik + +### **1. SAAT RADIUS OFFLINE** (`radius_enabled = "0"`) + +Semua pelanggan PPPoE **WAJIB** memiliki secret di MikroTik. + +**Saat Tambah Pelanggan:** +``` +1. Admin input: username, password, package +2. Data disimpan ke database billing +3. Secret OTOMATIS dibuat di MikroTik +4. Profile di-set sesuai package pelanggan +``` + +**Saat Edit Pelanggan:** +``` +1. Admin edit data pelanggan (ganti package, status, dll) +2. Data diupdate di database billing +3. Sistem cek: apakah secret sudah ada di MikroTik? + - Jika SUDAH ADA: Hanya update PROFILE (tidak hapus!) + - Jika BELUM ADA: Create secret baru (jika ada password) +``` + +### **2. SAAT RADIUS AKTIF** (`radius_enabled = "1"`) + +Ada dua kemungkinan per pelanggan: + +#### **Mode Full RADIUS** (`is_radius = 1`) +- Pelanggan autentikasi via RADIUS Server +- Secret **TIDAK** perlu ada di MikroTik +- MikroTik hanya sebagai NAS Client yang forward request ke RADIUS +- Sinkronisasi ke MikroTik: **SKIP** + +#### **Mode Hybrid - Tetap ke MikroTik** (`is_radius = 0`) +- Pelanggan autentikasi langsung dari MikroTik secret +- Secret **HARUS** ada di MikroTik +- Sinkronisasi ke MikroTik: **AKTIF** +- Berguna untuk pelanggan khusus yang tidak mau pakai RADIUS + +--- + +## 🔧 Implementasi Teknis + +### **File yang Dimodifikasi:** +- `routes/adminPortal.js` (route handler tambah/edit pelanggan) + +### **Logika Kode:** + +```javascript +// Tentukan apakah harus sync ke MikroTik +const radiusEnabled = getSetting('radius_enabled', '0') === '1'; +const isRadius = radiusEnabled ? (req.body.is_radius || 0) : 0; +const shouldSyncToMikrotik = !radiusEnabled || !isRadius; + +// Jika shouldSyncToMikrotik = true, maka: +if (shouldSyncToMikrotik) { + // TAMBAH: Create secret baru ke MikroTik + // EDIT: Update profile atau create jika belum ada +} +``` + +### **Kondisi Sinkronisasi:** + +| RADIUS Status | is_radius | Sync ke MikroTik? | Keterangan | +|---------------|-----------|-------------------|------------| +| OFFLINE (0) | - | ✅ YA | Selalu ke MikroTik | +| AKTIF (1) | 0 | ✅ YA | Hybrid mode | +| AKTIF (1) | 1 | ❌ TIDAK | Full RADIUS | + +--- + +## 📝 Contoh Skenario + +### **Skenario 1: RADIUS Offline (Mode Normal)** + +``` +Settings: radius_enabled = "0" + +TAMBAH PELANGGAN: +- Username: customer01 +- Password: pass123 +- Package: 10Mbps + +HASIL: +✅ Data tersimpan di database billing +✅ Secret dibuat di MikroTik: customer01/pass123 +✅ Profile di-set: 10Mbps + +EDIT PELANGGAN (Ganti Package ke 20Mbps): +✅ Data diupdate di database +✅ Profile di MikroTik diupdate: 10Mbps → 20Mbps +✅ Secret TIDAK dihapus, tetap ada di MikroTik +``` + +### **Skenario 2: RADIUS Aktif - Full RADIUS Mode** + +``` +Settings: radius_enabled = "1" +Form: is_radius = 1 (checkbox dicentang) + +TAMBAH PELANGGAN: +- Username: customer02 +- Password: pass456 +- Package: 20Mbps + +HASIL: +✅ Data tersimpan di database billing +❌ Secret TIDAK dibuat di MikroTik (RADIUS yang handle) +✅ Pelanggan login via RADIUS Server + +EDIT PELANGGAN: +✅ Data diupdate di database +❌ Tidak sync ke MikroTik (RADIUS mode) +``` + +### **Skenario 3: RADIUS Aktif - Hybrid Mode** + +``` +Settings: radius_enabled = "1" +Form: is_radius = 0 (checkbox TIDAK dicentang) + +TAMBAH PELANGGAN: +- Username: customer03 +- Password: pass789 +- Package: 50Mbps + +HASIL: +✅ Data tersimpan di database billing +✅ Secret dibuat di MikroTik (meskipun RADIUS aktif) +✅ Pelanggan login dari MikroTik secret, bukan RADIUS + +Berguna untuk: +- Pelanggan VIP yang butuh koneksi stabil +- Testing sebelum migrasi penuh ke RADIUS +``` + +--- + +## 🛡️ Keamanan & Best Practices + +### **1. Password Management:** +- ✅ Password disimpan di database billing +- ✅ Password disimpan di MikroTik secret (saat mode MikroTik) +- ✅ Password di-hash di database RADIUS accounting (aman) + +### **2. Saat Hapus Pelanggan:** +```javascript +// File: services/customerService.js +// Fungsi: deleteCustomer() + +// Secret AKAN dihapus dari MikroTik jika: +✅ Pelanggan dihapus dari database +✅ Connection type = pppoe +✅ Ada pppoe_username +✅ Ada router_id + +// Ini adalah satu-satunya kondisi secret dihapus otomatis! +``` + +### **3. Saat Ganti Connection Type:** +``` +PPPoE → Static IP: +✅ pppoe_username dikosongkan di database +⚠️ Secret TETAP ada di MikroTik (tidak auto-hapus) +💡 Admin perlu hapus manual jika diperlukan + +Static IP → PPPoE: +✅ pppoe_username diisi +✅ Secret dibuat baru di MikroTik (jika ada password) +``` + +--- + +## 🚀 Migrasi dari MikroTik ke RADIUS + +### **Langkah Migrasi Bertahap:** + +**Step 1: Persiapan** +``` +1. Pastikan database billing sudah lengkap (username & password) +2. Test RADIUS Server: aktifkan di Admin → RADIUS Settings +3. Setup MikroTik: jalankan script auto-setup dari admin panel +4. Test koneksi 1-2 pelanggan pilot +``` + +**Step 2: Migrasi Bertahap** +``` +1. RADIUS aktif, tapi semua pelanggan is_radius = 0 (hybrid) +2. Pelanggan masih login dari MikroTik secret +3. Test RADIUS dengan mengubah 5-10 pelanggan ke is_radius = 1 +4. Monitor stability & performance +``` + +**Step 3: Full RADIUS** +``` +1. Bulk update semua pelanggan: is_radius = 1 +2. Semua autentikasi via RADIUS Server +3. Secret di MikroTik bisa dihapus (opsional, untuk cleanup) +``` + +### **Rollback Plan:** +``` +Jika ada masalah dengan RADIUS: +1. Set radius_enabled = "0" di settings.json +2. Restart aplikasi +3. Semua pelanggan otomatis kembali ke MikroTik mode +4. Secret masih ada di MikroTik (tidak pernah dihapus) +``` + +--- + +## 🐛 Troubleshooting + +### **Problem: "Secret tidak dibuat di MikroTik saat tambah pelanggan"** + +**Cek:** +1. ✅ RADIUS Status: Apakah RADIUS sedang aktif? + - Jika YA: Cek checkbox "Mode RADIUS" di form (harus TIDAK dicentang) + - Jika TIDAK: Seharusnya auto-create + +2. ✅ Password: Apakah field password diisi? + - Jika TIDAK: Secret tidak bisa dibuat + - Jika YA: Cek error log di `logs/error.log` + +3. ✅ Router: Apakah router MikroTik dipilih dan online? + - Test koneksi: Admin → MikroTik → Test Connection + +4. ✅ Profile: Apakah profile/package ada di MikroTik? + - Cek: Admin → MikroTik → PPPoE Profiles + +**Solusi:** +```bash +# Cek log error +tail -f logs/error.log + +# Restart aplikasi +pm2 restart billing-rtrw +``` + +### **Problem: "Secret tidak terupdate saat edit pelanggan"** + +**Cek:** +1. ✅ Mode RADIUS: Apakah is_radius = 0? (harus 0 untuk sync ke MikroTik) +2. ✅ Username: Apakah pppoe_username terisi? +3. ✅ Connection Type: Apakah masih PPPoE? + +**Catatan:** +- Update hanya mengubah **profile**, bukan password +- Jika ingin ganti password, edit langsung di MikroTik + +### **Problem: "Pelanggan tidak bisa login setelah edit"** + +**Kemungkinan:** +1. Profile tidak ada di MikroTik +2. Secret terhapus manual dari MikroTik +3. Password berubah di database tapi tidak di MikroTik + +**Solusi:** +``` +1. Cek secret di MikroTik: + Admin → MikroTik → PPPoE Secrets + +2. Jika secret hilang: + - Edit pelanggan + - Isi password + - Save (akan create secret baru) + +3. Jika profile salah: + - Edit pelanggan + - Ganti package + - Save (akan update profile) +``` + +--- + +## 📊 Monitoring & Logging + +### **Log yang Tercatat:** + +**Saat Tambah Pelanggan:** +``` +[Add Customer] Created PPPoE secret "customer01" in MikroTik (RADIUS OFF) +[Add Customer] Updated PPPoE profile for "customer01" to "10Mbps" +``` + +**Saat Edit Pelanggan:** +``` +[Edit Customer] Updated PPPoE profile for "customer01" to "20Mbps" +[Edit Customer] Created NEW PPPoE secret for "customer02" in MikroTik +``` + +**Saat Hapus Pelanggan:** +``` +[DELETE] Attempting to remove PPPoE secret: customer01 from router 1 +[DELETE] Successfully removed PPPoE secret for customer01 from MikroTik +``` + +### **Lokasi File Log:** +``` +logs/combined.log - Semua log aplikasi +logs/error.log - Error saja +``` + +--- + +## 🎓 Kesimpulan + +### **Yang Perlu Diingat:** + +1. ✅ **PPPoE Secret TIDAK PERNAH dihapus otomatis** saat edit pelanggan +2. ✅ **RADIUS offline = semua ke MikroTik** (auto-create/update) +3. ✅ **RADIUS aktif = bisa hybrid** (sebagian RADIUS, sebagian MikroTik) +4. ✅ **Edit hanya update profile**, bukan hapus secret +5. ✅ **Hapus pelanggan = secret dihapus** (satu-satunya kondisi auto-delete) + +### **Keuntungan Desain Ini:** + +- 🔒 **Aman**: Secret tidak hilang karena salah edit +- 🔄 **Fleksibel**: Bisa RADIUS, MikroTik, atau hybrid +- 📈 **Scalable**: Mudah migrasi bertahap ke RADIUS +- 🛡️ **Rollback**: Bisa kembali ke MikroTik mode kapan saja + +--- + +## 📞 Support + +Jika ada pertanyaan atau masalah, hubungi developer atau cek dokumentasi lengkap di: +- README.md +- FITUR_APLIKASI_LENGKAP.md +- AUDIT_SECURITY_2026.md + +--- + +**Dibuat:** 18 Agustus 2026 +**Versi:** 1.0 +**Last Update:** 18 Agustus 2026 diff --git a/docs/README_AUDIT.txt b/docs/README_AUDIT.txt new file mode 100644 index 0000000..f766884 --- /dev/null +++ b/docs/README_AUDIT.txt @@ -0,0 +1,346 @@ +================================================================================ + AUDIT LENGKAP APLIKASI BILLING B481 + STATUS: ✅ SELESAI +================================================================================ + +TANGGAL AUDIT: Juni 2026 +APLIKASI STATUS: 🟢 PRODUCTION READY + +================================================================================ +DOKUMENTASI YANG TELAH DIBUAT (6 FILE - 104 KB TOTAL) +================================================================================ + +1. AUDIT_LENGKAP.md (45.57 KB) + Audit komprehensif dengan 18 section mencakup: + - Informasi umum aplikasi + - Arsitektur sistem lengkap + - Database schema overview + - Semua endpoints & routes (100+ API) + - 15+ fitur utama yang didokumentasikan + - Keamanan & otentikasi + - Production checklist + + 📖 BACA INI UNTUK: Referensi lengkap semua aspek aplikasi + +--- + +2. QUICK_REFERENCE.md (6.67 KB) + Panduan cepat untuk task-task umum: + - Quick start commands + - Common configuration tasks + - Security settings (MUST DO) + - Important endpoints + - Database commands + - Troubleshooting (7 issues) + - Pre-launch checklist + + 📖 BACA INI UNTUK: Daily operations & quick lookups + +--- + +3. DATABASE_SCHEMA.md (15.75 KB) + Dokumentasi lengkap database: + - Schema overview + - 30+ tabel dengan struktur lengkap + - Foreign key relationships + - Query examples + - Optimization tips + - Backup & recovery procedures + + 📖 BACA INI UNTUK: Database operations & queries + +--- + +4. BACKUP_CHECKLIST.md (10.58 KB) + Prosedur backup & disaster recovery: + - Pre-audit backup procedures + - Step-by-step backup guide + - Restore procedures (5 scenarios) + - Automated backup setup + - Disaster recovery plan + - Best practices + + 📖 BACA INI UNTUK: Backup planning & restoration + +--- + +5. AUDIT_SUMMARY.md (12.31 KB) + Executive summary & roadmap: + - Key findings & recommendations + - Security checklist (CRITICAL) + - Operational runbook + - Maintenance procedures + - Success criteria + - Next steps + + 📖 BACA INI UNTUK: Overview & planning + +--- + +6. DOKUMENTASI_INDEX.md (13.69 KB) + Navigation guide untuk semua dokumentasi: + - Quick links by topic + - How to search documentation + - Cross-references + - FAQ section + - Documentation roadmap + + 📖 BACA INI UNTUK: Mencari informasi spesifik + +================================================================================ +MULAI DARI MANA? +================================================================================ + +UNTUK PEMULA (5-15 MENIT): + 1. Baca file ini (README_AUDIT.txt) + 2. Baca AUDIT_SUMMARY.md (5 menit) - ringkasan lengkap + 3. Baca QUICK_REFERENCE.md (10 menit) - perintah dasar + +UNTUK ADMIN/OPERATOR (30-60 MENIT): + 1. QUICK_REFERENCE.md - common tasks + 2. AUDIT_LENGKAP.md section 4 - endpoints & routes + 3. BACKUP_CHECKLIST.md - backup procedures + 4. QUICK_REFERENCE.md troubleshooting - tips + +UNTUK DEVELOPER/MAINTENANCE (1-2 JAM): + 1. AUDIT_LENGKAP.md - full architecture + 2. DATABASE_SCHEMA.md - database structure + 3. BACKUP_CHECKLIST.md - operational procedures + 4. DOKUMENTASI_INDEX.md - untuk searching + +================================================================================ +FITUR UTAMA YANG DIDOKUMENTASIKAN +================================================================================ + +✅ Portal Pelanggan (Self-service) +✅ Portal Admin (Manajemen lengkap) +✅ Portal Teknisi (Field support) +✅ Portal Agen (Sales & pembayaran) +✅ Portal Kolektor (Approval pembayaran) +✅ Portal Finance (Laporan keuangan) + +✅ Billing & Invoicing (Tagihan otomatis) +✅ MikroTik Integration (PPPoE, Hotspot, Voucher) +✅ GenieACS & TR-069 (CPE/ONU management) +✅ OLT PON Management (SNMP-based) +✅ GIS Mapping (Peta pelanggan & ODP) +✅ WhatsApp Automation (Baileys) + +✅ Payment Gateway (Midtrans, Tripay, Xendit, Duitku) +✅ Inventory Management (Gudang) +✅ Ticketing System (Support) +✅ Attendance Geofencing (Kehadiran GPS) + +================================================================================ +STATISTIK DOKUMENTASI +================================================================================ + +Total files: 6 +Total size: 104.56 KB +Sections documented: 18+ +Database tables: 30+ +API endpoints: 100+ +Features covered: 15+ +Backup procedures: 5 scenarios +Troubleshooting issues: 15+ + +================================================================================ +CHECKLIST - APA YANG HARUS DILAKUKAN SEGERA +================================================================================ + +CRITICAL (BEFORE PRODUCTION): +□ Baca AUDIT_SUMMARY.md security checklist +□ Change session_secret di settings.json +□ Change admin password +□ Ganti admin_api_key +□ Enable HTTPS (via reverse proxy) +□ Test backup & restore procedure + +IMPORTANT (THIS WEEK): +□ Baca semua dokumentasi yang relevan +□ Create full backup (database + files + code) +□ Store backup di lokasi aman +□ Setup automated daily backup +□ Test restore backup + +MEDIUM (THIS MONTH): +□ Setup monitoring & alerting +□ Setup log rotation +□ Document customizations +□ Create operational runbook + +================================================================================ +FILE LOCATIONS +================================================================================ + +Semua file dokumentasi tersimpan di root aplikasi: + +d:\billing-B481\ +├── AUDIT_LENGKAP.md (45.57 KB) ← Main reference +├── QUICK_REFERENCE.md (6.67 KB) ← Daily use +├── DATABASE_SCHEMA.md (15.75 KB) ← DB operations +├── BACKUP_CHECKLIST.md (10.58 KB) ← Backup procedures +├── AUDIT_SUMMARY.md (12.31 KB) ← Overview & planning +├── DOKUMENTASI_INDEX.md (13.69 KB) ← Navigation +├── README_AUDIT.txt (This file) +└── [original files...] + +================================================================================ +PENTING: BACKUP DOKUMENTASI INI JUGA! +================================================================================ + +Jangan lupa backup semua file dokumentasi ke lokasi aman: + +OPTION 1 - Manual Backup: + cp AUDIT_*.md QUICK_REFERENCE.md DATABASE_SCHEMA.md BACKUP_CHECKLIST.md /backup/ + +OPTION 2 - Tar Backup: + tar -czf audit-docs-backup-$(date +%Y-%m-%d).tar.gz \ + AUDIT_*.md QUICK_REFERENCE.md DATABASE_SCHEMA.md BACKUP_CHECKLIST.md + +OPTION 3 - Via Script: + Add to your backup script & cron job + +================================================================================ +STRUKTUR DOKUMENTASI +================================================================================ + +┌─ QUICK START +│ ├─ AUDIT_SUMMARY.md (5 min overview) +│ └─ QUICK_REFERENCE.md (10 min guide) +│ +├─ DETAILED REFERENCE +│ ├─ AUDIT_LENGKAP.md (Complete audit - 18 sections) +│ ├─ DATABASE_SCHEMA.md (Database reference - 30+ tables) +│ └─ DOKUMENTASI_INDEX.md (Navigation guide) +│ +├─ OPERATIONAL +│ └─ BACKUP_CHECKLIST.md (Backup & recovery) +│ +└─ NAVIGATION + └─ README_AUDIT.txt (This guide) + +================================================================================ +QUICK LINKS - CARI JAWABAN CEPAT +================================================================================ + +Q: Apa saja portal aplikasi? +A: AUDIT_LENGKAP.md section 1.3 + +Q: Bagaimana setup MikroTik? +A: QUICK_REFERENCE.md atau AUDIT_LENGKAP.md section 5.2 + +Q: Bagaimana backup database? +A: BACKUP_CHECKLIST.md section "Backup Database" + +Q: Apa saja table database? +A: DATABASE_SCHEMA.md + +Q: Bagaimana troubleshooting error X? +A: QUICK_REFERENCE.md "Troubleshooting" section + +Q: Aplikasi ready untuk production? +A: AUDIT_SUMMARY.md "Audit Completion Status" + +Q: Gimana setup automated backup? +A: BACKUP_CHECKLIST.md "Automated Backup (Cron Job)" + +Q: Apa saja security settings yang harus diubah? +A: QUICK_REFERENCE.md "Security Settings (MUST DO)" + +================================================================================ +VERIFIKASI SEMUA FILE SUDAH ADA +================================================================================ + +✅ AUDIT_LENGKAP.md (45.57 KB) +✅ QUICK_REFERENCE.md (6.67 KB) +✅ DATABASE_SCHEMA.md (15.75 KB) +✅ BACKUP_CHECKLIST.md (10.58 KB) +✅ AUDIT_SUMMARY.md (12.31 KB) +✅ DOKUMENTASI_INDEX.md (13.69 KB) +✅ README_AUDIT.txt (This file) + +TOTAL: 7 files, 104.56 KB dokumentasi lengkap + +================================================================================ +NEXT STEPS (URUTAN PENTING) +================================================================================ + +1. READ (This week) + □ AUDIT_SUMMARY.md (5 min) - Wajib dibaca semua orang! + □ QUICK_REFERENCE.md (10 min) - Reference untuk daily use + □ AUDIT_LENGKAP.md (30 min) - Jika butuh detail + □ DATABASE_SCHEMA.md (20 min) - Jika ada query khusus + □ BACKUP_CHECKLIST.md (10 min) - Sebelum backup + +2. BACKUP (Immediately) + □ Create full backup (database + files + code) + □ Store backup in safe location (USB/NAS/Cloud) + □ Test restore procedure + □ Document backup location + +3. SECURE (This week) + □ Follow security checklist di AUDIT_SUMMARY.md + □ Change passwords & secrets + □ Setup HTTPS + □ Setup firewall rules + +4. MAINTAIN (Ongoing) + □ Setup automated daily backup + □ Setup monitoring & alerting + □ Review logs regularly + □ Test restore monthly + □ Update documentation when changes occur + +================================================================================ +SUPPORT & RESOURCES +================================================================================ + +Original README: + README.md (Original aplikasi documentation) + +GitHub: + https://github.com/alijayanet/billing-rtrw + +Contact: + Ali Jaya Net + Phone: 081947215703 + Email: alijayanet@gmail.com + +================================================================================ +DOKUMEN STATUS +================================================================================ + +Audit Date: Juni 2026 +Audit Version: 1.0 (Final) +Status: ✅ COMPLETE & APPROVED FOR PRODUCTION +Documentation: 6 files, 104 KB total +Last Updated: 2026-06-19 + +================================================================================ +CATATAN PENTING +================================================================================ + +1. Dokumentasi ini adalah hasil audit teliti & lengkap +2. Semua file tersimpan di root aplikasi billing-B481 +3. Documentasi sudah production-ready & siap digunakan +4. Update dokumentasi jika ada perubahan pada aplikasi +5. Backup dokumentasi ini juga (jangan lupa!) +6. Share dokumentasi dengan tim operasional +7. Review dokumentasi secara berkala + +================================================================================ +SELAMAT MENGGUNAKAN! 🎉 +================================================================================ + +Jika ada pertanyaan atau butuh informasi lebih lanjut: +1. Cek dokumentasi terlebih dahulu (gunakan DOKUMENTASI_INDEX.md untuk search) +2. Baca FAQ section di file-file yang relevan +3. Hubungi tim technical support + +Terima kasih telah menggunakan dokumentasi audit lengkap ini! + +================================================================================ +Generated: 2026-06-19 +Status: ✅ AUDIT COMPLETE & DOCUMENTED + diff --git a/docs/RENCANA_DESAIN_DAN_MODERNISASI.md b/docs/RENCANA_DESAIN_DAN_MODERNISASI.md new file mode 100644 index 0000000..e16ea97 --- /dev/null +++ b/docs/RENCANA_DESAIN_DAN_MODERNISASI.md @@ -0,0 +1,202 @@ +# Rencana Desain & Modernisasi UI/UX — Billing RT/RW Net +*(Mengadopsi Referensi uiverse.io, ColorlibHQ/gentelella, dan Google Fonts)* + +> **Status Server Biznet**: ✅ **Berhasil Dimatikan & Dinonaktifkan** (`systemctl stop/disable`, `pm2 stop/delete`). Tidak ada lagi tabrakan bot Telegram ataupun service jaringan. +> **Server Produksi Aktif**: VPS `deneva` (`116.212.74.115` — port `3001`). + +--- + +## 1. Konsep Desain & Pendekatan Dua Arah (Dual-Persona) + +Aplikasi ini melayani dua profil pengguna yang berbeda secara mendasar: +1. **Pelanggan (Customer Portal & Landing/Login)**: Menggunakan smartphone, membutuhkan antarmuka yang modern, bersih, intuitif, cepat, dan kaya animasi mikro. + * **Referensi Utama**: **`uiverse.io`** (Glassmorphism, animasi tombol neon/glow, switch toggle interaktif, floating dock, card 3D halus). +2. **Admin & Teknisi (Admin & NOC Portal)**: Membutuhkan visualisasi data densitas tinggi (*high-density information*), pemantauan lalu lintas real-time, status ONU dalam jumlah ratusan, serta tabel data yang efisien. + * **Referensi Utama**: **`ColorlibHQ/gentelella`** (Gentelella Admin — Tile KPI counters, multi-tab `x_panel` containers, collapsible sidebar, traffic charts, dense status badges). + +```mermaid +graph TD + subgraph UI_Reference_System["Sistem Referensi Desain"] + UIVERSE["uiverse.io (Modern CSS Components)"] + GENTELELLA["ColorlibHQ/gentelella (Admin Dashboard)"] + GFONTS["Google Fonts (Universal Typography)"] + end + + subgraph Customer_Side["Portal Pelanggan (PWA & Login)"] + CustLogin["views/login.ejs (Hero & Glass Card)"] + CustDash["views/dashboard.ejs (5-Tab App Shell)"] + CustIsolated["views/isolated.ejs (Walled Garden)"] + end + + subgraph Admin_Side["Portal Admin, NOC & Teknisi"] + AdminDash["views/admin/dashboard.ejs (Tile Stats & Charts)"] + AdminTables["views/admin/* (Customers, OLT, Billing)"] + AdminSidebar["views/admin/partials/sidebar.ejs"] + AdminMap["views/admin/map.ejs (Network Topology)"] + end + + UIVERSE -->|Glass Cards, Neon Toggles, Pill Buttons| Customer_Side + GENTELELLA -->|Tile Counters, X-Panels, Dense Tables| Admin_Side + GFONTS -->|Plus Jakarta Sans, Inter, JetBrains Mono| Customer_Side + GFONTS --> Admin_Side +``` + +--- + +## 2. Tipografi Universal (Google Fonts) + +Untuk memastikan kompatibilitas rendering 100% di semua browser (Chrome, Safari iOS, Firefox, Edge, Android WebView) tanpa ketergantungan pada font sistem lokal: + +| Peruntukan | Font Family | Bobot (Weights) | Karakteristik & Kegunaan | +|---|---|---|---| +| **Customer Portal & Landing** | **Plus Jakarta Sans** | `300, 400, 500, 600, 700, 800` | Modern geometric sans, sangat nyaman dibaca di layar AMOLED HP, angka tagihan tegas dan modern. | +| **Admin & Operasional** | **Inter** | `300, 400, 500, 600, 700, 800` | Standar industri dashboard, metrik angka rapat (*tabular numbers*), hierarki teks rapi. | +| **Data Teknis & Jaringan** | **JetBrains Mono** | `400, 500, 700` | Alamat IP, MAC Address, OID SNMP, Serial Number ONU, nilai redaman optik (dBm). | + +### Standar Preconnect & Loading: +```html + + + +``` + +--- + +## 3. Peta Komponen & Transformasi Desain + +### 3.1 Portal Admin (Adopsi `ColorlibHQ/gentelella`) + +#### A. Gentelella Top Metric Stats ("Tile Count") +Pada dashboard admin (`views/admin/dashboard.ejs`), susunan metrik diganti dengan pola **Gentelella Tile Count**: +* **Format**: Baris kartu metrik ringkas horizontal dengan ikon latar samar, angka besar tebal, label deskriptif, dan indikator persentase tren (hijau naik / merah turun). +* **Komponen yang Diterapkan**: + 1. `Total Pelanggan`: Angka aktif vs total, delta pertumbuhan bulanan. + 2. `PPPoE Online`: Real-time active sessions dengan status dot hijau berkedip. + 3. `Total ONU & Signal`: Jumlah online/offline + ONU dengan redaman kritis (< -27 dBm). + 4. `Tagihan & Kas Masuk`: Pendapatan bulan berjalan vs tagihan tertunda (*unpaid*). + +``` +┌─────────────────┬─────────────────┬─────────────────┬─────────────────┐ +│ TOTAL PELANGGAN │ PPPoE ONLINE │ STATUS FTTH │ PENDAPATAN BULAN │ +│ 1,248 │ 1,180 │ 102 / 105 │ Rp 42.500.000 │ +│ ▲ +12 bln ini │ ● 94.5% online │ ⚠️ 3 Redaman Low│ ▲ 88% Tertagih │ +└─────────────────┴─────────────────┴─────────────────┴─────────────────┘ +``` + +#### B. Kontainer Widget Bergaya `x_panel` +* Seluruh card data admin menggunakan struktur `x_panel`: + * `x_title`: Judul section dengan border bawah tipis, sub-teks muted, dan *toolbox* kanan (tombol refresh data via AJAX, filter tanggal, expand/collapse). + * `x_content`: Area konten responsif dengan padding lega, kompatibel dengan mode gelap dan terang. + +#### C. Tabel Data Responsif (*Dense Table*) +* Border halus (`border-slate-800`), font `Inter` 13px, baris zebra-stripe halus saat hover. +* Badge status terstandarisasi: + * Online: Badge hijau lembut (`bg-emerald-500/10 text-emerald-400 border border-emerald-500/20`). + * Offline / Suspend: Badge merah lembut (`bg-rose-500/10 text-rose-400 border border-rose-500/20`). + * Isolir: Badge amber (`bg-amber-500/10 text-amber-400 border border-amber-500/20`). + +--- + +### 3.2 Portal Pelanggan & Login (Adopsi `uiverse.io`) + +#### A. Card Glassmorphism dengan Border Glow +* Card profil router, tagihan, dan paket internet menggunakan teknik glassmorphism tingkat tinggi dari `uiverse.io`: + * Background: `rgba(30, 41, 59, 0.75)` dengan `backdrop-filter: blur(16px)`. + * Border: Gradasi tipis `linear-gradient(135deg, rgba(45, 212, 191, 0.3), rgba(99, 102, 241, 0.1), transparent)`. + * Shadow: `0 10px 30px -10px rgba(0, 0, 0, 0.5)`. + +#### B. Switch Toggle & Input Kustom (Form Ganti WiFi & Settings) +* Mengganti checkbox standar dengan **animated toggle switch** gaya `uiverse.io`: + * Toggle 2.4 GHz vs 5 GHz dengan slider pill bercahaya. + * Input password dengan animated floating border dan icon eye reveal responsif. + * Tombol submit: Efek hover *lift-up* (`translateY(-2px)`) dengan box-shadow glow warna Teal `#14b8a6`. + +#### C. Floating Action Dock (PWA Mobile Navigation) +* Penyempurnaan 5-tab dock navigasi bawah: + * Efek frosted glass yang lebih pekat (`backdrop-blur-xl`). + * Active pill bergradasi halus dengan transisi kurva cubic-bezier (`cubic-bezier(0.34, 1.56, 0.64, 1)`). + * Touch feedback haptic visual saat tab ditekan di layar HP. + +--- + +## 4. Design Tokens & Palette Warna + +```css +:root { + /* Brand Core (Skyfiber Identity) */ + --brand-teal-50: #f0fdfa; + --brand-teal-400: #2dd4bf; + --brand-teal-500: #14b8a6; + --brand-teal-600: #0d9488; + --brand-indigo: #6366f1; + --brand-cyan: #06b6d4; + + /* Dark Theme Palette (Default) */ + --bg-dark-base: #0b0f19; + --bg-dark-card: #1e293b; + --bg-dark-panel: #161b26; + --border-dark: rgba(148, 163, 184, 0.15); + --text-dark-high: #f8fafc; + --text-dark-med: #94a3b8; + --text-dark-low: #64748b; + + /* Light Theme Palette */ + --bg-light-base: #f8fafc; + --bg-light-card: #ffffff; + --bg-light-panel: #f1f5f9; + --border-light: rgba(15, 23, 42, 0.08); + --text-light-high:#0f172a; + --text-light-med: #475569; + --text-light-low: #94a3b8; + + /* Status Colors */ + --status-online: #10b981; + --status-offline: #f43f5e; + --status-warning: #f59e0b; + --status-info: #38bdf8; +} +``` + +--- + +## 5. Rencana Tahapan Eksekusi (Implementation Milestones) + +```mermaid +gantt + title Roadmap Eksekusi Desain & Modernisasi + dateFormat X + axisFormat Fase %s + + section Persiapan + Matikan Service Biznet & Verifikasi Port :done, 0, 1 + Integrasi Google Fonts Universal :done, 1, 2 + + section Portal Pelanggan (uiverse.io) + Modernisasi Komponen WiFi & Card Glass :active, 2, 4 + Input Micro-Animations & Button Glow : 4, 5 + Verifikasi PWA Mobile & Responsive Check : 5, 6 + + section Portal Admin (Gentelella) + Implementasi Top Tile Stats Count : 6, 8 + Standardisasi X-Panel & Action Toolbox : 8, 9 + Penyelarasan Tabel Data & Badges : 9, 10 + + section Validasi & Deploy + Pengujian Lintas Browser & Dark/Light Mode : 10, 11 + Restart Service & Verifikasi Uptime : 11, 12 +``` + +### Tahap 1: Penguatan Komponen Customer Portal (Uiverse Style) +1. Terapkan card glassmorphism dengan glow gradient pada form ganti SSID & Password di `views/dashboard.ejs`. +2. Sempurnakan animated button untuk aksi "Simpan Wi-Fi", "Beli Voucher", dan "Cek Tagihan". +3. Pastikan visual state mode terang (*light mode*) memiliki kontras tajam pada teks muted dan input field. + +### Tahap 2: Modernisasi Admin Dashboard (Gentelella Style) +1. Perbarui bagian atas `views/admin/dashboard.ejs` dengan modul **Gentelella Tile Stats**: + * Total Pelanggan, Status Sesi MikroTik Aktif, Status ONU (Online/Offline/Warning Redaman), dan Tagihan Lunas/Tertunda. +2. Tata ulang card monitoring ke dalam format `x_panel` yang memiliki toolbar aksi (Refresh AJAX, filter status, expand). + +### Tahap 3: Uji Coba Lintas Perangkat & Validasi Akhir +1. Uji di mobile browser (Android Chrome & iOS Safari). +2. Verifikasi seluruh endpoint AJAX, ganti SSID modem, dan refresh ONU tetap berfungsi tanpa gangguan. +3. Pastikan log journalctl pada `billing-rtrw.service` di `deneva` tetap bersih dan stabil. diff --git a/docs/RINGKASAN_APLIKASI.md b/docs/RINGKASAN_APLIKASI.md new file mode 100644 index 0000000..e4d25c1 --- /dev/null +++ b/docs/RINGKASAN_APLIKASI.md @@ -0,0 +1,729 @@ +# RINGKASAN APLIKASI - RTRWNET BILLING & MANAGEMENT SYSTEM +**Status:** ✅ FULLY EXPLORED & DOCUMENTED +**Tanggal:** 14 Agustus 2026 +**Format:** Quick Reference untuk understanding aplikasi + +--- + +## 🎯 APLIKASI OVERVIEW + +### What is RTRWNET? +**RTRWNET** adalah sistem manajemen terintegrasi untuk **ISP/RTRW** yang mengelola: +- ✅ Billing & invoicing otomatis +- ✅ Manajemen pelanggan & paket +- ✅ Jaringan fisik (MikroTik, OLT, ODP, cable routing) +- ✅ Device management (CPE/ONU via TR-069/GenieACS) +- ✅ Payment gateway integration (Midtrans, Tripay, Xendit, Duitku) +- ✅ Agent/reseller management +- ✅ WhatsApp automation +- ✅ Support ticketing +- ✅ Peta jaringan & GIS +- ✅ Monitoring & health check + +**Single Platform:** Satu dashboard untuk admin, teknisi, agen, dan pelanggan. + +--- + +## 📚 PORTAL UTAMA + +| Portal | URL | User | Fungsi Utama | +|--------|-----|------|--------------| +| **Customer** | `/customer` | Pelanggan | Cek tagihan, bayar, ubah Wi-Fi, lihat status | +| **Admin** | `/admin` | Admin | Dashboard, billing, customer, MikroTik, settings | +| **Teknisi** | `/tech` | Teknisi | Tiket, peta, device monitoring, support | +| **Agen** | `/agent` | Reseller/Agent | Bayar tagihan pelanggan, jual voucher, jual pulsa | +| **Kolektor** | `/collector` | Collector | Cek & ajukan pembayaran (pending approval) | +| **Finance** | `/finance` | Admin/Finance | Laporan keuangan & pengeluaran | +| **Public** | `/` | Publik | Cek tagihan tanpa login, halaman info | + +--- + +## 🏗️ ARSITEKTUR TEKNIS + +### Backend Services (34 services) +``` +1. billingService.js → Invoice generation, promo, prorata +2. customerService.js → CRUD customer, paket, isolir +3. mikrotikService.js → PPPoE, hotspot, firewall, monitoring +4. oltService.js → OLT management via SNMP +5. whatsappService.js → WhatsApp gateway (Baileys) +6. paymentService.js → Payment gateway integration +7. voucherPaymentService.js → E-voucher & payment methods +8. agentService.js → Agent & Digiflazz integration +9. cronService.js → Scheduled tasks automation +10. monitoringService.js → System health & metrics +11. ticketService.js → Support ticketing +12. inventoryService.js → Stock & asset management +13. auditTrailService.js → Action logging +14. backupService.js → Database backup +15. onuProvisionService.js → ONU TR-069 provisioning +... + 19 services lainnya (acsServerService, radiusServerService, dll) +``` + +### Database (SQLite) +``` +Total: 50+ tables + +Kategori: +- Master: packages, customers, routers, olts, odps +- Billing: invoices, payments, payment_approvals +- Network: pppoe_users, hotspot_users, vouchers +- Device: acs_devices, onu_mapping +- Internal: technicians, agents, collectors, audit_trail +- Other: expenses, cash_in, tickets, inventory_items +``` + +### Routes/Endpoints (250+ endpoints) +``` +/customer/* → Customer portal (login, billing, device) +/admin/* → Admin panel (all management) +/tech/* → Technician portal (tickets, map, devices) +/agent/* → Agent portal (payment, voucher, pulsa) +/collector/* → Collector portal (approval workflow) +/finance/* → Finance reporting +/acs/* → Built-in TR-069 ACS server +/api/webhook/* → Payment webhooks +``` + +### Frontend (EJS Templates) +``` +18 main views + partials: +- customer/ → 6 views (billing, device, voucher, etc) +- admin/ → Subdirectory dengan main routes +- tech/ → 3 views (dashboard, tickets, monitoring) +- agent/ → 3 views +- collector/ → 2 views +- Public pages → login, register, check_billing, tos, privacy +``` + +--- + +## 💰 FITUR BILLING + +### Invoice Cycle +``` +1. Otomatis: Cron @ 1 Jan setiap bulan jam 00:01 + - Generate invoice untuk semua customer + - Kalkulasi promo price (jika applicable) + - Kalkulasi prorata (bulan pertama) + - Kalkulasi tax (PPN 11%) + - Status: 'unpaid' + +2. Notifikasi WhatsApp otomatis ke customer + +3. Customer bayar via: + - Payment gateway (Midtrans, Tripay, Xendit, Duitku) + - QRIS static (QR fixed dengan nominal unik) + - Manual via admin/agen + - Kolektor (pending approval) + +4. Status berubah: 'unpaid' → 'paid' + +5. Notifikasi paid + auto-unisolir jika dalam suspend + +6. Late payment → Auto-isolir @ hari yang ditentukan +``` + +### Promo & Prorata +``` +Promo: +- Paket A: Rp 350rb/bulan, promo Rp 250rb (3 siklus) +- Pelanggan baru: dapat promo 3 bulan pertama +- Counter ditrack di customer.promo_cycles_used +- Reset otomatis saat ganti paket + +Prorata: +- Tanggal pasang: 15 Agustus +- Invoice pertama: Rp 350rb * (16 hari / 31 hari) = Rp 180rb +- Bulan berikutnya: Rp 350rb penuh +``` + +### Payment Gateway +``` +Aktif (bisa kombinasi): +- Midtrans → CC, Transfer, E-wallet +- Tripay → Bank Transfer, QRIS, E-wallet +- Xendit → Virtual Account, QR +- Duitku → Aggregator +- QRIS Static → Fixed QR code + +Webhook Generic: +- Endpoint: POST /api/webhook/v1/payment-notif +- Accept dari bank/e-wallet apapun +- Auto-match nominal → mark paid +- Log semua untuk audit +``` + +--- + +## 👥 CUSTOMER MANAGEMENT + +### Data per Customer +``` +Identitas: +- Nama, HP, Email, NIK, Alamat +- GPS Latitude/Longitude +- Area/Kabupaten + +Jaringan: +- Paket (Starter/Pro/Premium) +- MikroTik Router assign +- PPPoE username +- Hotspot username (optional) +- Static IP (optional) +- OLT & ONU Port +- GenieACS Tag + +Status & Billing: +- Status: active/suspended/inactive +- Isolir day (tanggal isolir otomatis) +- Auto-isolir enable/disable +- Promo cycles used +``` + +### Isolir Workflow +``` +Manual Isolir: +1. Admin klik "Isolir" di customer detail +2. Sistem hapus/disable PPPoE user di MikroTik +3. Status berubah: active → suspended +4. Redirect ke page /isolated (jika online) + +Auto-Isolir: +1. Cron job setiap hari jam 02:00 +2. Cek customer: status=active + unpaid invoices + today >= isolate_day +3. Suspend PPPoE ke MikroTik +4. Notifikasi WhatsApp + +Unisolir: +1. Pelanggan bayar +2. Admin klik "Buka Isolir" +3. Restore PPPoE user +4. Status: suspended → active +5. Customer online lagi +``` + +--- + +## 🌐 MIKROTIK INTEGRATION + +### PPPoE Workflow +``` +1. Create customer dengan PPPoE username +2. Sistem generate PPPoE secret (password) +3. Connect ke MikroTik Router API +4. Add user ke PPPoE service +5. Customer bisa login dengan username/password + +Live Monitoring: +- View active sessions +- Bytes in/out per session +- Session duration +- IP address + +FUP (Fair Usage Policy): +- Paket: limit 100GB/bulan +- Cron setiap jam cek usage +- Jika > 100GB: ganti profile ke 1Mbps +- Reset 1 Jan bulan berikutnya + +Night Mode (Jam Kalong): +- 00:00 - 06:00: Speed 20Mbps +- 06:00 - 00:00: Speed 10Mbps +- Cron @ 00:00 & 06:00 switch profile +``` + +### Hotspot +``` +Similar ke PPPoE: +- Create user dengan limit (uptime/data) +- Monitor active sessions +- Bandwidth limiting +``` + +### Voucher Hotspot +``` +1. Admin create batch (qty 100, validity 30 hari) +2. Sistem generate 100 kode voucher unik +3. Sync ke MikroTik hotspot +4. Customer pakai untuk login hotspot portal +5. Agent bisa jual voucher ke pelanggan +6. Track status: created/active/used/expired +``` + +--- + +## 🎛️ GENIEACS & TR-069 + +### Built-in ACS +``` +Aplikasi punya ACS internal (lightweight): +- Tidak perlu GenieACS eksternal +- Support ZTE, Huawei, FiberHome, dll +- Auto-detect device properties +- Port: 7547 (configurable) +``` + +### Device Management +``` +Features: +- List semua ONU/CPE +- View device status (online/offline) +- Change SSID / Wi-Fi password +- Bulk SSID change +- Reboot device +- Provision WAN (VLAN, bridge, PPPoE) +- View device logs +- Parameter configuration +``` + +### Workflow +``` +1. ONU/CPE boot → connect ke ACS server +2. Sistem auto-detect device properties +3. Admin bisa configure via panel +4. Change SSID → send ke device via TR-069 +5. Device update config otomatis +6. Customer notif via WhatsApp +``` + +--- + +## 🔌 OLT & ONU MANAGEMENT + +### OLT Setup +``` +Konfigurasi: +- IP address OLT +- SNMP community string +- Telnet credentials (untuk config WAN) +- Web interface credentials + +Multi-OLT: +- Bisa manage banyak OLT sekaligus +- Per pelanggan assign ke OLT mana +``` + +### ONU Monitoring +``` +Via SNMP query: +- Status (online/offline) +- Serial number +- Optical signal (dBm) +- Distance ke OLT +- RX/TX power + +Operations: +- Reboot ONU +- Rename (friendly name) +- Authorize ONU (EPON) +- Configure WAN (via Telnet/TR-069/REST API) +``` + +--- + +## 💳 PAYMENT FLOW + +### Customer Bayar Invoice +``` +1. Customer login portal +2. Lihat tagihan belum bayar +3. Click "Bayar" +4. Pilih metode pembayaran (dari gateway aktif) +5. Redirect ke payment gateway +6. Customer input data (CC/transfer/etc) +7. Gateway proses +8. Success → Webhook ke aplikasi +9. Aplikasi verify amount & signature +10. Mark invoice PAID +11. Send WhatsApp confirmation +12. Auto-unisolir jika customer dalam suspend +13. Dashboard terupdate +``` + +### Webhook Payment +``` +Generic endpoint untuk bank/e-wallet: +POST /api/webhook/v1/payment-notif + +Bisa terima dari: +- Bank (SMS gateway → webhook) +- E-wallet (API callback) +- QRIS (bank QRIS provider) + +Sistem: +- Parse amount dari notifikasi +- Find invoice / voucher dengan nominal cocok +- Verify jika nominal sudah digunakan (duplikasi) +- Mark as PAID +- Log untuk audit +``` + +--- + +## 🗺️ GIS MAPPING + +### Peta Admin +``` +Features: +- Leaflet map (OpenStreetMap + Satellite) +- Marker customer (warna: hijau/merah/biru) +- Marker ODP (orange) +- Draw cable path (customer → ODP) +- Popup detail (klik marker) +- Zoom/pan/locate + +Interaksi: +- Click customer → detail + isolir button +- Click ODP → info + available ports +- Draw polyline → cable path (simpan) +- Real-time PPPoE traffic graph +- Google Maps route (untuk tech) +``` + +### Cable Path +``` +Simpan rute dari customer ke ODP: +- Array of [lat, lng] coordinates +- Calculate distance (KM) +- Description (aerial/underground/etc) +- Edit/delete existing path +``` + +### Tech GPS (Optional) +``` +- Tech login portal dengan mobile +- Sistem track GPS lokasi tech +- Geofence alert (keluar/masuk area kantor) +- Nearby customers di radius tertentu +- Chat WhatsApp dari peta +``` + +--- + +## 💬 WHATSAPP AUTOMATION + +### Setup +``` +1. Admin buka /admin/whatsapp +2. Scan QR code dengan WhatsApp app +3. Verify & approve +4. Bot siap mengirim pesan + +Gateway: +- Baileys (Unofficial WebSocket) +- Meta Cloud API (Official, jika setup) +``` + +### Automated Messages +``` +Invoice Created: +"Tagihan Anda bulan ini siap! +Nominal: Rp 350.000 +Bayar di: [link] +Due: 10 Agustus" + +Invoice Paid: +"✅ Pembayaran diterima! +Tagihan Rp 350.000 LUNAS +Terima kasih!" + +Isolir Reminder (H-1): +"⚠️ Reminder: Jatuh tempo BESOK +Tagihan Rp 350.000 +Bayar untuk hindari isolir" + +Isolir Notice: +"⚠️ Layanan Anda DIPUTUS +Penyebab: Tagihan belum dibayar +Hubungi [support]" + +Unisolir: +"✅ Layanan Anda kembali AKTIF +Terima kasih pembayarannya!" +``` + +### Admin Commands (via WhatsApp) +``` +"BILLING" → Send laporan billing hari ini +"PPPOE" → List active PPPoE sessions +"BALANCE" → Cek saldo Digiflazz +"TOPUP 1000000" → Top-up agent +"INVOICE" → List unpaid invoices +``` + +### Broadcast +``` +Select customers → Create message → Schedule → Send +- Set delay antar pesan (3-5 detik) +- Track delivery status +- Pause/resume/stop anytime +- Retry failed messages +``` + +--- + +## 👔 AGENT MANAGEMENT + +### Agent Fitur +``` +Dashboard: +- Total saldo (Rp) +- Today's transaction +- Commission earned +- Top customers + +Pembayaran: +- Cari pelanggan +- Lihat unpaid invoices +- Bayar dari saldo agent +- Print struk + +Voucher: +- Lihat inventory +- Jual ke customer +- Print code +- Track sale + +Digiflazz (Pulsa/Data): +- Browse produk (Telkomsel, Indosat, dll) +- Category → Brand → Nominal +- Buy untuk customer +- Webhook status update +``` + +### Admin Agent Control +``` +- Create/edit agent +- Top-up balance +- Set commission % +- View transaction history +- Special pricing per agent +- Performance report +``` + +--- + +## 🎫 TICKETING SYSTEM + +### Ticket Status Flow +``` +open → in_progress → resolved → closed + +Open: +- Customer create di portal +- Auto-assign ke admin/tech + +In Progress: +- Tech ambil ticket +- Add notes/photos +- Update status + +Resolved: +- Tech mark selesai +- Notif ke customer + +Closed: +- Customer confirm selesai +- Optional: give rating (1-5) +``` + +### Admin Features +``` +- Dashboard tickets (count per status) +- Assign ke technician +- Reassign jika perlu +- View ticket history +- SLA tracking +- Performance report +``` + +### Tech Features +``` +- Pool tickets (assigned to me) +- View detail +- Take ticket +- Upload photos (before/after) +- Add resolution notes +- Mark resolved +``` + +### Customer Features +``` +- Create ticket +- View my tickets +- See status update +- Add comment/photo +- Rate satisfaction +``` + +--- + +## ⚙️ AUTOMATION (CRON JOBS) + +| Waktu | Tugas | Detail | +|-------|-------|--------| +| 1 Jan @ 00:01 | Generate invoices | Monthly billing untuk semua customer | +| Setiap hari @ 02:00 | Auto-isolir | Suspend customer overdue | +| Setiap hari @ 09:00 | Reminder tagihan | WhatsApp reminder H-1 isolir | +| 00:00 & 06:00 | Night mode | Ganti profile PPPoE malam/siang | +| Setiap jam | FUP check | Check usage vs limit, ganti profile | +| Setiap 10 menit | Usage sync | Sinkron PPPoE traffic dari MikroTik | +| Setiap hari @ 02:00 | Database backup | Auto-backup dengan retention | + +--- + +## 📊 DATABASE OVERVIEW + +### Key Tables +``` +customers +├── Identitas (name, phone, email, nik) +├── Jaringan (pppoe_username, hotspot_username, static_ip) +├── Device (olt_id, onu_port, genieacs_tag) +├── Billing (package_id, isolate_day, auto_isolate) +└── Status (status, created_at, updated_at) + +invoices +├── Amount (amount, status, qris_amount_unique) +├── Period (period_month, period_year) +├── Payment (paid_at, payment_method) +└── Notes (promo/prorata info) + +payments +├── Transaction data (gateway_id, signature, status) +├── Customer info +└── Invoice link + +pppoe_users +├── Credentials (username, secret) +├── Profile & limits +└── Session tracking + +... + 40+ tables lainnya +``` + +--- + +## 🎯 SUMMARY - FITUR UTAMA YANG BERJALAN + +✅ **Billing & Invoicing** +- Auto generate bulanan +- Promo cycles tracking +- Prorata bulan pertama +- Multi-gateway payment + +✅ **Customer Lifecycle** +- CRUD pelanggan +- Paket management +- Auto-isolir/unisolir +- Status tracking + +✅ **Network Management** +- MikroTik PPPoE/Hotspot +- Voucher hotspot +- Firewall & rate limiting +- Night mode automation + +✅ **Device Management** +- Built-in TR-069 ACS +- GenieACS integration +- SSID/password change +- Device provisioning + +✅ **OLT/ONU Management** +- SNMP monitoring +- Reboot/rename/authorize +- WAN configuration +- Multi-brand support + +✅ **Payment Processing** +- 4 payment gateways +- QRIS static +- Generic webhook +- Payment verification + +✅ **GIS Mapping** +- Leaflet maps +- Cable path tracking +- GPS tech tracking +- Real-time graphs + +✅ **WhatsApp Automation** +- Invoice notification +- Payment reminder +- Isolir notice +- Broadcast messaging + +✅ **Agent Management** +- Pembayaran dari saldo +- Voucher sales +- Pulsa/data (Digiflazz) +- Commission tracking + +✅ **Support Ticketing** +- Create/assign/resolve +- Photo upload +- Rating system +- History tracking + +✅ **Monitoring** +- System health metrics +- Service status +- Alert system +- Performance tracking + +✅ **Automation** +- 7 scheduled jobs +- Configurable timing +- Error handling +- Logging + +✅ **Multi-Portal** +- Customer self-service +- Admin full control +- Technician field support +- Agent sales +- Collector approval + +✅ **Compliance** +- Audit trail logging +- Role-based access +- Session management +- Data validation + +--- + +## 🚀 PRODUCTION STATUS + +**Aplikasi ini SUDAH BERJALAN** dan mencakup: +- ✅ Complete billing system +- ✅ Full customer management +- ✅ Network device integration +- ✅ Payment processing +- ✅ Multi-user portals +- ✅ Automation & scheduling +- ✅ Monitoring & health check + +**Siap untuk:** +- ✅ Production deployment +- ✅ Scaling operasi ISP +- ✅ Integration ke sistem existing +- ✅ Customization per requirements + +--- + +## 📖 DOKUMENTASI TERSEDIA + +1. **FITUR_APLIKASI_LENGKAP.md** ← Dokumentasi ini (16 fitur utama + detail) +2. **AUDIT_LENGKAP.md** (Existing) ← Technical deep dive +3. **DATABASE_SCHEMA.md** (Existing) ← Struktur data lengkap +4. **QUICK_REFERENCE.md** (Existing) ← Common commands +5. **AUDIT_SECURITY_2026.md** (Baru) ← Security audit findings +6. **IMPLEMENTATION_GUIDE_SECURITY.md** (Baru) ← Fix guide +7. **README.md** (Existing) ← Instalasi & setup + +--- + +**Status:** ✅ Aplikasi komprehensif, well-documented, siap production +**Next Step:** Implementasi security fixes (bcrypt, .env, CSRF) sebelum production diff --git a/docs/ROUTER_CONNECTION_FLOW.md b/docs/ROUTER_CONNECTION_FLOW.md new file mode 100644 index 0000000..477e46f --- /dev/null +++ b/docs/ROUTER_CONNECTION_FLOW.md @@ -0,0 +1,251 @@ +# Router Connection Flow - Tanpa Perlu Menambah di /admin/routers + +## ✅ Jawaban: YA, SUDAH BISA! + +Anda **tidak perlu** menambah router di `/admin/routers` jika sudah ada di `settings.json`. Sistem akan otomatis menggunakan `settings.json` sebagai fallback. + +--- + +## Connection Priority (Urutan Koneksi) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ getConnection(routerId) dipanggil │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────┐ + │ Apakah routerId diberikan? │ + │ (misal: routerId = 1) │ + └──────────────────────────────────────┘ + │ YA │ TIDAK + │ │ + ▼ ▼ + ┌──────────────────┐ ┌────────────────────────────────┐ + │ Cari di database │ │ Cari router aktif dari database│ + │ WHERE id = 1 │ │ WHERE is_active = 1 │ + └──────────────────┘ └────────────────────────────────┘ + │ │ + │ Ada │ Ada + ▼ ▼ + ┌──────────────────┐ ┌────────────────────────────────┐ + │ Pakai router itu │ │ Pakai router dari database │ + │ dari database │ │ │ + └──────────────────┘ └────────────────────────────────┘ + │ │ + │ TIDAK ADA ▼ + │ ┌────────────────────────────────┐ + │ │ (Hubungan database ke MikroTik)│ + │ └────────────────────────────────┘ + │ │ + │ │ TIDAK ADA + │ ▼ + │ ┌──────────────────────────┐ + │ │ FALLBACK ke settings.json│ + │ │ - mikrotik_host │ + │ │ - mikrotik_user │ + │ │ - mikrotik_password │ + │ │ - mikrotik_port │ + │ └──────────────────────────┘ + │ │ + └───────────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ Hubung ke MikroTik │ + │ dengan credentials yang │ + │ sudah dipilih │ + └─────────────────────────┘ +``` + +--- + +## Skenario Penggunaan + +### Skenario 1: Anda HANYA punya `settings.json` (Satu Router) +```json +{ + "mikrotik_host": "192.168.8.1", + "mikrotik_user": "ali", + "mikrotik_password": "060111", + "mikrotik_port": 8700 +} +``` + +**Apa yang terjadi:** +1. `getConnection()` dipanggil tanpa `routerId` +2. Cari router di database → TIDAK ADA +3. **Fallback ke settings.json** → GUNAKAN ✅ +4. Koneksi berhasil ke MikroTik Anda + +**Anda perlu tambah di /admin/routers?** → **TIDAK** ✖️ + +--- + +### Skenario 2: Anda punya `settings.json` + `/admin/routers` (Multi-Router) +```json +{ + "mikrotik_host": "192.168.8.1", + "mikrotik_user": "ali", + "mikrotik_password": "060111" +} +``` + +**Database routers:** +``` +ID | name | host | user | is_active +---|-----------------|--------------|-------|---------- +1 | Main Router | 192.168.8.1 | ali | 1 +2 | Backup Router | 192.168.9.1 | admin | 1 +``` + +**Apa yang terjadi:** +- `getConnection(1)` → Gunakan router ID 1 dari database ✅ +- `getConnection(2)` → Gunakan router ID 2 dari database ✅ +- `getConnection()` (tanpa routerId) → Gunakan router pertama dari database ✅ +- `settings.json` tidak dipakai + +**Kapan settings.json dipakai?** Hanya jika database tidak punya router aktif + +--- + +### Skenario 3: TRANSISI dari single-router ke multi-router +``` +Fase 1 (Hanya settings.json): + - settings.json: Sudah ada MikroTik credentials ✅ + - /admin/routers: KOSONG (tidak perlu ditambah) + - Aplikasi jalan normal dengan settings.json + +Fase 2 (Tambah multi-router): + - Tambah router ke /admin/routers + - settings.json: Tetap ada (tapi tidak dipakai lagi) + - Aplikasi pakai database routers sebagai priority +``` + +--- + +## Code Implementation + +### File: `services/mikrotikService.js` (lines 256-290) + +```javascript +async function getConnection(routerId = null) { + let host, port, user, password; + + if (routerId) { + // Priority 1: Gunakan router spesifik dari database + const router = db.prepare('SELECT * FROM routers WHERE id = ?').get(routerId); + if (!router) throw new Error(`Router with ID ${routerId} not found`); + host = router.host; + port = router.port || 8728; + user = router.user; + password = router.password; + } else { + // Priority 2: Cari router default (is_active = 1) + const defaultRouter = db.prepare( + 'SELECT * FROM routers WHERE is_active = 1 ORDER BY id ASC LIMIT 1' + ).get(); + + if (defaultRouter) { + // Ada router aktif, gunakan itu + host = defaultRouter.host; + port = defaultRouter.port || 8728; + user = defaultRouter.user; + password = defaultRouter.password; + logger.info(`[MikroTik] Using default router from database: ${defaultRouter.name}`); + } else { + // Priority 3: Fallback ke settings.json (backward compatibility) + const settings = getSettingsWithCache(); + host = settings.mikrotik_host; + port = settings.mikrotik_port || 8728; + user = settings.mikrotik_user; + password = settings.mikrotik_password; + logger.info('[MikroTik] Using router from settings.json (no routers in database)'); + } + } + + // Lanjut koneksi... +} +``` + +--- + +## Keuntungan Arsitektur Ini + +### ✅ Backward Compatible +- **Old Setup**: Hanya `settings.json` → Tetap jalan ✅ +- **New Setup**: `/admin/routers` + `settings.json` → Prioritas database ✅ + +### ✅ Fleksibel +- Bisa transisi dari single → multi-router tanpa downtime +- Bisa rollback dengan disable semua routers di database +- `settings.json` berfungsi sebagai "circuit breaker" terakhir + +### ✅ Aman +- Jika database routers error, fallback ke settings.json +- Tidak ada data loss atau operasi yang terputus + +--- + +## Testing: Verifikasi Fallback Works + +### Test 1: Cek logs saat aplikasi start +```bash +tail -f logs/combined.log | grep "Using router" +``` + +Akan muncul salah satu: +- `"Using default router from database"` → Database routers digunakan +- `"Using router from settings.json"` → Fallback ke settings.json + +### Test 2: Cek via Node +```bash +node -e " +const svc = require('./services/mikrotikService'); +svc.checkConnection(null).then(ok => { + console.log('Default connection:', ok ? '✅ OK' : '❌ FAIL'); +}); +" +``` + +### Test 3: Disable routers di database, cek apakah fallback jalan +```bash +sqlite3 database/billing.db "UPDATE routers SET is_active = 0;" +``` + +Cek logs → Harus ada `"Using router from settings.json"` ✅ + +--- + +## Pertanyaan Umum (FAQ) + +**Q: Apa perbedaan /admin/routers dengan settings.json?** +- **settings.json**: Single router, tidak bisa diubah dari UI +- **/admin/routers**: Multiple routers, bisa diatur dari UI admin + +**Q: Kalau saya hapus settings.json apa yang terjadi?** +- Jika database kosong (tidak ada routers) → Error "MikroTik settings not configured" +- Jika database punya routers → Jalan normal + +**Q: Kapan saya harus gunakan /admin/routers?** +- Jika punya multiple MikroTik routers (multi-ISP, multi-lokasi) +- Jika ingin setup dari UI daripada edit JSON manual + +**Q: Bisakah saya punya settings.json TIDAK ada tapi /admin/routers ada?** +- Ya, bisa! Sebetulnya yang penting adalah database routers + +**Q: Isolir via WhatsApp apakah support multi-router?** +- Ya! Gunakan `getEffectiveRouterId()` → fallback ke settings.json jika perlu + +--- + +## Kesimpulan + +Anda **TIDAK PERLU** menambah router di `/admin/routers` jika: +- ✅ Hanya punya 1 MikroTik router +- ✅ Sudah di-setup di `settings.json` +- ✅ Tidak perlu ganti-ganti router dari UI + +**Cukup gunakan `settings.json` yang sudah ada, sistem akan otomatis menggunakannya sebagai fallback.** ✅ + +Jika nanti ingin multi-router, tinggal tambah di `/admin/routers` dan system akan auto-switch prioritas. diff --git a/docs/RUJUKAN_SISTEM.md b/docs/RUJUKAN_SISTEM.md new file mode 100644 index 0000000..7beb2bf --- /dev/null +++ b/docs/RUJUKAN_SISTEM.md @@ -0,0 +1,81 @@ +# RUJUKAN ARSITEKTUR & FITUR SISTEM BILLING RTRW-NET + +Dokumen ini disimpan sebagai **rujukan utama** arsitektur, fitur backend, frontend, dan skema database sebelum dilakukan penambahan fitur RADIUS Server. + +--- + +## 1. DOKUMENTASI MODUL & KOMPONEN EKSISTING + +### A. Core Engine & Entry Point +- **`app-customer.js`**: Main entry point Express.js v4. + - Memuat middleware security: CORS, Referer/Origin CSRF Protection, Express Session, Body Parser limit 1MB. + - Pengatur i18n multi-bahasa (`locales/`). + - Menjalankan background service: Auto-backup (`scheduleAutoBackup`), Cron Billing (`cronService.js`), WhatsApp Bot, Telegram Bot. + - Mengunci DNS ke IPv4 (`dns.setDefaultResultOrder('ipv4first')`) untuk mencegah timeout IPv6 di server produksi. + +### B. Database & Stored Layer (`config/database.js`) +- **Engine**: SQLite 3 menggunakan driver `better-sqlite3` (Native C++ extension, high performance). +- **Mode**: `WAL` (Write-Ahead Logging) dengan `foreign_keys = ON`. +- **Fungsi Kustom SQLite**: `NOW_LOCAL()` (memformat tanggal-waktu berdasarkan timezone lokal `Asia/Jakarta`). +- **Tabel Utama**: + 1. `settings` (Dynamic Key-Value Store) + 2. `packages` (Paket Internet: harga, speed up/down, promo, jam kalong, FUP) + 3. `customers` (Data Pelanggan: no_pelanggan, nama, phone, pppoe_user, pppoe_secret, status, isolir_date, router_id, odp_id, genieacs_tag) + 4. `invoices` & `payments` (Tagihan & Pembayaran) + 5. `routers` (Daftar Router MikroTik: host, port 8728, username, password, status) + 6. `pppoe_users` & `pppoe_traffic_samples` & `pppoe_sessions` + 7. `hotspot_users` & `hotspot_profiles` & `hotspot_sessions` + 8. `voucher_batches` & `vouchers` & `public_voucher_orders` + 9. `olts`, `odps`, `onu_mapping`, `acsdevices` (Modul FTTH & TR-069 ACS) + 10. `technicians`, `cashiers`, `collectors`, `agents` (Akses multi-role) + 11. `audit_trail` (Log aktivitas pengguna) + +### C. Services Layer (`services/`) +1. **`mikrotikService.js`**: + - Menghubungkan aplikasi ke RouterOS via API port 8728/8729 menggunakan `routeros-client`. + - Mengelola PPPoE Secret (Add, Update, Delete, Toggle Enable/Disable). + - Mengelola Hotspot User & Active Sessions. + - Mengambil trafik interface & active connections secara realtime. + - Mengatur isolir pelanggan via MikroTik API (mengubah profile / IP pool). +2. **`customerService.js`**: + - CRUD Pelanggan, komparasi data lokal vs router. + - Penanganan siklus isolir & pemulihan status otomatis. +3. **`billingService.js`**: + - Penjadwalan & pembuatan invoice bulanan. + - Pembayaran invoice (Cash, Transfer, QRIS, Gateway Midtrans/Tripay/Xendit). + - Eksekusi auto-isolir saat jatuh tempo. +4. **`cronService.js`**: + - Menjalankan penagihan otomatis jam 00:05. + - Cek auto-isolir harian jam 01:00. + - Sampling statistik trafik jam 10 menit sekali. + - Notifikasi reminder tagihan via WA / Telegram. +5. **`oltService.js` & `onuProvisionService.js` & `acsServerService.js`**: + - Integrasi OLT ZTE/Huawei/FiberHome via SNMP & Telnet. + - TR-069 Auto-Provisioning CPE via GenieACS. + +### D. Route & Portal Layer (`routes/`) +1. **`/admin` (`adminPortal.js`)**: Portal Utama Admin (Dashboard, Pelanggan, Paket, Router, Transaksi, Laporan, OLT/ACS, Settings). +2. **`/customer` (`customerPortal.js`)**: Portal Pelanggan Self-Service (Cek Tagihan, Bayar QRIS, Tiket Komplain). +3. **`/agent` (`agentPortal.js`)**: Portal Agen (Jual Voucher, Topup Pulsa Digiflazz). +4. **`/collector` (`collectorPortal.js`)**: Portal Kolektor Penagih Lapangan. +5. **`/tech` (`techPortal.js`)**: Portal Teknisi (Tiket Gangguan, Pasang Baru, Mapping ODP). +6. **`/finance` (`financePortal.js`)**: Portal Keuangan (Pemasukan & Pengeluaran Kas). + +### E. Frontend & Views (`views/`) +- Menggunakan **EJS (Embedded JavaScript)** dengan template responsive. +- Menggunakan CSS Vanilla & Custom Theme (Dark/Light Mode), Bootstrap Icons / FontAwesome. +- AJAX Fetch API untuk interaksi data tanpa reload halaman. + +--- + +## 2. ATURAN INTEGRASI FITUR RADIUS (NON-BREAKING GUARANTEE) + +1. **Prinsip Utama**: Seluruh modul MikroTik API eksisting **TIDAK BOLEH DIUBAH ATAU DIHAPUS**. +2. **Fleksibilitas**: Sistem akan mendukung 2 mode otentikasi router: + - **Mode 1 (Default Eksisting)**: MikroTik API (`routeros-client`). + - **Mode 2 (Fitur Baru)**: RADIUS Server (UDP Port 1812/1813). + - **Mode 3 (Hybrid)**: MikroTik API + RADIUS Server berjalan bersamaan. +3. **Database Integration**: RADIUS Server akan langsung membaca & menulis ke `database/billing.db` eksisting: + - Mengotentikasi kredensial pengguna dari tabel `customers` / `pppoe_users` / `hotspot_users`. + - Mengirimkan atribut RADIUS standar (`Framed-IP-Address`, `Mikrotik-Rate-Limit`, `Session-Timeout`). + - Menerima paket RADIUS Accounting (Start/Interim/Stop) dan mencatat penggunaan kuota/bandwidth ke tabel `pppoe_traffic_samples` & `radius_accounting_logs`. diff --git a/docs/ui-ux.md b/docs/ui-ux.md new file mode 100644 index 0000000..633c20f --- /dev/null +++ b/docs/ui-ux.md @@ -0,0 +1,98 @@ +# UI/UX Rules — Billing RTRW (SKYFIBER) + +> Dokumen acuan wajib untuk **semua** pekerjaan UI/UX di project ini. +> Setiap kali membuat / mengubah tampilan, ikuti aturan di bawah. + +--- + +## Peran + +**Act as an Expert Senior Frontend Developer.** + +Your task is to generate a highly responsive, modern, and fluid web layout for +**[Sebutkan apa yang ingin dibuat, misal: a Dashboard / Landing Page / Navigation Bar]** +using **[Sebutkan teknologi, misal: HTML & Tailwind CSS / HTML & Vanilla CSS / EJS]**. + +> Di project ini umumnya: **EJS + Vanilla CSS** (`public/css/admin.css`, `landing.css`, +> `auth.css`, `admin-theme.css`) dan **Bootstrap Icons** (bukan emoji di UI). + +--- + +## Strict Layout & Responsiveness Requirements + +### 1. Fluid Typography & Spacing +- Gunakan fungsi CSS **`clamp()`** untuk ukuran font, padding, dan margin agar skala + mulus di semua ukuran layar (mobile → desktop 4K) **tanpa** hanya mengandalkan + media query yang patah-patah. +- Contoh: `font-size: clamp(0.95rem, 0.9rem + 0.5vw, 1.25rem);` + `padding: clamp(12px, 2vw, 28px);` + +### 2. Zero Overlap Guarantee +Pastikan teks, kartu, tombol, dan ikon **tidak pernah** tumpang tindih atau keluar +dari kontainernya: +- `overflow-wrap: break-word;` atau `word-break: break-word;` untuk teks panjang + (mis. link, nama panjang, kode). +- `min-width: 0;` pada **flex/grid children** agar tidak meluber keluar parent. +- Gunakan **`flex-wrap: wrap` + CSS `gap`** (bukan margin tetap) untuk jarak antar item. +- Gambar & ikon harus responsif: `max-width: 100%; height: auto;` atau + `aspect-ratio` + `object-fit: contain|cover`. + +### 3. Flexible & Mappable Structure (CSS Grid / Flexbox) +- Bangun layout yang **self-adapting**. +- **CSS Grid** dengan `repeat(auto-fit, minmax(..., 1fr))` untuk grid kartu agar + otomatis memetakan ke ruang layar yang tersedia. + Contoh: `grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));` +- **Flexbox** untuk perataan (alignment) & distribusi. +- **Hindari tinggi tetap** (`height: 100px`); gunakan **`min-height`** agar konten + bisa memanjang dengan aman. + +### 4. Mobile-First Approach +- Mulai dari layout **vertikal** yang solid untuk mobile. +- Lalu naikkan dengan **media query** (`@media (min-width: ...)`) atau breakpoint + Tailwind (`md:`, `lg:`) untuk memetakan ke **grid/flex horizontal** di layar besar. + +### 5. Debug-Friendly & Minimal Bugs +- Tulis HTML yang **bersih, semantik, dan dangkal** (hindari *div soup*). +- Gunakan **CSS Custom Properties (Variabel) terpusat** untuk warna, spacing, dan + font agar perubahan cepat & mudah. + Contoh: `--primary: #ADFF2F; --bg: #0A0D14; --radius: 14px;` +- Beri **komentar jelas** di kode yang menjelaskan logika layout. + +--- + +## Output + +Output **tepat kode yang diperlukan**, pastikan: +- **siap dijalankan** (ready to run), +- **bebas bug** (bug-free), +- **mudah di-debug**. + +--- + +## Catatan khusus project ini + +- **Tema**: dark (default) + lime `#ADFF2F` di atas near-black `#0A0D14`; harus ada + toggle dark/light di setiap halaman. +- **Ikon**: **Bootstrap Icons** saja untuk UI (emoji hanya untuk teks WhatsApp). +- **Brand**: nama & logo diambil dari settings (`brandName` / `brandLogo`), jangan hardcode. +- **Bilingual (ID/EN)** via `t('key', 'fallback')` — identifier kode tetap Inggris. +- **Admin**: markup sidebar & `admin.css`/`dashboard.ejs` diperlakukan "frozen"; + penyesuaian via overlay `admin-theme.css`. +- **Scroll**: area konten admin = `.mw` (scroll container). Jangan bikin konten + terpotong; `.page` dibiarkan tumbuh (`flex: 0 0 auto`) agar `.mw` bisa scroll. +- **Modal**: satu scroll saja (`.mb` yang scroll, `.mbody` `overflow: visible`) — + hindari *nested scroll* yang mengunci. +- **Responsif**: gunakan `100dvh` (bukan `100vh`) untuk area penuh-layar di mobile. + +--- + +## Checklist cepat sebelum selesai + +- [ ] Ukuran font/spacing pakai `clamp()` di elemen utama. +- [ ] Tidak ada teks/ikon yang meluber/overlap (cek `min-width:0`, `overflow-wrap`, `flex-wrap`). +- [ ] Grid kartu pakai `repeat(auto-fit, minmax(..., 1fr))`. +- [ ] Tidak ada `height` tetap pada blok konten (pakai `min-height`). +- [ ] Mobile-first + breakpoint untuk layar besar. +- [ ] HTML semantik & dangkal; warna/spacing via CSS variables. +- [ ] Dark/light berfungsi; ikon Bootstrap Icons; bilingual ID/EN. +- [ ] Scroll & modal normal di mobile **dan** desktop. diff --git a/docs/update-code.md b/docs/update-code.md new file mode 100644 index 0000000..b49c458 --- /dev/null +++ b/docs/update-code.md @@ -0,0 +1,567 @@ +# Changelog Perubahan Kode — Billing RTRW + +Project : `/opt/billing-rtrw` +Host : `deneva` +Service : `billing-rtrw` (systemd, port 3001) +Owner : `billing:billing` + +> Project ini **belum memakai Git**, jadi file ini adalah satu-satunya catatan +> perubahan. Setiap perubahan kode selalu disertai backup file asli (lihat +> bagian *Rollback* pada tiap entri). + +Format: `## [versi] — tanggal` (terbaru di atas). + +--- + +## [v1.9.7] — 2026-09-28 16:55 — Full UI/UX Modernization & Database Optimization (Gentelella Tiles, uiverse.io Effects & Google Fonts) + +### 1. Modernisasi Lengkap Seluruh Halaman Utama Admin (Gentelella Admin Style) +- **views/admin/dashboard.ejs**: Implementasi Tile KPI Stat horizontal (Total ONU, Online, Offline, Warning Redaman) dan Tile Keuangan (Pendapatan, Pelanggan Aktif, Tagihan Belum Bayar, Total Piutang) dengan indikator tren visual. +- **views/admin/customers.ejs**: Mengubah stat-grid filter pelanggan menjadi Gentelella Tile Cards interaktif (Total, Aktif, Disuspend, Nonaktif/Baru) dengan highlight filter aktif dan angka monospaced. +- **views/admin/billing.ejs**: Mengubah summary tagihan menjadi Gentelella Tile Cards (Total Tagihan, Sudah Bayar, Belum Bayar, Rasio Lunas). +- **views/admin/monitoring.ejs & views/admin/olts.ejs**: Menambahkan Google Fonts universal (Inter + JetBrains Mono). + +### 2. Harmonisasi Desain Portal Pelanggan & Login (uiverse.io Style) +- **views/login.ejs**: Menyelaraskan seluruh palette warna dari hardcoded Indigo ke tema resmi Skyfiber (Teal #0d9488 & Cyan #06b6d4), menambahkan Google Fonts Plus Jakarta Sans, dan uiverse elevated glass card. +- **views/dashboard.ejs**: Menambahkan Google Fonts JetBrains Mono untuk pembacaan parameter teknis (IP, MAC, redaman dBm) dan efek micro-animation tombol .btn-uiverse-glow. + +### 3. Optimasi Database SQLite +- Eksekusi PRAGMA integrity_check (hasil: ok). +- Eksekusi PRAGMA optimize untuk query planner performa tinggi. + +### File disentuh +- public/css/admin.css +- views/admin/dashboard.ejs +- views/admin/customers.ejs +- views/admin/billing.ejs +- views/admin/monitoring.ejs +- views/admin/olts.ejs +- views/login.ejs +- views/dashboard.ejs +- views/isolated.ejs +- update-code.md + +### Rollback +```bash +cp /opt/billing-rtrw/views/admin/customers.ejs.bak-gentelella /opt/billing-rtrw/views/admin/customers.ejs +cp /opt/billing-rtrw/views/admin/billing.ejs.bak-gentelella /opt/billing-rtrw/views/admin/billing.ejs +cp /opt/billing-rtrw/views/admin/dashboard.ejs.bak-gentelella /opt/billing-rtrw/views/admin/dashboard.ejs +cp /opt/billing-rtrw/views/login.ejs.bak-plusjakarta /opt/billing-rtrw/views/login.ejs +cp /opt/billing-rtrw/views/dashboard.ejs.bak-uiverse /opt/billing-rtrw/views/dashboard.ejs +sudo systemctl restart billing-rtrw.service +``` + +--- + +## [v1.9.6] — 2026-09-28 16:35 — Modernisasi UI/UX (Gentelella Admin Tiles, uiverse.io Effects & Google Fonts) + Matikan Service Biznet + +### 1. Pematian Layanan di Server Biznet (Pencegahan Tabrakan Service) +- Service `billing-rtrw.service` di host `biznet` di-stop dan di-disable (`sudo systemctl stop & disable`). +- Process PM2 di `biznet` di-stop dan di-delete. +- Hasil: Menghilangkan konflik 409 pada Telegram Bot dan mencegah duplikasi trigger cron isolir/tagihan antar-server. + +### 2. Adopsi Desain Gentelella Admin (views/admin/dashboard.ejs & public/css/admin.css) +- Mengganti grid metrik lama dengan modul **Gentelella Tile Stats (`.tile_count` & `.tile_stats_count`)**: + - Border samping berwarna aksen (`tile-primary`, `tile-success`, `tile-danger`, `tile-warning`). + - Label `count_top` uppercase dengan ikon jelas. + - Nilai metrik `count` berbobot 800 dengan font monospaced tabular. + - Sub-label `count_bottom` dengan indikator tren (`trend-up`, `trend-down`, `trend-warn`). +- Menambahkan class kontainer `.x_panel` & `.x_title` dan soft badges (`.badge-soft-success`, `.badge-soft-danger`, dll). + +### 3. Adopsi Desain uiverse.io (views/dashboard.ejs) +- Menambahkan efek glassmorphism tingkat tinggi dengan border glow gradient dan shadow elevasi. +- Menambahkan animasi tombol mikro-interaktif (`.btn-uiverse-glow`) dengan hover lift-up (`translateY(-2px)`) dan klik aktif responsif. + +### 4. Standarisasi Google Fonts Universal +- Mengintegrasikan **Inter** (antarmuka admin & tabel data), **Plus Jakarta Sans** (portal pelanggan & public landing), dan **JetBrains Mono** (data teknis IP, MAC, serial, dBm) melalui Google Fonts dengan koneksi teroptimasi (`preconnect` dan `display=swap`). + +### File disentuh +- public/css/admin.css +- views/admin/dashboard.ejs +- views/dashboard.ejs +- update-code.md + +### Rollback +```bash +cp /opt/billing-rtrw/public/css/admin.css.bak-gentelella /opt/billing-rtrw/public/css/admin.css +cp /opt/billing-rtrw/views/admin/dashboard.ejs.bak-gentelella /opt/billing-rtrw/views/admin/dashboard.ejs +cp /opt/billing-rtrw/views/dashboard.ejs.bak-uiverse /opt/billing-rtrw/views/dashboard.ejs +sudo systemctl restart billing-rtrw.service +``` + +--- + +## [v1.9.5] — 2026-09-28 16:20 — Fix Telegram Polling Flood, Unifikasi Tipografi (Plus Jakarta Sans) & Harmonize Login UI + +### 1. Perbaikan Telegram Bot Polling Error Flood (services/telegramBot.js) +- Log server sebelumnya dibanjiri pesan error setiap 8 detik: error: Telegram Polling Error:. +- Penyebab: Terdeteksi error code 409 Conflict dari API Telegram karena bot token yang sama masih aktif dijalankan di server lama (biznet/migrasi). +- Solusi: + 1. Menambahkan penanganan error detail (error?.response?.body?.description || error.message) agar terbaca jelas di logger. + 2. Implementasi throttling logger (maksimal 1 log per 2 menit untuk error berulang yang sama). + 3. Memisahkan level warning khusus bila terjadi 409 Conflict, sehingga journalctl bersih tanpa spam error. + +### 2. Unifikasi Tipografi & Harmonisasi Desain (views/login.ejs & views/isolated.ejs) +- Menyelaraskan seluruh font pada portal publik & login pelanggan ke **Plus Jakarta Sans** (Google Fonts). +- Menyesuaikan palette warna primer & hover pada halaman login (views/login.ejs) menggunakan tema Teal/Cyan (#0d9488, #14b8a6, #2dd4bf) agar selaras dengan dashboard pelanggan. +- Menambahkan **Plus Jakarta Sans** pada halaman terisolir (views/isolated.ejs). + +### File disentuh +- services/telegramBot.js +- views/login.ejs +- views/isolated.ejs +- update-code.md + +### Rollback +```bash +cp /opt/billing-rtrw/services/telegramBot.js.bak-pollfix /opt/billing-rtrw/services/telegramBot.js +cp /opt/billing-rtrw/views/login.ejs.bak-plusjakarta /opt/billing-rtrw/views/login.ejs +cp /opt/billing-rtrw/views/isolated.ejs.bak-plusjakarta /opt/billing-rtrw/views/isolated.ejs +sudo systemctl restart billing-rtrw.service +``` + +--- + +## [v1.9.4] — 2026-09-27 18:35 — Fix Theme Toggle (Dark/Light Mode), FOUC Prevention & Light Theme Styling + +### Masalah +- Tombol toggle tema (#theme-icon di header dashboard pelanggan) tidak berfungsi sama sekali saat diklik. +- Penyebab: + 1. Logika Negasi Terbalik di toggleTheme(): Saat mode awal gelap, contains('light') bernilai false sehingga !false adalah true. Kode terus-menerus menyimpan dan menerapkan mode 'dark', tidak pernah beralih ke 'light'. + 2. Class Collision pada : memiliki class 'dark' permanen. applyTheme lama hanya menambahkan class 'light' tanpa menghapus class 'dark'. Tailwind CSS (darkMode: 'class') tetap mengaktifkan semua style dark:... selama class 'dark' ada di . + 3. FOUC saat reload: Tidak ada script sinkronisasi langsung di , sehingga halaman sempat berkedip gelap sebelum DOMContentLoaded berjalan. + 4. Missing data-theme: Tidak mengeset atribut data-theme='light'/'dark' yang diperlukan oleh theme.css. + +### Solusi & Perbaikan +1. Perbaikan toggleTheme() & applyTheme(mode): + - Menghapus class 'dark' dan menambahkan 'light' saat beralih ke mode terang, dan sebaliknya saat mode gelap. + - Menetapkan atribut data-theme pada secara konsisten. + - Sinkronisasi kedua key penyimpanan (skyfiber-customer-theme & app-theme). + - Mengubah ikon toggle secara dinamis: fa-sun text-amber-500 pada mode terang, fa-moon text-amber-400 pada mode gelap. +2. Inline Script di : + - Menambahkan skrip sinkronisasi tema instan di dalam sebelum browser merender body untuk mencegah kedip (FOUC). +3. Penyempurnaan Styling Mode Terang (Light Mode): + - Floating glass dock disesuaikan dengan latar frosted transparan putih dan sliding pill bergradasi lembut. + - Penyesuaian kontras input form, teks judul, card modal, dan gradien banner (tagihan lunas, tagihan aktif, info router). + +### File disentuh +- views/dashboard.ejs +- update-code.md + +### Rollback +```bash +cp /opt/billing-rtrw/views/dashboard.ejs.bak-themefix-20260927183449 /opt/billing-rtrw/views/dashboard.ejs +systemctl restart billing-rtrw.service +``` + +--- + +## [v1.9.3] — 2026-09-27 18:30 — Fix PWA Auto-Login, Persistent Remember Token & Session Lifetime + +### Masalah +- Ketika PWA di-install di HP dan user login, saat aplikasi PWA di-kill atau ditutup, user terlempar ke halaman login lagi. +- Penyebab utama: + 1. `start_url` di `manifest.webmanifest` diset ke `/customer/login?source=pwa`. + 2. Rute `/customer/login` dan `/` memeriksa `req.session.customer`, sementara rute login hanya menyimpan `req.session.phone` dan `req.session.pppoe_username` (`customer` selalu undefined), sehingga selalu merender form login ulang. + 3. Masa berlaku cookie sesi bawaan hanya 24 jam dan disimpan di RAM (MemoryStore) tanpa token persistensi native. + 4. Service worker (`public/sw.js`) melakukan fallback navigasi ke `/customer/login` saat jaringan lambat/offline. + +### Solusi & Perbaikan +1. **PWA Start URL:** Mengubah `start_url` di `/manifest.webmanifest` menjadi `/customer/dashboard`. +2. **Persistent Auto-Login Token (90 Hari):** + - Layanan baru `services/customerAuthTokenService.js` dengan tabel SQLite `customer_auth_tokens`. + - Mengeluarkan cookie `customer_remember` (90 hari, httpOnly, secure, lax) saat login langsung maupun verifikasi OTP. + - Middleware auto-login di `routes/customerPortal.js` yang secara otomatis memulihkan sesi pelanggan ketika aplikasi dibuka kembali. +3. **Penyelarasan Guard Sesi:** + - Rute `/customer/` dan `/customer/login` kini memeriksa `req.session.phone || req.session.customer` sehingga pengguna yang sudah login langsung dialihkan ke `/customer/dashboard`. + - Objek `req.session.customer` kini diisi lengkap pada direct login dan OTP. +4. **Masa Berlaku Cookie Sesi:** Ditingkatkan dari 24 jam menjadi 30 hari. +5. **Logout Bersih:** Rute `/customer/logout` (mendukung POST dan GET) mencabut token dari database dan menghapus cookie `customer_remember` serta `customer.sid`. +6. **Fallback Offline SW v6:** Navigasi offline dialihkan ke `/offline.html` dengan tombol "Coba Lagi", bukan ke halaman login. +7. **Penyimpanan Lokal Form:** Menyimpan ID pelanggan terakhir di `localStorage` pada `views/login.ejs`. + +### File disentuh +- `services/customerAuthTokenService.js` (file baru) +- `app-customer.js` +- `routes/customerPortal.js` +- `public/sw.js` +- `views/login.ejs` + +--- + +## [v1.9.2] — 2026-09-27 18:15 — Multi-Vendor WiFi SSID & Password Changer (Huawei / Nokia / FiberHome / ZTE) + +### Perbaikan Ganti SSID & Password WiFi Multi-Vendor +- Mengatasi kegagalan ganti password TR-069 akibat penumpukan multiple parameter paths dalam satu payload SOAP atomik. +- Menghindari pengiriman `PreSharedKey.1.PreSharedKey` (format hex 64 digit) bersamaan dengan `KeyPassphrase`, yang sebelumnya memicu SOAP fault 9007 (Invalid Parameter Value) di modem. +- **Deteksi Vendor Otomatis (`HWTC`, `FiberHome`, `Nokia`, `ZTE`, `ZICG`, `CIOT`, `CDTC`):** + - Huawei / FiberHome / Nokia: memprioritaskan `PreSharedKey.1.KeyPassphrase` (menghindari fault 9007 karena penolakan `KeyPassphrase` top-level). + - ZTE / ZICG / CIOT: memprioritaskan `KeyPassphrase`. +- **SSID 2.4G & 5G:** Mengirim path tunggal yang tepat untuk 2.4 GHz (`WLANConfiguration.1.SSID`) dan 5 GHz (`WLANConfiguration.5.SSID`) jika perangkat dual-band, tanpa menimpa interface guest/sekunder. +- Fallback cerdas berbasis standar TR-098 per vendor. + +### File disentuh +- `services/customerDeviceService.js` + +### Rollback +```bash +cp /opt/billing-rtrw/services/customerDeviceService.js.bak-wififix /opt/billing-rtrw/services/customerDeviceService.js +sudo systemctl restart billing-rtrw +``` + +--- + +## [v1.9.1] — 2026-09-27 16:30 — 5-Tab Floating Dock, PPOB Soon, Profil Tiket & WhatsApp Anti-Ban + +### 1. Floating Dock 5-Tab (Dashboard Pelanggan) +- Menghilangkan tombol tengah (+) Aksi Cepat dan popup modal sheet. +- Menjadikan navbar 5 tombol langsung: Beranda, Router, PPOB (badge gradient "Soon"), Tagihan (ping unread), dan Profil. +- Pusat Bantuan & Pembuatan Tiket Kendala diintegrasikan ke dalam Tab Profil (`tab-profile`) dengan tombol cepat ke form pelaporan dan WhatsApp CS. +- Ditambahkan tombol "Kembali ke Profil" di halaman tiket bantuan (`tab-bantuan`). +- Banner "Layanan PPOB Segera Hadir" pada tab PPOB (`tab-ppob`). + +### 2. Sinkronisasi Upstream & Fix Anti-Ban WhatsApp +- Verifikasi repository upstream `alijayanet/billing-rtrw`: base commit `ffd3c62` sudah mencakup seluruh perbaikan OLT Hioso (gap-fill DDM, Buffer UTF-8 decoding, slow SNMP session) dan timezone midnight hourCycle h23. +- Menambahkan proteksi anti-ban WhatsApp pada alert monitoring (`services/whatsappBot.mjs`): + - Mematuhi toggle `genieacs_monitoring_enabled` (tidak kirim alert jika toggle OFF). + - Jeda acak (8-20 detik) antar penerima saat kirim broadcast alert ke admin/teknisi. +- Menjadwalkan pemeriksaan monitoring harian pada jam 07:00 pagi (`config/genieacs.js`) lewat `node-cron` untuk mencegah spam alert berulang. + +### File disentuh +- `views/dashboard.ejs` +- `services/whatsappBot.mjs` +- `config/genieacs.js` + +--- + +## [v1.9.0] — 2026-09-26 22:55 — Floating Dock Navbar + CDN Pin + SW v5 + +Perubahan UI besar (navbar) sekaligus perbaikan regresi PWA/offline. + +### Navbar: floating glass dock +- Bottom nav lama (waterdrop, 6 tombol horizontal) diganti **floating glass + dock** sesuai referensi `adaptive_glassmorphism_floating_dock.html`. +- Struktur: 4 tombol langsung (**Beranda, Router, Tagihan, Profil**) + 1 tombol + tengah `+` yang membuka **quick-action sheet** berisi + **PPOB, Bantuan, Reconnect, Poll ONU**. + PPOB & Bantuan sengaja dipindah ke sheet agar dock tidak terlalu padat. +- ** keenam tab tetap bisa diakses**, `id="tab-*"` tidak berubah: + `tab-beranda`, `tab-router`, `tab-ppob`, `tab-tagihan`, `tab-bantuan`, + `tab-profile`. +- CSS baru: `.dock-glass`, `.dock-pill` (sliding pill), `.dock-item`, + `.dock-icon`, quick-sheet overlay. `safe-area-inset-bottom` untuk iPhone. +- JS baru: `updatePillPosition()`, `toggleQuickSheet()`, `openQuickSheet()`, + `closeQuickSheet()`, state `currentTab`, `isSheetOpen`. + `switchTab()` sekarang menandai `.dock-item` aktif dan memindahkan pill. +- Support keyboard: angka `1`–`6` pindah tab, `Escape` menutup sheet. +- Ikon memakai **Lucide** (`data-lucide`) + `lucide.createIcons()`. +- CSS `.waterdrop-active` yang tidak terpakai lagi dibuang. + +### Perbaikan regresi offline/PWA +- **Semua CDN dashboard kini di-pin ke versi tetap** (sebelumnya `@latest` / + tanpa versi → bisa berubah atau break tanpa notice): + + ||Aset|URL| + |---|---| + |Lucide|`https://unpkg.com/lucide@1.48.0/dist/umd/lucide.min.js`| + |Chart.js|`https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js`| + |Tailwind|`https://cdn.tailwindcss.com/3.4.17`| + + `https://unpkg.com/lucide@latest` dan `https://cdn.tailwindcss.com` + (tanpa versi) sama-sama **redirect 302** tiap load → di-pin ke URL final. +- **Bug:** service worker hanya cache-first `cdn.jsdelivr.net`, sehingga + Tailwind, FontAwesome, Google Fonts, dan Lucide **tidak tersedia saat + offline** — layout PWA hancur. Sekarang `sw.js` punya array `CDN_HOSTS`: + `cdn.jsdelivr.net`, `unpkg.com`, `cdnjs.cloudflare.com`, + `cdn.tailwindcss.com`, `fonts.googleapis.com`, `fonts.gstatic.com`. +- Cache bust: `CACHE_NAME` / `CDN_CACHE` `customer-pwa-v4` → **`-v5`**, plus + 5 URL baru masuk `PRECACHE_URLS`. + +### File disentuh +- `views/dashboard.ejs` (1481 baris) +- `public/sw.js` (173 baris) + +### Verifikasi +- Render EJS OK, `div` seimbang `160/160`. +- Live `/customer/dashboard` HTTP 200, `div` seimbang `184/184`. +- Semua CDN membalas 200 tanpa redirect. +- `systemctl is-active billing-rtrw` = `active`, journal tanpa error. + +### Rollback +```bash +cd /opt/billing-rtrw +cp views/dashboard.ejs.bak-twpin-225510 views/dashboard.ejs +cp public/sw.js.bak-twpin-225510 public/sw.js +sudo systemctl restart billing-rtrw +``` + +### Catatan +- Tampilan visual (sliding pill, animasi sheet, posisi icon) **belum dicek di + browser sungguhan** — verifikasi di atas semuanya server-side. + +--- + +## [v1.8.2] — 2026-09-26 22:30 — Fix flash message toast + +- `notif` dari server sebelumnya tidak tampil. Kini di-render sebagai variabel + `FLASH` (`{ text, type }`) lalu dimunculkan lewat `#toast-container`. +- Pemetaan tipe: `danger → error`, `warning → info`, selain itu `info`. +- Render diproteksi dari injeksi `` via `.replace(/ Catatan: **tidak ada** route `/customer/poll-onu`. Tombol "Poll ONU" +> memanggil `POST /customer/api/device/refresh`. + +--- + +## TODO / tindak lanjut + +Detail lengkap ada di `/home/oxpoe/Documents/CATATAN-OPS-SKYFIBER.txt` +section 11 (mode `600`, jangan dibagikan). + +### Keamanan — perlu tindakan +- [ ] Rotasi `telegram_bot_token` (via `@BotFather` → `/revoke`; token **baru** + jangan dikirim ke chat mana pun). +- [ ] Rotasi `admin_password`. +- [ ] Rotasi `admin_api_key`. +- [ ] Amankan backup `backups/settings_20260926_211113.json` (masih memuat + token lama) atau hapus setelah rotasi. +- [ ] Inisialisasi Git (`git init` + commit awal) supaya changelog berikutnya + punya riwayat & diff. + +### Fungsional +- [ ] **Password WiFi pelanggan uji masih nilai sementara** dan harus + dikembalikan ke nilai asli (nilai uji tercatat di notes section 11). +- [ ] 3 perangkat belum punya CR credentials, akan provisioning otomatis saat + online: + - `001141-CL601-ZTEGCBBFB774` + - `B4E46B-SIGMA Z99LX XPON-ZICG27766207` + - `B4E46B-SIGMA Z99LX XPON-ZICG27766F6A` +- [ ] Alur pembayaran belum diuji end-to-end (butuh invoice uji). +- [ ] Cek visual floating dock di browser/HP sungguhan. +- [ ] Test PWA mode offline (SW v5) — belum pernah diuji. +- [ ] Keputusan: apakah FUP enforcement mengikuti isolate-day. +- [ ] Parity peta admin desktop vs mobile. +- [ ] Pemetaan kecepatan WiFi 2.4GHz vs 5GHz. + +--- + +## Cara rollback umum + +```bash +cd /opt/billing-rtrw +sudo systemctl stop billing-rtrw +# salin backup yang dikehendali ke file aslinya +sudo systemctl start billing-rtrw +sudo journalctl -u billing-rtrw -n 50 --no-pager +``` + +Backup tersedia (format `.bak-