fix(qr-custom): center QR, timer berhenti, refund saat lunas, regenerate expired
All checks were successful
CI / test (push) Successful in 1m44s

- link publik: QR center, nominal/QR/timer disembunyikan saat expired
- reject/refund untuk paid
- regenerate QR expired (kode unik baru) di detail & tabel
- versi 0.6.4
This commit is contained in:
oxpoe
2026-11-06 05:05:58 +07:00
parent b72dbf3e55
commit 8fcb50e16a
8 changed files with 71 additions and 15 deletions

View File

@@ -2,6 +2,15 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/). Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.6.4] — 2026-11-06 — "qr-custom-ux"
### Diperbaiki
- QR pada link publik kini **center**.
- Timer hanya tampil saat QR masih aktif; berhenti/disembunyikan ketika LUNAS, ditolak, atau kedaluwarsa.
- **Reject / Refund** kini tersedia juga untuk pembayaran yang sudah LUNAS.
- QR kedaluwarsa: **nominal dan gambar QR disembunyikan** di link publik maupun detail dashboard.
- Tombol **Regenerate** untuk membuat QR baru + kode unik baru dari QR yang kedaluwarsa (juga tersedia di tabel histori).
## [0.6.3] — 2026-11-06 — "webhook-secret-auto" ## [0.6.3] — 2026-11-06 — "webhook-secret-auto"
### Diperbaiki ### Diperbaiki

View File

@@ -11,7 +11,7 @@
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC"> <img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.6.3_webhook--secret-ADFF2F" alt="Versi 0.6.3 (webhook-secret-auto)"> <img src="https://img.shields.io/badge/versi-0.6.4_qr--ux-ADFF2F" alt="Versi 0.6.4 (qr-custom-ux)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20"> <img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker"> <img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a> <a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>

View File

@@ -2116,7 +2116,11 @@ app.get('/custom-qr/:token', (req, res) => {
const company = settings.company_header || 'OPEN-ISP'; const company = settings.company_header || 'OPEN-ISP';
const statusLabels = { pending: 'Menunggu Pembayaran', paid: 'LUNAS', expired: 'Kedaluwarsa', warning: 'Perlu Verifikasi Admin', rejected: 'Ditolak / Refund' }; const statusLabels = { pending: 'Menunggu Pembayaran', paid: 'LUNAS', expired: 'Kedaluwarsa', warning: 'Perlu Verifikasi Admin', rejected: 'Ditolak / Refund' };
const status = String(row.status || 'pending'); const status = String(row.status || 'pending');
const showQr = status !== 'paid' && status !== 'rejected'; const expiresMs = row.expires_at ? new Date(row.expires_at).getTime() : 0;
const expiredNow = status === 'expired' || (status === 'pending' && expiresMs && expiresMs < Date.now());
const showAmount = !expiredNow;
const showQr = !expiredNow && status !== 'paid' && status !== 'rejected';
const showTimer = status === 'pending' && !expiredNow;
const amountText = 'Rp ' + Number(row.amount_unique || 0).toLocaleString('id-ID'); const amountText = 'Rp ' + Number(row.amount_unique || 0).toLocaleString('id-ID');
const baseAmountText = 'Rp ' + Number(row.amount || 0).toLocaleString('id-ID'); const baseAmountText = 'Rp ' + Number(row.amount || 0).toLocaleString('id-ID');
const html = `<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> const html = `<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -2126,7 +2130,7 @@ body{margin:0;background:#0b1220;color:#e5e7eb;font-family:system-ui,Segoe UI,Ar
.card{max-width:460px;width:100%;background:#0f172a;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:22px;text-align:center} .card{max-width:460px;width:100%;background:#0f172a;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:22px;text-align:center}
h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px} h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px}
.amt{font-size:26px;font-weight:800;color:#ADFF2F;margin:12px 0 2px}.base{font-size:12px;color:#64748b} .amt{font-size:26px;font-weight:800;color:#ADFF2F;margin:12px 0 2px}.base{font-size:12px;color:#64748b}
.qr{background:#fff;padding:12px;border-radius:14px;display:inline-block;margin:14px 0}.qr img{display:block;width:240px;height:240px} .qr{background:#fff;padding:12px;border-radius:14px;display:block;width:max-content;margin:14px auto}.qr img{display:block;width:240px;height:240px}
.badge{display:inline-block;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:700;margin-top:6px} .badge{display:inline-block;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:700;margin-top:6px}
.pending{background:rgba(245,158,11,.15);color:#fbbf24}.paid{background:rgba(34,197,94,.15);color:#4ade80} .pending{background:rgba(245,158,11,.15);color:#fbbf24}.paid{background:rgba(34,197,94,.15);color:#4ade80}
.expired{background:rgba(239,68,68,.15);color:#f87171}.warning{background:rgba(249,115,22,.15);color:#fb923c}.rejected{background:rgba(148,163,184,.15);color:#cbd5e1} .expired{background:rgba(239,68,68,.15);color:#f87171}.warning{background:rgba(249,115,22,.15);color:#fb923c}.rejected{background:rgba(148,163,184,.15);color:#cbd5e1}
@@ -2134,20 +2138,22 @@ h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px}
</style></head><body><div class="card"> </style></head><body><div class="card">
<div style="font-size:12px;letter-spacing:1px;color:#64748b;text-transform:uppercase">${cqEscape(row.category || 'Pembayaran')}</div> <div style="font-size:12px;letter-spacing:1px;color:#64748b;text-transform:uppercase">${cqEscape(row.category || 'Pembayaran')}</div>
<h1>${cqEscape(row.title)}</h1> <h1>${cqEscape(row.title)}</h1>
<div class="amt">${amountText}</div> ${showAmount
<div class="base">Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}</div> ? `<div class="amt">${amountText}</div><div class="base">Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}</div>`
<div class="badge ${cqEscape(status)}" id="statusBadge">${cqEscape(statusLabels[status] || status)}</div> : `<div class="amt" style="font-size:18px;color:#f87171">QR Kedaluwarsa</div><div class="base">Nominal dan QR disembunyikan. Minta admin membuat QR baru.</div>`}
<div class="badge ${cqEscape(expiredNow ? 'expired' : status)}" id="statusBadge">${cqEscape(expiredNow ? 'Kedaluwarsa' : (statusLabels[status] || status))}</div>
${showQr ? `<div class="qr"><img src="/custom-qr/${cqEscape(row.share_token)}/qr.png?amount=${Number(row.amount_unique || 0)}" alt="QRIS"></div>` : ''} ${showQr ? `<div class="qr"><img src="/custom-qr/${cqEscape(row.share_token)}/qr.png?amount=${Number(row.amount_unique || 0)}" alt="QRIS"></div>` : ''}
<div class="timer" id="timer" data-expires="${cqEscape(row.expires_at || '')}"></div> ${showTimer ? `<div class="timer" id="timer" data-expires="${cqEscape(row.expires_at || '')}"></div>` : ''}
${row.note ? `<div class="note">${cqEscape(row.note)}</div>` : ''} ${row.note ? `<div class="note">${cqEscape(row.note)}</div>` : ''}
<div class="note">Bayar dengan nominal <b>persis</b> seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.</div> ${showAmount ? `<div class="note">Bayar dengan nominal <b>persis</b> seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.</div>` : ''}
<div class="note">${cqEscape(company)}</div> <div class="note">${cqEscape(company)}</div>
</div> </div>
<script> <script>
(function(){ (function(){
var el=document.getElementById('timer'), exp=el&&el.getAttribute('data-expires'); var el=document.getElementById('timer'), exp=el&&el.getAttribute('data-expires');
function tick(){ function tick(){
if(!el||!exp) return; if(!el) return;
if(!exp) return;
var d=new Date(exp).getTime()-Date.now(); var d=new Date(exp).getTime()-Date.now();
if(d<=0){ el.textContent='Batas 20 menit telah lewat — pembayaran tetap dicek admin.'; return; } if(d<=0){ el.textContent='Batas 20 menit telah lewat — pembayaran tetap dicek admin.'; return; }
var m=Math.floor(d/60000), s=Math.floor((d%60000)/1000); var m=Math.floor(d/60000), s=Math.floor((d%60000)/1000);

View File

@@ -1,6 +1,6 @@
{ {
"name": "open-isp", "name": "open-isp",
"version": "0.6.3", "version": "0.6.4",
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)", "description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js", "main": "app-customer.js",
"scripts": { "scripts": {

View File

@@ -4062,6 +4062,17 @@ router.post('/custom-qr/:id/manual-paid', requireAdminSession, requireSidebarMen
res.redirect('/admin/custom-qr?id=' + Number(req.params.id)); res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
}); });
router.post('/custom-qr/:id/regenerate', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
const row = require('../services/customQrService').regenerate(req.params.id, resolvePaidByName(req, 'Admin'));
req.session._msg = { type: 'success', text: `QR baru dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} (kode ${row.unique_code}).` };
return res.redirect('/admin/custom-qr?id=' + row.id);
} catch (e) {
req.session._msg = { type: 'error', text: e.message };
return res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
}
});
router.post('/custom-qr/:id/accept', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => { router.post('/custom-qr/:id/accept', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try { try {
require('../services/customQrService').acceptWarning(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note); require('../services/customQrService').acceptWarning(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);

View File

@@ -118,6 +118,20 @@ function acceptWarning(id, actor, note) {
return getById(id); return getById(id);
} }
function regenerate(id, actor) {
const old = getById(id);
if (!old) throw new Error('Data tidak ditemukan.');
if (old.status === 'pending' && !isExpired(old)) throw new Error('QR masih aktif. Tunggu kedaluwarsa atau gunakan QR yang ada.');
const row = create({
title: old.title,
category: old.category,
amount: old.amount,
note: old.note,
createdBy: actor
});
return row;
}
function reject(id, actor, note) { function reject(id, actor, note) {
const row = getById(id); const row = getById(id);
if (!row) throw new Error('Data tidak ditemukan.'); if (!row) throw new Error('Data tidak ditemukan.');
@@ -181,6 +195,7 @@ module.exports = {
candidatesByAmount, candidatesByAmount,
markManualPaid, markManualPaid,
acceptWarning, acceptWarning,
regenerate,
reject, reject,
markPaidFromWebhook, markPaidFromWebhook,
markWarningFromWebhook, markWarningFromWebhook,

View File

@@ -1 +1 @@
0.6.3 0.6.4

View File

@@ -54,12 +54,15 @@
</div> </div>
<% if (detail) { %> <% if (detail) { %>
<% const cqExpired = detail.status === 'expired' || (detail.status === 'pending' && detail.expires_at && new Date(detail.expires_at).getTime() < Date.now()); %>
<div class="card mb16"> <div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></h6></div> <div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %><% if (!cqExpired) { %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %><% } %></h6></div>
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px"> <div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px">
<div style="text-align:center"> <div style="text-align:center">
<% if (detail.status === 'paid' || detail.status === 'rejected') { %> <% if (detail.status === 'paid' || detail.status === 'rejected' || cqExpired) { %>
<div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">QR tidak aktif — status: <b><%= detail.status %></b></div> <div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">
<% if (cqExpired) { %>QR kedaluwarsa — nominal &amp; QR disembunyikan.<% } else { %>QR tidak aktif — status: <b><%= detail.status %></b><% } %>
</div>
<% } else { %> <% } else { %>
<img src="/custom-qr/<%= detail.share_token %>/qr.png" alt="QRIS" style="max-width:240px;background:#fff;padding:10px;border-radius:12px"> <img src="/custom-qr/<%= detail.share_token %>/qr.png" alt="QRIS" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
<div id="cqTimer" data-expires="<%= detail.expires_at || '' %>" style="margin-top:8px;font-size:13px;color:#fbbf24"></div> <div id="cqTimer" data-expires="<%= detail.expires_at || '' %>" style="margin-top:8px;font-size:13px;color:#fbbf24"></div>
@@ -67,9 +70,11 @@
</div> </div>
<div style="font-size:13px;line-height:1.8"> <div style="font-size:13px;line-height:1.8">
<div><b>Status:</b> <%= detail.status %></div> <div><b>Status:</b> <%= detail.status %></div>
<% if (!cqExpired) { %>
<div><b>Nominal dasar:</b> Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %></div> <div><b>Nominal dasar:</b> Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %></div>
<div><b>Kode unik:</b> <%= detail.unique_code %></div> <div><b>Kode unik:</b> <%= detail.unique_code %></div>
<div><b>Nominal bayar:</b> Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></div> <div><b>Nominal bayar:</b> Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></div>
<% } %>
<div><b>Kategori:</b> <%= detail.category || '-' %></div> <div><b>Kategori:</b> <%= detail.category || '-' %></div>
<div><b>Dibuat:</b> <%= detail.created_at %></div> <div><b>Dibuat:</b> <%= detail.created_at %></div>
<div><b>Kedaluwarsa:</b> <%= detail.expires_at || '-' %></div> <div><b>Kedaluwarsa:</b> <%= detail.expires_at || '-' %></div>
@@ -93,12 +98,17 @@
<button class="btn btn-p btn-sm"><i class="bi bi-check2"></i> Selesaikan</button> <button class="btn btn-p btn-sm"><i class="bi bi-check2"></i> Selesaikan</button>
</form> </form>
<% } %> <% } %>
<% if (detail.status !== 'paid' && detail.status !== 'rejected') { %> <% if (detail.status !== 'rejected') { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/reject" onsubmit="return confirm('Tolak / refund pembayaran ini?')"> <form method="POST" action="/admin/custom-qr/<%= detail.id %>/reject" onsubmit="return confirm('Tolak / refund pembayaran ini?')">
<input type="hidden" name="note" value="Refund dari panel admin"> <input type="hidden" name="note" value="Refund dari panel admin">
<button class="btn btn-g btn-sm"><i class="bi bi-x-circle"></i> Reject / Refund</button> <button class="btn btn-g btn-sm"><i class="bi bi-x-circle"></i> Reject / Refund</button>
</form> </form>
<% } %> <% } %>
<% if (cqExpired) { %>
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/regenerate" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
<button class="btn btn-p btn-sm"><i class="bi bi-arrow-repeat"></i> Regenerate QR</button>
</form>
<% } %>
</div> </div>
</div> </div>
</div> </div>
@@ -131,6 +141,11 @@
<button class="btn btn-g btn-sm" title="Manual pay"><i class="bi bi-check2-circle"></i></button> <button class="btn btn-g btn-sm" title="Manual pay"><i class="bi bi-check2-circle"></i></button>
</form> </form>
<% } %> <% } %>
<% if (r.status === 'expired') { %>
<form method="POST" action="/admin/custom-qr/<%= r.id %>/regenerate" style="display:inline" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
<button class="btn btn-g btn-sm" title="Regenerate"><i class="bi bi-arrow-repeat"></i></button>
</form>
<% } %>
</td> </td>
</tr> </tr>
<% }); %> <% }); %>