Cara Membuat QRIS Statis Jadi Ber-nominal dan Mencetak Struk ke Printer Thermal Bluetooth dari Aplikasi Kasir JavaScript (Capacitor)
Cara Membuat QRIS Statis Jadi Ber-nominal dan Mencetak Struk ke Printer Thermal Bluetooth dari Aplikasi Kasir JavaScript (Capacitor)
Banyak warung sudah punya stiker QRIS statis dari bank atau dompet digital. Masalahnya, pembeli harus mengetik nominal sendiri, dan salah ketik Rp2.500 jadi Rp25.000 bukan hal langka. Di sisi lain, aplikasi kasir berbasis HP sering mentok di urusan printer: struk berantakan, huruf acak, atau printer tidak terdeteksi di iPhone.
Tulisan ini membahas teknik inti yang dipakai Papikasir Warung, aplikasi kasir Android & iOS yang dibangun dengan Capacitor dan JavaScript biasa: mengubah QRIS statis menjadi QR ber-nominal, menyusun perintah ESC/POS, mengirimnya lewat Bluetooth LE, dan menjaga data tetap aman dengan transaksi IndexedDB serta file backup ber-checksum.
1. Membedah isi QRIS: format TLV
Isi QRIS adalah teks panjang yang diawali 000201. Formatnya EMVCo TLV: dua digit tag, dua digit panjang, lalu nilai. Contoh 5802ID berarti tag 58 (negara), panjang 2, nilai ID. Beberapa tag penting:
01:11untuk QR statis,12untuk QR dinamis54: nominal transaksi59dan60: nama merchant dan kota63: CRC16, selalu paling akhir
Parser-nya cukup satu perulangan:
export function parseTlv(s) {
const out = [];
let i = 0;
while (i < s.length) {
const tag = s.slice(i, i + 2), len = parseInt(s.slice(i + 2, i + 4), 10);
if (!/^\d{2}$/.test(tag) || !Number.isFinite(len) || i + 4 + len > s.length) throw new Error('Format QRIS rusak di posisi ' + i);
out.push({ tag, nilai: s.slice(i + 4, i + 4 + len) });
i += 4 + len;
}
return out;
}
2. Menyisipkan nominal dan menghitung ulang CRC16
QRIS memakai CRC-16/CCITT-FALSE (polinom 0x1021, nilai awal 0xFFFF). CRC dihitung dari seluruh teks sampai dan termasuk 6304. Begitu satu karakter berubah, CRC wajib dihitung ulang, kalau tidak aplikasi bank akan menolak QR-nya.
export function crc16(str) {
let crc = 0xffff;
for (let i = 0; i < str.length; i++) {
crc ^= str.charCodeAt(i) << 8;
for (let j = 0; j < 8; j++) crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
}
return crc.toString(16).toUpperCase().padStart(4, '0');
}
Langkah menyisipkan nominal: buang tag 63 dan tag 54 lama, ubah tag 01 menjadi 12, tambahkan tag 54, urutkan tag, lalu tempel 6304 dan CRC baru.
export function qrisDenganNominal(payload, nominal) {
const tags = parseTlv(payload).filter((t) => t.tag !== '63' && t.tag !== '54');
tags.find((t) => t.tag === '01').nilai = '12';
tags.push({ tag: '54', nilai: String(Math.round(nominal)) });
tags.sort((a, b) => +a.tag - +b.tag);
const isi = tags.map((t) => t.tag + String(t.nilai.length).padStart(2, '0') + t.nilai).join('') + '6304';
return isi + crc16(isi);
}
Cara cepat memastikan CRC benar: crc16('123456789') harus menghasilkan 29B1, nilai referensi standar CCITT-FALSE. Uji ini ada di _test/test.mjs.
Satu catatan jujur: QRIS statis tidak memberi notifikasi otomatis ke aplikasi kasir. Karena itu tombol di Papikasir bertuliskan "Dana sudah masuk, selesaikan", supaya kasir tetap mengecek notifikasi bank sebelum menutup transaksi.
3. Membaca isi QRIS dari foto stiker
Pemilik toko tidak perlu menyalin teks QRIS. Cukup foto stikernya, lalu pustaka jsqr membaca piksel gambar. Foto HP biasanya besar dan QR-nya kecil, jadi gambar dicoba di beberapa ukuran:
for (const maks of [1000, 1600, 700]) {
const s = Math.min(1, maks / Math.max(img.width, img.height));
const c = document.createElement('canvas');
c.width = Math.round(img.width * s); c.height = Math.round(img.height * s);
const ctx = c.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0, c.width, c.height);
const d = ctx.getImageData(0, 0, c.width, c.height);
const r = jsQR(d.data, c.width, c.height, { inversionAttempts: 'attemptBoth' });
if (r?.data) return r.data;
}
Setelah terbaca, teks divalidasi (harus diawali 000201 dan CRC cocok) sebelum disimpan.
4. Menyusun struk dengan perintah ESC/POS
Printer thermal murah memahami perintah ESC/POS: deretan byte seperti 1B 40 (reset), 1B 61 01 (rata tengah), atau 1D 56 42 00 (potong kertas). Papikasir memisahkan isi struk dari cara mencetaknya. Isi struk berupa daftar baris:
[{ t: 'teks', s: 'Warung Bu Ratna', align: 'tengah', bold: true },
{ t: 'kk', kiri: 'Indomie Goreng x10', kanan: '33.000' },
{ t: 'garis' },
{ t: 'qr', data: qrisDenganNominal(payload, 33000) }]
Daftar yang sama dirender ke byte printer, ke teks WhatsApp, atau ke gambar PNG. Kunci kerapian ada di fungsi kiri-kanan: kertas 58 mm memuat 32 karakter, 80 mm memuat 48, dan nama barang yang panjang dibungkus ke baris berikutnya.
export function kiriKanan(kiri, kanan, lebar) {
const ruang = Math.max(1, lebar - kanan.length - 1);
const ki = bungkus(kiri, ruang);
const akhir = ki.pop();
return [...ki, akhir + ' '.repeat(Math.max(1, lebar - akhir.length - kanan.length)) + kanan];
}
QR dicetak sebagai gambar raster (GS v 0) karena lebih banyak printer murah yang mendukungnya dibanding perintah QR bawaan. Gambar dipecah per 128 baris agar buffer printer tidak penuh.
5. Mengirim ke printer lewat Bluetooth LE (Android dan iPhone)
iPhone tidak bisa memakai Bluetooth klasik (SPP) tanpa sertifikasi MFi, jadi jalur yang berlaku di dua platform adalah Bluetooth LE. Plugin @capacitor-community/bluetooth-le menyediakan dialog pilih perangkat. Merek printer berbeda memakai UUID layanan berbeda, jadi karakteristik yang bisa ditulisi dicari otomatis:
for (const s of await BleClient.getServices(deviceId))
for (const c of s.characteristics)
if (c.properties.write || c.properties.writeWithoutResponse)
return { service: s.uuid, characteristic: c.uuid, tanpaRespons: c.properties.writeWithoutResponse };
Data struk dikirim dalam potongan kecil (default 100 byte, bisa diatur), dengan jeda singkat bila memakai writeWithoutResponse. Inilah penyebab paling umum struk terpotong atau huruf acak: data dikirim terlalu besar sekaligus.
for (let i = 0; i < bytes.length; i += ukuran) {
const potong = bytes.subarray(i, i + ukuran);
const dv = new DataView(potong.buffer, potong.byteOffset, potong.byteLength);
if (cfg.tanpaRespons) { await BleClient.writeWithoutResponse(id, svc, chr, dv); await tunggu(25); }
else await BleClient.write(id, svc, chr, dv);
}
Untuk printer Bluetooth klasik di Android, byte yang sama dikirim ke aplikasi RawBT lewat tautan rawbt:base64,....
6. Data aman: transaksi IndexedDB dan backup ber-checksum
Satu penjualan mengubah banyak hal: menyimpan transaksi, mengurangi stok, mencatat mutasi, dan menambah kasbon. Semua dibungkus satu transaksi IndexedDB. Jika ada yang gagal (misal stok kurang), seluruhnya dibatalkan:
return this.db.tulis(['produk', 'transaksi', 'mutasi', 'pelanggan', 'kasbon', 'kv'], async (t) => {
const p = await t.get('produk', it.produkId);
if (p.stok < qty) gagal(`Stok ${p.nama} tinggal ${p.stok}`);
p.stok -= qty;
await t.put('produk', p);
await t.put('transaksi', trx);
});
Untuk pindah HP, seluruh isi database dibungkus menjadi JSON beserta checksum SHA-256 dari bagian data. File yang terpotong saat dikirim lewat WhatsApp langsung ketahuan saat dipulihkan:
if ((await sha256(JSON.stringify(obj.data))) !== obj.checksum)
throw new Error('Checksum tidak cocok: file rusak atau terpotong saat dikirim.');
File itu lalu dibagikan lewat menu Share bawaan HP (@capacitor/share), sehingga pemilik bisa menyimpannya ke Google Drive, WhatsApp, atau email.
Ringkasan
- QRIS statis bisa diberi nominal dengan mengubah tag
01menjadi12, menambah tag54, lalu menghitung ulang CRC16/CCITT-FALSE. - Foto stiker QRIS dibaca dengan
jsqrlalu divalidasi CRC-nya sebelum disimpan. - Isi struk disusun sebagai daftar baris, lalu dirender ke ESC/POS, teks WhatsApp, atau gambar.
- Bluetooth LE adalah jalur cetak yang berlaku di Android dan iPhone; kirim data dalam potongan kecil.
- Penjualan, stok, dan kasbon ditulis dalam satu transaksi IndexedDB, dan backup diberi checksum agar aman saat pindah HP.
Download source code lengkapnya
Source code lengkap Papikasir Warung (multi kasir, shift, harga grosir, kasbon, laporan, backup pindah HP, proyek Android & iOS siap build) tersedia di Papicode seharga Rp50.000:
Source code lengkapnya gratis
Lihat semua yang dibahas di artikel ini langsung berjalan.
Tutorial lainnya
Cara Membuat Sistem Tiket Travel dengan Pilih Kursi di Google Apps Script: Layout Armada, Anti Kursi Dobel, dan Kursi Kedaluwarsa
20 Oktober 2026
Cara Membuat Website Donasi Transparan dengan Google Apps Script: Kode Unik Transfer dan Laporan Penyaluran
20 Oktober 2026