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
157 lines
5.1 KiB
JavaScript
157 lines
5.1 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const jimpMod = require('jimp');
|
|
const Jimp = jimpMod.Jimp || jimpMod;
|
|
const jsQR = require('jsqr');
|
|
const QRCode = require('qrcode');
|
|
const { BinaryBitmap, HybridBinarizer, RGBLuminanceSource, MultiFormatReader, BarcodeFormat, DecodeHintType } = require('@zxing/library');
|
|
|
|
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 decodeQrisPayloadFromBuffer(buf) {
|
|
if (!buf || !buf.length) return '';
|
|
|
|
// 1. Try jsQR (Fast & reliable for screenshots)
|
|
try {
|
|
const img = await Jimp.read(buf);
|
|
const w = img.bitmap.width;
|
|
const h = img.bitmap.height;
|
|
const data = new Uint8ClampedArray(img.bitmap.data);
|
|
const code = jsQR(data, w, h);
|
|
if (code && code.data) {
|
|
const payload = normalizeQrisPayload(code.data);
|
|
if (payload && payload.startsWith('000201')) return payload;
|
|
}
|
|
} catch (e) {}
|
|
|
|
// 2. Fallback to @zxing/library
|
|
try {
|
|
const img = await Jimp.read(buf);
|
|
const rgba = new Uint8ClampedArray(img.bitmap.data.buffer, img.bitmap.data.byteOffset, img.bitmap.data.byteLength);
|
|
const source = new RGBLuminanceSource(rgba, img.bitmap.width, img.bitmap.height);
|
|
const bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
|
const reader = new MultiFormatReader();
|
|
const hints = new Map();
|
|
hints.set(DecodeHintType.POSSIBLE_FORMATS, [BarcodeFormat.QR_CODE]);
|
|
hints.set(DecodeHintType.TRY_HARDER, true);
|
|
reader.setHints(hints);
|
|
const decoded = reader.decode(bitmap, hints);
|
|
const text = typeof decoded?.getText === 'function' ? decoded.getText() : String(decoded?.text || '');
|
|
const payload = normalizeQrisPayload(text);
|
|
if (payload && payload.startsWith('000201')) return payload;
|
|
} catch (e) {}
|
|
|
|
return '';
|
|
}
|
|
|
|
async function buildDynamicQrisJpgBuffer(staticPayload, amount) {
|
|
const dynamic = convertStaticQrisToDynamic(staticPayload, amount);
|
|
const png = await QRCode.toBuffer(dynamic, { errorCorrectionLevel: 'M', margin: 1, width: 420, type: 'png' });
|
|
const img = await Jimp.read(png);
|
|
return await img.getBuffer('image/jpeg');
|
|
}
|
|
|
|
module.exports = {
|
|
normalizeQrisPayload,
|
|
crc16CcittFalse,
|
|
parseEmvTlvString,
|
|
buildEmvTlvString,
|
|
convertStaticQrisToDynamic,
|
|
decodeQrisPayloadFromBuffer,
|
|
buildDynamicQrisJpgBuffer
|
|
};
|