/* * NotivGo untuk Google Form + Sheets — versi 0.1.0 * Panduan: https://notivgo.com/google-form * Lisensi MIT — Copyright (c) 2026 NotivGo * 1. Buka Spreadsheet, pilih Extensions → Apps Script. * 2. Tempel seluruh berkas ini sebagai Kode.gs, lalu simpan. * 3. Setelah alur pemasangan tersedia, pilih NotivGo → Pasang. */ var NG_VERSI = '0.1.0'; var NG_API = 'https://portal.notivgo.com'; var NG_PROP_KUNCI = 'NG_KUNCI_API'; var NG_MAKS_NOMINAL = 10000000; var NG_KUNCI_KEDALUWARSA_DETIK = 86400; var NG_PANTAU_KEDALUWARSA_DETIK = 86400; var NG_JEDA_KEDALUWARSA_DETIK = 900; var NG_STATUS = { MEMBUAT: 'Membuat QR', MENUNGGU: 'Menunggu pembayaran', LUNAS: 'Lunas', SELISIH: 'Selisih', TINJAU: 'Perlu dicek', KEDALUWARSA: 'Kedaluwarsa', BATAL: 'Batal', GAGAL: 'Gagal membuat QR', DITOLAK: 'Tidak diproses' }; function ngSekarang_() { return Math.floor(Date.now() / 1000); } function ngAcak_(min, maks) { return min + Math.floor(Math.random() * (maks - min + 1)); } /** @param {*} nilai */ function ngBacaHarga_(nilai) { if (typeof nilai === 'string') { nilai = nilai.trim().replace(/^rp\.?\s*/i, ''); if (!/^[1-9][0-9]*$/.test(nilai) && !/^[1-9][0-9]{0,2}(\.[0-9]{3})+$/.test(nilai)) { return null; } nilai = Number(nilai.replace(/\./g, '')); } return typeof nilai === 'number' && Number.isInteger(nilai) && nilai >= 1 && nilai <= NG_MAKS_NOMINAL ? nilai : null; } /** @param {*} nilai */ function ngBacaJumlah_(nilai, maks) { if (nilai === null || nilai === undefined) { return 0; } if (typeof nilai === 'string') { nilai = nilai.trim(); if (nilai === '') { return 0; } if (!/^[0-9]{1,4}$/.test(nilai)) { return null; } nilai = Number(nilai); } return typeof nilai === 'number' && Number.isInteger(nilai) && nilai >= 0 && nilai <= maks ? nilai : null; } function ngRupiah_(n) { if (!Number.isSafeInteger(n) || n < 0) { throw new Error('Nominal rupiah tidak sah.'); } return 'Rp' + String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '.'); } function ngHitungRincian_(katalog, jumlah, opsi) { if (!Array.isArray(katalog) || katalog.length === 0) { return {ok: false, alasan: 'katalog_kosong'}; } var baris = []; var subtotal = 0; for (var produk of katalog) { var n = ngBacaJumlah_(Object.prototype.hasOwnProperty.call(jumlah, produk.id) ? jumlah[produk.id] : undefined, opsi.maksJumlah); if (n === null) { return {ok: false, alasan: 'jumlah_tidak_sah'}; } var harga = ngBacaHarga_(produk.harga); if (harga === null) { return {ok: false, alasan: 'katalog_kosong'}; } if (n > 0) { var total = harga * n; subtotal += total; baris.push({nama: produk.nama, harga: harga, jumlah: n, subtotal: total}); } } if (!Number.isSafeInteger(subtotal) || subtotal > opsi.maksNominal) { return {ok: false, alasan: 'melebihi_batas'}; } if (!baris.length) { return {ok: false, alasan: 'kosong'}; } return {ok: true, baris: baris, subtotal: subtotal}; } function ngPilihKodeUnik_(dasar, maks, terpakai, acak) { dasar = Math.trunc(Number(dasar)); maks = Math.trunc(Number(maks)); terpakai = terpakai.map(function(n) { return Math.trunc(Number(n)); }); if (maks <= 0) { return {kode: 0, unik: !terpakai.includes(dasar)}; } var kode = 0; for (var i = 0; i < 20; i++) { kode = Math.trunc(acak(1, maks)); if (!terpakai.includes(dasar + kode)) { return {kode: kode, unik: true}; } } for (var k = 1; k <= maks; k++) { if (!terpakai.includes(dasar + k)) { return {kode: k, unik: true}; } } return {kode: kode, unik: false}; } function ngRincianTagihan_(subtotal, maksKode, terpakai, acak, maksNominal) { if (!Number.isSafeInteger(subtotal) || subtotal < 1) { throw new Error('Subtotal tidak sah.'); } if (maksKode > 0) { var ruang = maksNominal - subtotal; if (ruang < 1) { return {ok: false, alasan: 'melebihi_batas'}; } maksKode = Math.min(maksKode, ruang); } var pilih = ngPilihKodeUnik_(subtotal, maksKode, terpakai, acak); if (!pilih.unik) { return {ok: false, alasan: 'nominal_penuh'}; } var nominal = subtotal + pilih.kode; if (!Number.isSafeInteger(nominal) || nominal > maksNominal) { return {ok: false, alasan: 'melebihi_batas'}; } return {ok: true, dasar: subtotal, kode: pilih.kode, nominal: nominal}; } /** Angka teks mengikuti aturan numerik PHP, bukan pemaksaan boolean/null. @param {*} nilai */ function ngNumerik_(nilai) { return (typeof nilai === 'number' && Number.isFinite(nilai)) || (typeof nilai === 'string' && /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/.test(nilai.trim()) && Number.isFinite(Number(nilai))); } function ngStatusDariJawaban_(jawaban, nominal) { if (!jawaban || typeof jawaban !== 'object' || Array.isArray(jawaban)) { return 'tidak_dikenal'; } if (jawaban.status === 'paid') { return ngNumerik_(jawaban.amount) && Number(jawaban.amount) === Number(nominal) ? 'lunas' : 'selisih'; } if (jawaban.status === 'open') { return jawaban.needs_review === true ? 'tinjau' : 'menunggu'; } if (jawaban.status === 'expired') { return 'kedaluwarsa'; } if (jawaban.status === 'canceled') { return 'batal'; } return 'tidak_dikenal'; } function ngNominalTerkunci_(status, batas, sekarang) { return [NG_STATUS.MEMBUAT, NG_STATUS.MENUNGGU, NG_STATUS.TINJAU].includes(status) || ([NG_STATUS.KEDALUWARSA, NG_STATUS.BATAL].includes(status) && Number(batas) > 0 && sekarang < Number(batas) + NG_KUNCI_KEDALUWARSA_DETIK); } function ngPerluDicek_(status, batas, dicek, sekarang) { return [NG_STATUS.MENUNGGU, NG_STATUS.TINJAU].includes(status) || (status === NG_STATUS.KEDALUWARSA && sekarang <= Number(batas) + NG_PANTAU_KEDALUWARSA_DETIK && sekarang - dicek >= NG_JEDA_KEDALUWARSA_DETIK); } function ngBuatRef_(tanggal, acakHex) { if (typeof tanggal !== 'string' || !/^[0-9]{6}$/.test(tanggal) || typeof acakHex !== 'string' || !/^[0-9a-fA-F]{8}$/.test(acakHex)) { throw new Error('Bahan nomor pesanan tidak sah.'); } return 'GF-' + tanggal + '-' + acakHex.toUpperCase(); } /** @param {string} kode @param {boolean} [ulang] */ function ngAlasanAwam_(kode, ulang) { var pesan = { unauthorized: 'Kunci API NotivGo ditolak. Jalankan menu NotivGo → Pasang, lalu tempel kunci yang baru.', credit_limit_reached: 'Plafon tagihan NotivGo tercapai. Lunasi tagihan di portal NotivGo supaya QR bisa dibuat lagi.', account_inactive: 'Akun NotivGo belum aktif atau ditangguhkan. Buka portal NotivGo.', qris_not_found: 'Akun NotivGo belum punya QRIS terdaftar. Daftarkan QRIS di portal NotivGo, Pengaturan → Toko & HP.', webhook_required: 'Akun NotivGo belum punya alamat tujuan kabar uang masuk. Isi di portal NotivGo, Pengaturan → Kabar uang masuk (alamat webhook di tab Developer).', order_ref_conflict: 'Nomor pesanan ini sudah dipakai dengan nominal lain. Periksa di portal NotivGo, menu Transaksi.', nominal_penuh: 'Terlalu banyak pesanan bernominal sama yang belum dibayar. Dicoba lagi otomatis.', daftar_tidak_lengkap: 'Daftar pesanan terbuka belum bisa dibaca lengkap. Dicoba lagi otomatis.', melebihi_batas: 'Total pesanan melebihi batas QRIS Rp10.000.000 per transaksi.', kosong: 'Tidak ada produk yang dipilih.', jumlah_tidak_sah: 'Jumlah produk tidak bisa dibaca.', katalog_kosong: 'Daftar produk di formulir kosong. Jalankan menu NotivGo → Perbarui formulir.', belum_dipasang: 'Templat belum dipasang. Jalankan menu NotivGo → Pasang.', jawaban_tertunda: 'Ada jawaban formulir yang belum bisa diproses. Buka menu NotivGo → Cek pemasangan.' }; if (kode === 'rate_limited' || kode === 'jaringan' || ulang === true) { return 'NotivGo sedang tidak bisa dihubungi. Dicoba lagi otomatis.'; } if (Object.prototype.hasOwnProperty.call(pesan, kode)) { return pesan[kode]; } return 'NotivGo menolak permintaan (' + String(kode).replace(/[^a-z0-9_]/g, '').slice(0, 40) + '). Periksa di portal NotivGo.'; } function ngKunciApi_() { return PropertiesService.getUserProperties().getProperty(NG_PROP_KUNCI) || ''; } function ngSimpanKunciApi_(kunci) { if (typeof kunci !== 'string') { throw new Error('Kunci API tidak sah.'); } kunci = kunci.trim(); if (kunci.length < 8 || kunci.length > 200 || /[\s\x00-\x1f\x7f-\x9f]/.test(kunci)) { throw new Error('Kunci API tidak sah.'); } PropertiesService.getUserProperties().setProperty(NG_PROP_KUNCI, kunci); } function ngHapusKunciApi_() { PropertiesService.getUserProperties().deleteProperty(NG_PROP_KUNCI); } function ngAlamatAman_(alamat) { return typeof alamat === 'string' && (/^https:\/\/[A-Za-z0-9.-]+(:[0-9]+)?$/.test(alamat) || /^http:\/\/(127\.0\.0\.1|localhost)(:[0-9]+)?$/.test(alamat)); } /** @returns {GoogleAppsScript.URL_Fetch.URLFetchRequest & {url: string}} */ function ngPermintaan_(metode, jalur, isi) { /** @type {GoogleAppsScript.URL_Fetch.URLFetchRequest & {url: string}} */ var opsi = { url: NG_API + jalur, method: /** @type {GoogleAppsScript.URL_Fetch.HttpMethod} */ (metode.toLowerCase()), headers: {Authorization: 'Bearer ' + ngKunciApi_(), Accept: 'application/json'}, muteHttpExceptions: true, followRedirects: false }; if (isi !== null && isi !== undefined) { opsi.contentType = 'application/json'; opsi.payload = JSON.stringify(isi); } return opsi; } /** @param {GoogleAppsScript.URL_Fetch.HTTPResponse} resp */ function ngBacaBalasan_(resp, rahasia) { var status = resp.getResponseCode(); var data = {}; var sah = false; try { data = JSON.parse(resp.getContentText()); sah = data !== null && typeof data === 'object' && !Array.isArray(data); } catch (galat) { sah = false; } if (!sah) { data = {}; } var ok = status >= 200 && status < 300 && sah; var kodeSehat = ok ? '' : (sah && typeof data.error === 'string' ? data.error : 'invalid_response'); ngKesehatanApi_(ok, rahasia ? kodeSehat.split(rahasia).join('') : kodeSehat); return { ok: ok, status: status, kode: ok ? '' : (sah && typeof data.error === 'string' ? data.error : 'invalid_response'), data: data, ulang: !ok && (status === 429 || status >= 500) }; } function ngGalatApi_(kode, ulang) { ngKesehatanApi_(false, kode); return {ok: false, status: 0, kode: kode, data: {}, ulang: ulang}; } function ngPraApi_() { if (!ngAlamatAman_(NG_API)) { return ngGalatApi_('alamat_tidak_aman', false); } if (!ngKunciApi_()) { return ngGalatApi_('belum_dipasang', false); } return null; } /** Hapus pantulan rahasia oleh server, termasuk di kunci objek bersarang. */ function ngRahasiakan_(hasil) { var kunci = ngKunciApi_(); if (!kunci) { return hasil; } var teks = JSON.stringify(hasil); var literal = JSON.stringify(kunci).slice(1, -1); return JSON.parse(teks.split(literal).join('[disamarkan]')); } function ngApi_(metode, jalur, isi) { if (!ngAdaWaktu_()) { return ngGalatApi_('jaringan', true); } var galat = ngPraApi_(); if (galat) { return galat; } try { var opsi = ngPermintaan_(metode, jalur, isi); return ngRahasiakan_(ngBacaBalasan_(UrlFetchApp.fetch(opsi.url, opsi))); } catch (e) { return ngGalatApi_('jaringan', true); } } function ngApiBanyak_(daftar) { if (!daftar.length || !ngAdaWaktu_()) { return []; } var galat = ngPraApi_(); if (galat) { return daftar.map(function() { return ngGalatApi_(galat.kode, galat.ulang); }); } try { var permintaan = daftar.map(function(r) { return ngPermintaan_(r.metode, r.jalur, r.isi); }); return UrlFetchApp.fetchAll(permintaan).map(function(resp) { return ngRahasiakan_(ngBacaBalasan_(resp)); }); } catch (e) { return daftar.map(function() { return ngGalatApi_('jaringan', true); }); } } function ngApiBuatQr_(ref, nominal, detikBerlaku) { // Jangan membulatkan pecahan: lebih aman menolak sebelum jaringan. if (!Number.isSafeInteger(nominal) || nominal < 1 || nominal > NG_MAKS_NOMINAL || !Number.isSafeInteger(detikBerlaku) || detikBerlaku < 0 || detikBerlaku > 7776000) { return ngGalatApi_('bad_request', false); } return ngApi_('POST', '/v1/generate', { order_ref: ref, amount: nominal, create_order: true, expires_in: detikBerlaku, format: 'png' }); } function ngApiPesanan_(ref) { return ngApi_('GET', '/v1/orders/' + encodeURIComponent(ref), null); } function ngApiBatal_(ref) { return ngApi_('POST', '/v1/orders/' + encodeURIComponent(ref) + '/cancel', null); } function ngApiCekKunci_(kunci) { if (kunci === undefined) { return ngApi_('GET', '/v1/orders?limit=1', null); } if (!ngAlamatAman_(NG_API)) { return ngGalatApi_('alamat_tidak_aman', false); } if (!kunci) { return ngGalatApi_('unauthorized', false); } try { var opsi = ngPermintaan_('GET', '/v1/orders?limit=1', null); opsi.headers = {Authorization: 'Bearer ' + kunci, Accept: 'application/json'}; var r = ngBacaBalasan_(UrlFetchApp.fetch(opsi.url, opsi), kunci); return {ok: r.ok, kode: String(r.kode).split(kunci).join('').replace(/[^a-z0-9_]/g, '').slice(0, 40), ulang: r.ulang}; } catch (e) { return ngGalatApi_('jaringan', true); } } function ngDaftarTerbuka_() { if (!ngAdaWaktu_()) { return {ok: false, lengkap: false, nominal: [], kode: 'jaringan', ulang: true}; } var nominal = []; var before = 0; var dilihat = []; for (var i = 0; i < 5; i++) { var r = ngApi_('GET', '/v1/orders?status=open&limit=200' + (before ? '&before_id=' + before : ''), null); if (!r.ok || !Array.isArray(r.data.orders)) { return {ok: false, lengkap: false, nominal: [], kode: r.kode || 'invalid_response', ulang: r.ulang}; } for (var o of r.data.orders) { if (o && o.status === 'open' && ngNumerik_(o.amount)) { nominal.push(Math.trunc(Number(o.amount))); } } var next = ngNumerik_(r.data.next_before_id) ? Math.trunc(Number(r.data.next_before_id)) : 0; if (next <= 0) { return {ok: true, lengkap: true, nominal: nominal}; } if (dilihat.includes(next)) { return {ok: true, lengkap: false, nominal: nominal}; } dilihat.push(next); before = next; } return {ok: true, lengkap: false, nominal: nominal}; } var NG_KOLOM = { waktu: 1, ref: 2, nama: 3, email: 4, rincian: 5, subtotal: 6, kode: 7, total: 8, status: 9, batas: 10, dibayar: 11, catatan: 12, emailQr: 13, emailLunas: 14, dicek: 15, jawaban: 16, percobaan: 17, durasi: 18, rincianEmail: 19, sibuk: 20, ulangEmail: 21, emailPada: 22, catatanAwal: 23, emailGagal: 24, qrPertama: 25 }; var NG_KEPALA = ['Waktu pesan', 'No. pesanan', 'Nama', 'Email', 'Rincian', 'Subtotal', 'Kode unik', 'Total bayar', 'Status', 'Batas bayar', 'Dibayar pada', 'Keterangan', 'Email QR', 'Email lunas', 'Dicek pada', 'ID jawaban', 'Percobaan', 'Durasi QR (detik)', 'Rincian email (teknis)', 'Pekerjaan sampai (teknis)', 'Percobaan email otomatis', 'Email dicoba pada', 'Catatan awal', 'Email belum bisa diproses (teknis)', 'QR terkirim pertama']; var NG_EMAIL_GAGAL = 'Email QR belum terkirim. Pilih baris ini, lalu klik NotivGo → Kirim ulang email QR.'; var NG_EMAIL_LUNAS_GAGAL = 'Email lunas belum terkirim. Dicoba lagi otomatis, paling banyak tiga kali dengan jeda 10 menit. Kamu juga bisa memilih baris ini lalu klik NotivGo → Kirim ulang email QR untuk mengirim email lunas. Periksa kolom Email lunas bila masih gagal.'; var NG_TEKS = { deskripsi: 'Pilih jumlah tiap produk, lalu kirim. Kode QR untuk membayar dikirim ke email yang kamu isi.', konfirmasi: 'Terima kasih, pesanan sudah kami terima. Kode QR untuk membayar dikirim ke emailmu, biasanya dalam beberapa menit. Belum masuk? Cek folder Spam atau Promosi, atau hubungi toko.', belum: 'Templat belum dipasang di berkas ini. Jalankan menu NotivGo → Pasang.', subjekQr: 'Kode QR pembayaran pesanan {no} ({total})', qr: ['Halo {nama},', 'Terima kasih sudah memesan di {toko}. Berikut rincian pesanan {no}:', 'Subtotal: {subtotal}', 'Kode unik: {kode}', 'Total yang dibayar: {total}', 'Cara membayar:', '1. Buka aplikasi bank atau dompet digital, pilih menu bayar atau pindai QRIS.', '2. Pindai kode QR di email ini. Bila email dibuka di HP yang sama, simpan gambar QR terlampir lalu unggah dari galeri di menu pindai QRIS.', '3. Pastikan nominalnya tepat {total}, lalu selesaikan pembayaran.', '{gambar}', 'Bayar sebelum {batas}. Kode unik membuat nominal pesanan ini berbeda dari pesanan lain, supaya pembayaran dikenali otomatis. Mohon bayar tepat sesuai nominal.', 'Ada pertanyaan? Balas email ini{kontak}.', 'Salam,', '{toko}'], subjekLunas: 'Pembayaran pesanan {no} sudah diterima', lunas: ['Halo {nama},', 'Pembayaran {total} untuk pesanan {no} sudah kami terima. Terima kasih.', 'Salam,', '{toko}'], subjekGagal: 'Pesanan di {toko} belum bisa diproses', gagal: ['Halo {nama},', 'Formulir pesanan sudah kami terima, tetapi kode QR pembayarannya belum bisa dibuat: {alasanPembeli}.', 'Belum ada yang perlu dibayar. {lanjut}', 'Salam,', '{toko}'], alasanPembeli: { kosong: ['tidak ada produk yang dipilih', 'Silakan isi ulang formulirnya.'], jumlah_tidak_sah: ['jumlah produk tidak terbaca', 'Silakan isi ulang formulirnya.'], melebihi_batas: ['total pesanan melebihi batas pembayaran QRIS (Rp10.000.000)', 'Silakan isi ulang formulirnya dengan jumlah yang lebih kecil.'], lain: ['sistem pembayaran toko sedang terkendala', 'Kami akan menghubungi kembali, atau balas email ini.'] }, peringatan: 'Templat NotivGo: perlu perhatian' }; function ngJam_() { return Date.now(); } function ngDok_() { return PropertiesService.getScriptProperties(); } function ngBuku_() { return SpreadsheetApp.getActiveSpreadsheet() || SpreadsheetApp.openById(ngDok_().getProperty('NG_ID_SPREADSHEET')); } function ngBerkas_() { return ngBuku_(); } function ngTerpasang_() { return !!ngKunciApi_() && ngDok_().getProperty('NG_ID_SPREADSHEET') === ngBerkas_().getId() && !!ngDok_().getProperty('NG_ID_FORM'); } function ngAkunMenu_() { var pemasang = ngDok_().getProperty('NG_PEMASANG') || ''; var email = Session.getEffectiveUser().getEmail(); if (pemasang && email && email !== pemasang && ngDok_().getProperty('NG_ID_SPREADSHEET') === ngBuku_().getId()) { SpreadsheetApp.getUi().alert('Templat ini dipasang oleh ' + pemasang + '. Hanya akun itu yang bisa menjalankan menu ini. Untuk pindah akun, minta akun itu menjalankan NotivGo → Lepas templat lebih dulu.'); return false; } return true; } function ngWajibPasang_() { if (!ngAkunMenu_()) { return false; } if (ngTerpasang_()) { return true; } SpreadsheetApp.getUi().alert(NG_TEKS.belum); return false; } function ngSiapkanSheet_() { var ss = ngBerkas_(); if (!ss.getSheetByName('Produk')) { var p = ss.insertSheet('Produk'); p.appendRow(['Nama produk', 'Harga (Rp)', 'Keterangan (opsional)']); p.appendRow(['Contoh produk A', 10000, 'Ganti dengan produkmu']); p.appendRow(['Contoh produk B', 25000, '']); p.setFrozenRows(1); } if (!ss.getSheetByName('Pesanan')) { var o = ss.insertSheet('Pesanan'); o.appendRow(NG_KEPALA); o.setFrozenRows(1); o.hideColumns(NG_KOLOM.dicek, NG_KEPALA.length - NG_KOLOM.dicek + 1); } var pesanan = ss.getSheetByName('Pesanan'); try { var terlindung = pesanan.getProtections(SpreadsheetApp.ProtectionType.SHEET).some(function(p) { return p.getDescription() === 'Lembar ini diisi otomatis oleh templat NotivGo.'; }); if (!terlindung) { pesanan.protect().setWarningOnly(true).setDescription('Lembar ini diisi otomatis oleh templat NotivGo.'); pesanan.getRange(1, 1).setNote('Lembar ini diisi otomatis. Jangan mengurutkan, menyisipkan, atau menghapus baris di sini; salin ke lembar lain bila ingin mengolah datanya.'); } } catch (eProteksi) { /* peringatan bukan syarat pemrosesan */ } ss.getSheetByName('Pesanan').getRange(1, 1, 1, NG_KEPALA.length).setValues([NG_KEPALA]); ss.getSheetByName('Pesanan').hideColumns(NG_KOLOM.dicek, NG_KEPALA.length - NG_KOLOM.dicek + 1); if (!ss.getSheetByName('Setelan')) { var s = ss.insertSheet('Setelan'); s.appendRow(['Nama', 'Nilai', 'Penjelasan']); [['Nama toko', ss.getName(), 'Nama yang tampil di email'], ['Kontak toko', '', 'Kontak untuk pertanyaan pembeli'], ['Batas waktu bayar (menit)', 60, 'Bilangan bulat 5–1440 menit'], ['Kode unik terbesar (Rp)', 99, 'Bilangan bulat 9–999 rupiah'], ['Jumlah terbanyak per produk', 10, 'Bilangan bulat 1–50'], ['Kirim email saat lunas', 'ya', 'Hanya tidak yang mematikan']].forEach(function(r) { s.appendRow(r); }); } } function ngBulatSetelan_(n, min, maks, bawaan) { if (typeof n !== 'number' && (typeof n !== 'string' || !/^[0-9]+$/.test(n.trim()))) { return bawaan; } n = Number(n); return Number.isInteger(n) && n >= min && n <= maks ? n : bawaan; } function ngSetelan_() { var nilai = {}; var sheet = ngBerkas_().getSheetByName('Setelan'); if (sheet) { sheet.getDataRange().getValues().slice(1).forEach(function(r) { nilai[String(r[0])] = r[1]; }); } return { toko: String(nilai['Nama toko'] || ngBerkas_().getName()), kontak: String(nilai['Kontak toko'] || ''), menit: ngBulatSetelan_(nilai['Batas waktu bayar (menit)'], 5, 1440, 60), maksKode: ngBulatSetelan_(nilai['Kode unik terbesar (Rp)'], 9, 999, 99), maksJumlah: ngBulatSetelan_(nilai['Jumlah terbanyak per produk'], 1, 50, 10), emailLunas: String(nilai['Kirim email saat lunas'] || '').trim().toLowerCase() !== 'tidak' }; } function ngKatalog_() { try { return JSON.parse(ngDok_().getProperty('NG_KATALOG') || '{}'); } catch (e) { return {}; } } function ngForm_() { return FormApp.openById(ngDok_().getProperty('NG_ID_FORM')); } function ngHapusPemicu_() { ScriptApp.getProjectTriggers().forEach(function(t) { if (['ngSaatFormDikirim', 'ngPeriksaBerkala'].includes(t.getHandlerFunction())) { ScriptApp.deleteTrigger(t); } }); } function onOpen() { SpreadsheetApp.getUi().createMenu('NotivGo') .addItem('Pasang / sambungkan akun', 'ngMenuPasang') .addItem('Perbarui formulir', 'ngMenuPerbaruiFormulir') .addItem('Buka formulir', 'ngMenuBukaFormulir').addSeparator() .addItem('Periksa pembayaran sekarang', 'ngMenuPeriksa') .addItem('Kirim ulang email QR (baris terpilih)', 'ngMenuKirimUlang') .addItem('Batalkan pesanan (baris terpilih)', 'ngMenuBatalkan').addSeparator() .addItem('Cek pemasangan', 'ngMenuCek') .addItem('Lepas templat (hapus kunci & pemicu)', 'ngMenuLepas').addToUi(); } function ngMenuPasang() { if (!ngAkunMenu_()) { return; } var ui = SpreadsheetApp.getUi(); var tanya = ui.prompt('Pasang NotivGo', 'Tempel kunci API dari portal NotivGo (Pengaturan → Developer → Kunci API):', ui.ButtonSet.OK_CANCEL); if (tanya.getSelectedButton() !== ui.Button.OK) { return; } var lama = ngKunciApi_(); var baru = tanya.getResponseText().trim() || lama; var hasil = ngApiCekKunci_(baru); if (!hasil.ok) { ui.alert(hasil.kode === 'unauthorized' ? 'Kunci API ditolak NotivGo. Salin ulang dari portal, lalu coba lagi.' : hasil.ulang ? 'NotivGo sedang tidak bisa dihubungi. Coba lagi sebentar.' : ngAlasanAwam_(hasil.kode)); return; } var pesanan = ngBerkas_().getSheetByName('Pesanan'); var rowsAkun = pesanan ? pesanan.getDataRange().getValues().slice(1) : []; var berjalan = rowsAkun.filter(function(r) { return [NG_STATUS.MENUNGGU, NG_STATUS.TINJAU].includes(ngNilai_(r, 'status')) || (ngNilai_(r, 'status') === NG_STATUS.MEMBUAT && Number(ngNilai_(r, 'percobaan')) >= 1); }); if (baru !== lama && berjalan.length) { var bukti = rowsAkun.filter(function(r) { return [NG_STATUS.MENUNGGU, NG_STATUS.TINJAU].includes(ngNilai_(r, 'status')); }).sort(function(a, b) { return ngDetik_(ngNilai_(b, 'waktu')) - ngDetik_(ngNilai_(a, 'waktu')); }); if (!bukti.length) { ui.alert('Masih ada pesanan yang sedang dibuatkan QR. Tunggu beberapa menit sampai selesai, lalu jalankan Pasang lagi.'); return; } var cekAkun = ngApiAkunPesanan_(baru, ngNilai_(bukti[0], 'ref')); if (!cekAkun.ok) { ui.alert(cekAkun.kode === 'not_found' ? 'Kunci ini milik akun NotivGo yang berbeda, padahal masih ada pesanan yang menunggu pembayaran. Tunggu pesanan itu selesai atau batalkan dulu, atau pakai kunci dari akun yang sama.' : 'NotivGo sedang tidak bisa dihubungi. Coba lagi sebentar.'); return; } } try { ngSimpanKunciApi_(baru); } catch (e) { ui.alert('Kunci API tidak sah.'); return; } var ss = ngBerkas_(); var sendiri = ngDok_().getProperty('NG_ID_SPREADSHEET') === ss.getId(); if (!sendiri && !['id_ID', 'in_ID'].includes(ss.getSpreadsheetLocale())) { ss.setSpreadsheetLocale('id_ID'); } ngSiapkanSheet_(); var form; if (sendiri) { try { form = ngForm_(); } catch (e) { /* buat pengganti */ } } if (!form) { form = FormApp.create('Form Pesanan ' + ngSetelan_().toko); form.setCollectEmail(false).setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()) .setDescription(NG_TEKS.deskripsi).setConfirmationMessage(NG_TEKS.konfirmasi); var modern = /** @type {GoogleAppsScript.Forms.Form & {supportsAdvancedResponderPermissions?: function(): boolean, setPublished?: function(boolean): GoogleAppsScript.Forms.Form}} */ (form); if (typeof modern.supportsAdvancedResponderPermissions === 'function' && modern.supportsAdvancedResponderPermissions()) { modern.setPublished(true); } ngDok_().deleteProperty('NG_KATALOG'); } form.setAcceptingResponses(true); ngDok_().setProperty('NG_ID_SPREADSHEET', ss.getId()).setProperty('NG_ID_FORM', form.getId()) .setProperty('NG_PEMASANG', Session.getEffectiveUser().getEmail()); var selaras = ngSelaraskanFormulir_(); ngHapusPemicu_(); ScriptApp.newTrigger('ngSaatFormDikirim').forForm(form).onFormSubmit().create(); ScriptApp.newTrigger('ngPeriksaBerkala').timeBased().everyMinutes(1).create(); if (!ngDok_().getProperty('NG_REKON')) { ngDok_().setProperty('NG_REKON', String(ngSekarang_())); } ui.alert('NotivGo terpasang.\n' + form.getPublishedUrl() + '\n' + (selaras ? 'Bagikan alamat formulir kepada pembeli.' : 'Isi sheet Produk lalu jalankan Perbarui formulir.')); } function ngMenuBukaFormulir() { if (!ngWajibPasang_()) { return; } var f = ngForm_(); SpreadsheetApp.getUi().alert('Untuk pembeli: ' + f.getPublishedUrl() + '\nSunting: ' + f.getEditUrl()); } function ngMenuPerbaruiFormulir() { if (ngWajibPasang_()) { ngSelaraskanFormulir_(); } } function ngMenuLepas() { if (!ngWajibPasang_()) { return; } var ui = SpreadsheetApp.getUi(); if (ui.alert('Lepas templat?', 'Sheet dan pesanan tetap disimpan.', ui.ButtonSet.YES_NO) !== ui.Button.YES) { return; } ngHapusPemicu_(); ngHapusKunciApi_(); ngDok_().deleteProperty('NG_PEMASANG').deleteProperty('NG_REKON'); ngDok_().setProperty('NG_PANTAU', '0'); try { ngForm_().setAcceptingResponses(false); } catch (e) { /* formulir sudah hilang */ } } function ngMenuCek() { if (!ngWajibPasang_()) { return; } var r = ngApiCekKunci_(); var form = 'tidak bisa dibuka'; var menerima = false; try { menerima = ngForm_().isAcceptingResponses(); form = 'ada'; } catch (e) { /* laporkan */ } var pemicu = ScriptApp.getProjectTriggers().map(function(t) { return t.getHandlerFunction(); }); SpreadsheetApp.getUi().alert(['Versi templat: ' + NG_VERSI, 'Kunci API: ' + (r.ok ? 'tersambung' : ngAlasanAwam_(r.kode)), 'Formulir: ' + form, 'Pemicu formulir: ' + (pemicu.includes('ngSaatFormDikirim') ? 'ada' : 'tidak'), 'Pemicu waktu: ' + (pemicu.includes('ngPeriksaBerkala') ? 'ada' : 'tidak'), 'Jumlah produk di formulir: ' + (ngKatalog_().butir || []).length, 'Sisa jatah email hari ini: ' + MailApp.getRemainingDailyQuota()].concat(ngKesehatanMenu_(menerima)).join('\n')); } function ngSelaraskanFormulir_() { var ui = SpreadsheetApp.getUi(); var masalah = []; var nama = []; var produk = []; ngBerkas_().getSheetByName('Produk').getDataRange().getValues().slice(1).forEach(function(r, i) { var n = String(r[0] || '').trim(); if (!n) { return; } var h = ngBacaHarga_(r[1]); if (h === null) { masalah.push('Harga tidak terbaca di baris ' + (i + 2)); } if (n.length > 80) { masalah.push('Nama lebih dari 80 karakter di baris ' + (i + 2)); } if (nama.includes(n.toLowerCase())) { masalah.push('Nama kembar: ' + n); } nama.push(n.toLowerCase()); produk.push({nama: n, harga: h, keterangan: String(r[2] || '')}); }); if (!produk.length) { masalah.push('Isi minimal satu produk.'); } if (produk.length > 30) { masalah.push('Maksimal 30 produk.'); } // Ukuran katalog diperiksa sebelum satu pun butir diubah; ID aktual dibatasi panjangnya oleh Google. var katalogLama = ngKatalog_(); var fUkur = ngForm_(); var idLama = (katalogLama.idLama || []).concat((katalogLama.butir || []).filter(function(b) { return !produk.some(function(p) { return p.nama === b.nama; }) || !ngButir_(fUkur, b.id); }).map(function(b) { return b.id; })).slice(-60); var ukuran = JSON.stringify({idLama: idLama, butir: produk.map(function(p) { var lama = (katalogLama.butir || []).filter(function(b) { return b.nama === p.nama; })[0]; return {id: lama && ngButir_(fUkur, lama.id) ? lama.id : '999999999999', nama: p.nama, harga: p.harga}; }), idNama: ngButir_(fUkur, katalogLama.idNama) ? katalogLama.idNama : '999999999999', idEmail: ngButir_(fUkur, katalogLama.idEmail) ? katalogLama.idEmail : '999999999999', idCatatan: ngButir_(fUkur, katalogLama.idCatatan) ? katalogLama.idCatatan : '999999999999', maksJumlah: ngSetelan_().maksJumlah}); if (Utilities.newBlob(ukuran).getBytes().length > 8000) { masalah.push('Katalog melebihi 8000 karakter. Pendekkan nama produk.'); } if (masalah.length) { ui.alert(masalah.join('\n')); return false; } var murah = produk.filter(function(p) { return p.harga < 1000; }); if (murah.length && ui.alert('Periksa harga', 'Harga di bawah Rp1.000: ' + murah.map(function(p) { return p.nama + ' = ' + ngRupiah_(p.harga); }).join(', ') + '. Bila maksudnya ribuan, ketik angka penuhnya tanpa titik, mis. 25000. Lanjutkan dengan harga ini?', ui.ButtonSet.YES_NO) !== ui.Button.YES) { return false; } var lockForm = LockService.getScriptLock(); if (!lockForm.tryLock(5000)) { ui.alert('Formulir sedang diperbarui. Coba lagi sebentar.'); return false; } var alamatForm = ''; try { var f = ngForm_(); var lama = ngKatalog_(); // Katalog dapat berubah selama validasi atau dialog konfirmasi harga. idLama = (lama.idLama || []).concat((lama.butir || []).filter(function(b) { return !produk.some(function(p) { return p.nama === b.nama; }) || !ngButir_(f, b.id); }).map(function(b) { return b.id; })).slice(-60); f.setCollectEmail(false).setDescription(NG_TEKS.deskripsi).setConfirmationMessage(NG_TEKS.konfirmasi); var maks = ngSetelan_().maksJumlah; var pilihan = []; for (var j = 1; j <= maks; j++) { pilihan.push(String(j)); } var itemEmail = ngButir_(f, lama.idEmail); var eItem = itemEmail ? itemEmail.asTextItem() : f.addTextItem(); if (!itemEmail) { // Catat identitas sebelum konfigurasi butir dapat gagal. lama.idEmail = String(eItem.getId()); ngDok_().setProperty('NG_KATALOG', JSON.stringify(lama)); } eItem.setTitle('Email').setRequired(true).setHelpText('Kode QR untuk membayar dikirim ke email ini.') .setValidation(FormApp.createTextValidation() .setHelpText('Isi alamat email yang benar, mis. nama@gmail.com.').requireTextIsEmail().build()); f.moveItem(eItem.getIndex(), 0); var itemNama = ngButir_(f, lama.idNama); var itemCatatan = ngButir_(f, lama.idCatatan); var nItem = itemNama ? itemNama.asTextItem() : f.addTextItem(); nItem.setTitle('Nama pemesan').setRequired(true); f.moveItem(nItem.getIndex(), 1); var baru = {butir: [], idLama: idLama, idEmail: String(eItem.getId()), idNama: String(nItem.getId()), idCatatan: '', maksJumlah: maks}; var akhir = nItem.getIndex(); produk.forEach(function(p) { var sebelumnya = (lama.butir || []).filter(function(b) { return b.nama === p.nama; })[0]; var item = ngButir_(f, sebelumnya && sebelumnya.id); var list = item ? item.asListItem() : f.addListItem(); list.setTitle(p.nama + ' — ' + ngRupiah_(p.harga)).setHelpText(p.keterangan).setChoiceValues(pilihan).setRequired(false); f.moveItem(list.getIndex(), akhir + 1); akhir = list.getIndex(); baru.butir.push({id: String(list.getId()), nama: p.nama, harga: p.harga}); }); (lama.butir || []).forEach(function(b) { if (!produk.some(function(p) { return p.nama === b.nama; })) { var item = ngButir_(f, b.id); if (item) { f.deleteItem(item); } } }); var catatan = itemCatatan ? itemCatatan.asParagraphTextItem() : f.addParagraphTextItem(); catatan.setTitle('Catatan untuk penjual').setRequired(false); f.moveItem(catatan.getIndex(), akhir + 1); baru.idCatatan = String(catatan.getId()); var json = JSON.stringify(baru); if (Utilities.newBlob(json).getBytes().length > 8000) { throw new Error('Katalog melebihi 8000 karakter.'); } ngDok_().setProperty('NG_KATALOG', json).setProperty('NG_KATALOG_WAKTU', String(ngSekarang_())); alamatForm = f.getPublishedUrl(); } finally { ngLepasKunci_(lockForm); } // Dialog menangguhkan eksekusi Apps Script; tampilkan setelah kunci dilepas. ui.alert(produk.map(function(p) { return p.nama + ' — ' + ngRupiah_(p.harga); }).join('\n') + '\n' + alamatForm + '\nPembeli yang sudah membuka formulir sebelum perubahan ini bisa masih melihat harga lama; total di email mengikuti harga terbaru.'); return true; } function ngButir_(form, id) { if (!id) { return null; } try { return form.getItemById(Number(id)); } catch (e) { return null; } } function ngPesanan_() { return ngBerkas_().getSheetByName('Pesanan'); } var NG_SNAPSHOT = null; function ngBaris_(ref, id) { if (ref) { var lembar = ngPesanan_(); var banyakRef = lembar.getLastRow() - 1; var refs = banyakRef > 0 ? lembar.getRange(2, NG_KOLOM.ref, banyakRef, 1).getValues() : []; for (var ri = 0; ri < refs.length; ri++) { if (refs[ri][0] === ref) { return {nomor: ri + 2, nilai: lembar.getRange(ri + 2, 1, 1, NG_KEPALA.length).getValues()[0]}; } } return null; } var sheet = ngPesanan_(); if (id) { var banyak = sheet.getLastRow() - 1; var ids = banyak > 0 ? sheet.getRange(2, NG_KOLOM.jawaban, banyak, 1).getValues() : []; for (var j = 0; j < ids.length; j++) { if (ids[j][0] === ngSelAman_(id)) { return {nomor: j + 2, nilai: sheet.getRange(j + 2, 1, 1, NG_KEPALA.length).getValues()[0]}; } } return null; } var rows = sheet.getDataRange().getValues(); for (var i = 1; i < rows.length; i++) { if ((ref && rows[i][NG_KOLOM.ref - 1] === ref) || (id && rows[i][NG_KOLOM.jawaban - 1] === id)) { return {nomor: i + 1, nilai: rows[i]}; } } return null; } function ngTulis_(ref, kolom, nilai) { ngTulisBanyak_(ref, kolom, [nilai]); } function ngTulisBanyak_(ref, kolom, nilai, nomor) { // Nomor dari pembacaan di bawah kunci aman selama identitas baris masih cocok. // Jangan memindai ulang seluruh kolom referensi untuk setiap baris rekonsiliasi. var b = nomor ? {nomor: nomor, nilai: ngPesanan_().getRange(nomor, 1, 1, NG_KEPALA.length).getValues()[0]} : ngBaris_(ref, null); if (b && ngNilai_(b.nilai, 'ref') === ref) { ngPesanan_().getRange(b.nomor, kolom, 1, nilai.length).setValues([nilai]); ngDok_().setProperty('NG_BERIKUT', String(ngSekarang_())); } } function ngNilai_(r, nama) { return r[NG_KOLOM[nama] - 1]; } function ngDetik_(v) { return v && typeof v.getTime === 'function' ? v.getTime() / 1000 : 0; } function ngRefBaru_() { return ngBuatRef_(Utilities.formatDate(new Date(ngSekarang_() * 1000), ngBerkas_().getSpreadsheetTimeZone(), 'yyMMdd'), Utilities.getUuid().replace(/-/g, '').slice(0, 8)); } function ngAlokasi_(ref, daftar) { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'total')) { return true; } var subtotal = Number(ngNilai_(b.nilai, 'subtotal')); if (!Number.isSafeInteger(subtotal) || subtotal < 1) { ngTulis_(ref, NG_KOLOM.status, NG_STATUS.DITOLAK); ngTulis_(ref, NG_KOLOM.catatan, ngAlasanAwam_('jumlah_tidak_sah')); return false; } if (!daftar.ok || !daftar.lengkap) { ngTulis_(ref, NG_KOLOM.catatan, ngAlasanAwam_(daftar.ok ? 'daftar_tidak_lengkap' : daftar.kode, daftar.ulang)); return false; } var nominal = daftar.nominal.slice(); var sheet = ngPesanan_(); var jumlahBaris = sheet.getLastRow() - 1; var reservasi = jumlahBaris > 0 ? sheet.getRange(2, NG_KOLOM.total, jumlahBaris, 3).getValues() : []; reservasi.forEach(function(r) { if (ngNominalTerkunci_(r[1], ngDetik_(r[2]), ngSekarang_()) && r[0]) { nominal.push(Number(r[0])); } }); var pilih = ngRincianTagihan_(Number(ngNilai_(b.nilai, 'subtotal')), ngSetelan_().maksKode, nominal, ngAcak_, NG_MAKS_NOMINAL); if (!pilih.ok) { ngTulis_(ref, NG_KOLOM.catatan, ngAlasanAwam_(pilih.alasan)); if (pilih.alasan === 'melebihi_batas') { ngTulis_(ref, NG_KOLOM.status, NG_STATUS.DITOLAK); } return false; } ngTulisBanyak_(ref, NG_KOLOM.kode, [pilih.kode, pilih.nominal]); return true; } function ngSaatFormDikirim(e) { ngProsesJawaban_(e.response); } function ngProsesJawaban_(response, pemulihan) { var ref = ''; var tercatat = false; try { if (!ngTerpasang_()) { return; } var id = String(response.getId()); if (!id) { return; } var katalog = ngKatalog_(); var jumlah = {}; response.getItemResponses().forEach(function(r) { jumlah[String(r.getItem().getId())] = r.getResponse(); }); var dihapus = (katalog.idLama || []).some(function(id) { return Object.prototype.hasOwnProperty.call(jumlah, id) && String(jumlah[id]).trim() !== ''; }); var hitung = dihapus ? {ok: false, alasan: 'produk_dihapus'} : ngHitungRincian_(katalog.butir, jumlah, {maksJumlah: katalog.maksJumlah, maksNominal: NG_MAKS_NOMINAL}); var daftar = hitung.ok ? ngDaftarTerbuka_() : null; var lock = LockService.getScriptLock(); var terkunci = false; try { if (!ngAdaWaktu_()) { return; } lock.waitLock(ngAttente_(20000)); terkunci = true; } catch (eLock) { /* pemulihan melalui pemicu berikutnya */ } if (!terkunci) { ngDok_().setProperty('NG_PULIH', '0'); ngDok_().setProperty('NG_PANTAU', '1'); ngDok_().setProperty('NG_BERIKUT', String(ngSekarang_())); return; } try { var lama = ngBaris_(null, id); if (!lama && ngSudahDiproses_(id)) { return; } if (lama) { ref = String(ngNilai_(lama.nilai, 'ref')); } else { ref = ngRefBaru_(); /** @type {Array<*>} */ var row = NG_KEPALA.map(function() { return ''; }); row[NG_KOLOM.waktu - 1] = response.getTimestamp(); row[NG_KOLOM.ref - 1] = ref; row[NG_KOLOM.nama - 1] = ngSelAman_(String(jumlah[katalog.idNama] || '')); var emailJawaban = katalog.idEmail ? String(jumlah[katalog.idEmail] || '') : ''; row[NG_KOLOM.email - 1] = ngSelAman_(emailJawaban.trim() ? emailJawaban : String(response.getRespondentEmail() || '')); row[NG_KOLOM.rincian - 1] = hitung.ok ? ngSelAman_(hitung.baris.map(function(p) { return p.nama + ' × ' + p.jumlah; }).join('; ')) : ''; row[NG_KOLOM.subtotal - 1] = hitung.ok ? hitung.subtotal : ''; row[NG_KOLOM.status - 1] = hitung.ok ? NG_STATUS.MEMBUAT : NG_STATUS.DITOLAK; row[NG_KOLOM.catatan - 1] = dihapus ? 'Produk yang dipilih sudah dihapus dari formulir.' : hitung.ok ? ngAlasanAwam_('daftar_tidak_lengkap') : ngAlasanAwam_(hitung.alasan); var catatanAwal = []; if (ngDetik_(response.getTimestamp()) < Number(ngDok_().getProperty('NG_KATALOG_WAKTU') || 0)) { catatanAwal.push('Daftar produk atau harga berubah sesudah formulir ini diisi. Periksa rinciannya.'); } var mirip = ngPesanan_().getDataRange().getValues().slice(1).filter(function(r) { var selisih = ngSekarang_() - ngDetik_(ngNilai_(r, 'waktu')); return row[NG_KOLOM.email - 1] && ngNilai_(r, 'email') === row[NG_KOLOM.email - 1] && ngNilai_(r, 'rincian') === row[NG_KOLOM.rincian - 1] && selisih >= 0 && selisih <= 600 && [NG_STATUS.MEMBUAT, NG_STATUS.MENUNGGU].includes(ngNilai_(r, 'status')); }).pop(); if (mirip) { catatanAwal.push('Mirip pesanan ' + ngNilai_(mirip, 'ref') + ' dari email yang sama. Mungkin terkirim dua kali.'); } row[NG_KOLOM.catatanAwal - 1] = catatanAwal.join(' '); row[NG_KOLOM.jawaban - 1] = ngSelAman_(id); row[NG_KOLOM.percobaan - 1] = 0; row[NG_KOLOM.durasi - 1] = ngSetelan_().menit * 60; row[NG_KOLOM.rincianEmail - 1] = hitung.ok ? JSON.stringify(hitung.baris.map(function(p) { return p.nama + ' × ' + p.jumlah + ' — ' + ngRupiah_(p.subtotal); })) : '[]'; ngPesanan_().appendRow(row); ngPekerjaanBaru_(); if (terkunci) { ngCatatDiproses_(id); } tercatat = true; ngDok_().setProperty('NG_PANTAU', '1').setProperty('NG_BERIKUT', String(ngSekarang_())); if (hitung.ok && terkunci) { ngAlokasi_(ref, daftar); } } } finally { if (terkunci) { ngLepasKunci_(lock); } } var baris = ngBaris_(ref, null); if (ngNilai_(baris.nilai, 'status') === NG_STATUS.GAGAL) { ngEmailGagal_(ref, daftar.kode); ngPeringatanPenjual_(daftar.kode); } else if (ngNilai_(baris.nilai, 'status') === NG_STATUS.DITOLAK) { ngEmailGagal_(ref, hitung.alasan || 'melebihi_batas'); } else if (terkunci && hitung.ok && daftar.ok && daftar.lengkap) { ngLanjutkanBaris_(ref); } } catch (galat) { console.error('Jawaban formulir tidak berhasil diproses.'); if (tercatat) { ngDenganKunci_(function() { ngTulis_(ref, NG_KOLOM.catatan, 'Terjadi kendala saat memproses jawaban. Periksa pemasangan.'); }); } else { ngPeringatanPenjual_('jawaban_tertunda'); } } } function ngSelAman_(teks) { return /^[=+\-@]/.test(teks) ? "'" + teks : teks; } function ngKanonJawaban_(ref) { var b = ngBaris_(ref, null); if (!b) { return false; } var id = ngNilai_(b.nilai, 'jawaban'); if (!id) { return true; } var sheet = ngPesanan_(); var matches = sheet.getLastRow() > 1 ? sheet.getRange(2, 1, sheet.getLastRow() - 1, NG_KEPALA.length).getValues().filter(function(r) { return ngNilai_(r, 'jawaban') === id; }) : []; matches.sort(function(a, c) { var aMulai = !!ngNilai_(a, 'total'); var cMulai = !!ngNilai_(c, 'total'); if (aMulai !== cMulai) { return aMulai ? -1 : 1; } return String(ngNilai_(a, 'ref')).localeCompare(String(ngNilai_(c, 'ref'))); }); var utama = ngNilai_(matches[0], 'ref'); if (utama === ref) { return true; } ngTulis_(ref, NG_KOLOM.status, NG_STATUS.DITOLAK); ngTulis_(ref, NG_KOLOM.catatan, 'Jawaban ini sudah tercatat pada pesanan ' + utama + '.'); return false; } function ngLanjutkanBaris_(ref) { var lockKerja = LockService.getScriptLock(); if (!ngAdaWaktu_() || !lockKerja.tryLock(ngAttente_(20000))) { return; } var boleh = false; try { var awal = ngBaris_(ref, null); if (awal && ngNilai_(awal.nilai, 'jawaban')) { ngCatatDiproses_(String(ngNilai_(awal.nilai, 'jawaban'))); } if (awal && ngKanonJawaban_(ref) && ngNilai_(awal.nilai, 'status') === NG_STATUS.MEMBUAT && Number(ngNilai_(awal.nilai, 'sibuk') || 0) <= ngSekarang_()) { ngTulis_(ref, NG_KOLOM.sibuk, ngSekarang_() + 360); boleh = true; } } finally { ngLepasKunci_(lockKerja); } if (!boleh) { return; } try { ngKerjakanBaris_(ref); } finally { ngDenganKunci_(function() { ngTulis_(ref, NG_KOLOM.sibuk, ''); }); } } function ngDurasi_(ref) { var r = ngBaris_(ref, null).nilai; var lama = Number(ngNilai_(r, 'durasi')); if (!Number.isSafeInteger(lama) || lama < 300 || lama > 86400) { lama = ngSetelan_().menit * 60; ngDenganKunci_(function() { ngTulis_(ref, NG_KOLOM.durasi, lama); }); } return lama; } var NG_TAKPASTI = 'Hasil pembuatan QR tidak bisa dipastikan. Periksa pesanan ini di portal NotivGo, menu Transaksi.'; function ngHasilMembuat_(ref, status, catatan) { var berubah = false, takpasti = false; ngDenganKunci_(function() { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') !== NG_STATUS.MEMBUAT) { return; } if (!status && Number(ngNilai_(b.nilai, 'percobaan')) >= 1 && ngSekarang_() - ngDetik_(ngNilai_(b.nilai, 'waktu')) > 86400) { status = NG_STATUS.TINJAU; catatan = NG_TAKPASTI; takpasti = true; } ngTulis_(ref, NG_KOLOM.catatan, catatan); if (status) { ngTulis_(ref, NG_KOLOM.status, status); } berubah = true; }); if (takpasti) { ngPeringatanPenjual_('jaringan'); } return berubah; } function ngGagalMembuat_(ref, kode) { if (ngHasilMembuat_(ref, NG_STATUS.GAGAL, ngAlasanAwam_(kode))) { ngEmailGagal_(ref, kode); ngPeringatanPenjual_(kode); } } function ngJedaMembuat_(r, sekarang) { return sekarang - ngDetik_(ngNilai_(r, 'waktu')) <= 600 ? 60 : 900; } function ngKerjakanBaris_(ref) { try { ngKerjakanBarisInti_(ref); } catch (galat) { console.error('Jawaban formulir tidak berhasil diproses.'); var gagal = false, takpasti = false; ngDenganKunci_(function() { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') !== NG_STATUS.MEMBUAT) { return; } var umur = ngSekarang_() - ngDetik_(ngNilai_(b.nilai, 'waktu')); var pernahKirim = Number(ngNilai_(b.nilai, 'percobaan') || 0) >= 1; takpasti = pernahKirim && umur > 86400; gagal = !pernahKirim && umur > 600; ngTulis_(ref, NG_KOLOM.catatan, takpasti ? NG_TAKPASTI : 'Terjadi kendala saat memproses jawaban. Periksa pemasangan.'); if (takpasti || gagal) { ngTulis_(ref, NG_KOLOM.status, takpasti ? NG_STATUS.TINJAU : NG_STATUS.GAGAL); } }); if (gagal) { ngEmailGagal_(ref, 'jaringan'); } if (gagal || takpasti) { ngPeringatanPenjual_('jaringan'); } } } function ngKerjakanBarisInti_(ref) { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') !== NG_STATUS.MEMBUAT) { return; } var umur = ngSekarang_() - ngDetik_(ngNilai_(b.nilai, 'waktu')); ngDenganKunci_(function() { ngTulis_(ref, NG_KOLOM.dicek, new Date(ngSekarang_() * 1000)); }); if (!ngNilai_(b.nilai, 'total')) { var daftar = ngDaftarTerbuka_(), ok = false; ngDenganKunci_(function() { ok = ngAlokasi_(ref, daftar); }); if (!ok) { b = ngBaris_(ref, null); if (b && ngNilai_(b.nilai, 'status') === NG_STATUS.DITOLAK) { ngEmailGagal_(ref, 'jumlah_tidak_sah'); } else if (umur > 600) { ngGagalMembuat_(ref, daftar.kode || 'nominal_penuh'); } return; } b = ngBaris_(ref, null); } var pernahKirim = Number(ngNilai_(b.nilai, 'percobaan') || 0) >= 1; var g = null; if (pernahKirim) { g = ngApiPesanan_(ref); if (g.ok && (g.data.order_ref !== ref || !['paid', 'expired', 'canceled', 'open'].includes(g.data.status))) { g = ngGalatApi_('invalid_response', true); } if (g.ok && g.data.status !== 'open') { ngTerapan_(ref, g, true); if (g.data.status !== 'paid') { ngEmailGagal_(ref, 'jaringan'); } return; } if (!g.ok) { if (g.kode === 'not_found') { if (umur > 600) { ngGagalMembuat_(ref, 'jaringan'); return; } } else { ngHasilMembuat_(ref, umur > 86400 ? NG_STATUS.TINJAU : '', umur > 86400 ? NG_TAKPASTI : ngAlasanAwam_(g.kode, g.ulang)); if (umur > 600) { ngPeringatanPenjual_(g.kode); } return; } } } var percobaan = 0; ngDenganKunci_(function() { b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') !== NG_STATUS.MEMBUAT) { return; } percobaan = Number(ngNilai_(b.nilai, 'percobaan') || 0) + 1; ngTulis_(ref, NG_KOLOM.percobaan, percobaan); }); if (!percobaan) { return; } var r = ngApiBuatQr_(ref, Number(ngNilai_(b.nilai, 'total')), ngDurasi_(ref)); if (r.ok && (r.data.order_ref !== ref || typeof r.data.qris_png_base64 !== 'string' || !r.data.qris_png_base64.trim() || typeof r.data.expires_at !== 'number' || !Number.isFinite(r.data.expires_at))) { r = ngGalatApi_('invalid_response', true); } if (r.ok) { var sesudah = ngApiPesanan_(ref); if (!sesudah.ok || sesudah.data.order_ref !== ref || !['open', 'paid', 'expired', 'canceled'].includes(sesudah.data.status) || (sesudah.data.status === 'open' && (typeof sesudah.data.expires_at !== 'number' || !Number.isFinite(sesudah.data.expires_at)))) { ngHasilMembuat_(ref, '', ngAlasanAwam_(sesudah.kode || 'invalid_response', sesudah.ulang)); return; } if (sesudah.data.status !== 'open') { ngTerapan_(ref, sesudah, true); return; } if (!ngNumerik_(sesudah.data.amount) || Number(sesudah.data.amount) !== Number(ngNilai_(b.nilai, 'total'))) { ngHasilMembuat_(ref, NG_STATUS.TINJAU, ngAlasanAwam_('order_ref_conflict')); return; } } if (r.ok) { var kirim = false; ngDenganKunci_(function() { b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') !== NG_STATUS.MEMBUAT) { return; } kirim = sesudah.data.expires_at > ngSekarang_() && !ngNilai_(b.nilai, 'emailQr'); if (sesudah.data.expires_at <= ngSekarang_()) { ngTulis_(ref, NG_KOLOM.emailQr, 'Gagal: batas bayar sudah lewat'); } if (kirim) { ngTulis_(ref, NG_KOLOM.emailQr, 'Mengirim…'); ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); } ngTulisBanyak_(ref, NG_KOLOM.status, [NG_STATUS.MENUNGGU, new Date(sesudah.data.expires_at * 1000)]); ngTulis_(ref, NG_KOLOM.catatan, ngNilai_(b.nilai, 'catatanAwal') || ''); }); if (kirim) { ngCatatEmailQr_(ref, ngEmailQr_(ref, r.data.qris_png_base64)); } return; } if (r.ulang || r.kode === 'invalid_response') { ngHasilMembuat_(ref, '', ngAlasanAwam_(r.kode, r.ulang)); return; } if (pernahKirim && g.ok) { var batal = ngApiBatal_(ref); if (!batal.ok) { ngHasilMembuat_(ref, NG_STATUS.TINJAU, NG_TAKPASTI); ngPeringatanPenjual_(r.kode); return; } } ngGagalMembuat_(ref, r.kode); } function ngEsc_(s) { return String(s).replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function ngIsiTeks_(teks, nilai) { return teks.replace(/\{([a-zA-Z]+)\}/g, function(_, k) { return String(nilai[k] === undefined ? '' : nilai[k]); }); } function ngWaktu_(date) { var zona = ngBerkas_().getSpreadsheetTimeZone(); var label = {'Asia/Jakarta': 'WIB', 'Asia/Pontianak': 'WIB', 'Asia/Makassar': 'WITA', 'Asia/Jayapura': 'WIT'}; return Utilities.formatDate(date, zona, "dd/MM/yyyy 'pukul' HH.mm") + ' ' + (label[zona] || zona); } function ngAmanGalat_(s) { var kunci = ngKunciApi_(); return (kunci ? String(s).split(kunci).join('[disamarkan]') : String(s)).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').slice(0, 80); } /** @param {*} blob */ function ngKirimEmail_(email, subject, lines, blob) { if (!ngAdaWaktu_()) { return 'Gagal: hasil kirim tidak diketahui'; } if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email)) { return 'Gagal: email pembeli kosong atau tidak sah'; } if (MailApp.getRemainingDailyQuota() < 1) { return 'Gagal: jatah email harian Google habis'; } lines = lines.map(function(line) { return line === 'Halo ,' ? 'Halo,' : line; }); var isi = lines.map(function(line) { return line === '{gambar}' ? 'Kode QR pembayaran' : '

' + ngEsc_(line) + '

'; }).join(''); /** @type {{to: string, name: string, subject: string, body: string, htmlBody: string, inlineImages?: Object, attachments?: GoogleAppsScript.Base.Blob[]}} */ var pesan = { to: email, name: ngSetelan_().toko.replace(/[\x00-\x1f\x7f-\x9f]/g, ' '), subject: subject.replace(/[\x00-\x1f\x7f-\x9f]/g, ' '), body: lines.filter(function(line) { return line !== '{gambar}'; }).join('\n'), htmlBody: '
' + isi + '
' }; if (blob) { pesan.inlineImages = {qris: blob}; pesan.attachments = [blob]; } try { MailApp.sendEmail(pesan); return 'Terkirim ' + Utilities.formatDate(new Date(ngSekarang_() * 1000), ngBerkas_().getSpreadsheetTimeZone(), 'dd/MM HH.mm'); } catch (e) { return 'Gagal: ' + ngAmanGalat_(e.message || 'email tidak terkirim'); } } function ngEmailNilai_(ref) { var r = ngBaris_(ref, null).nilai; var s = ngSetelan_(); return {no: ref, nama: ngNilai_(r, 'nama'), toko: s.toko, kontak: s.kontak, total: ngNilai_(r, 'total') ? ngRupiah_(Number(ngNilai_(r, 'total'))) : '', subtotal: ngNilai_(r, 'subtotal') ? ngRupiah_(Number(ngNilai_(r, 'subtotal'))) : '', kode: ngNilai_(r, 'kode') !== '' ? ngRupiah_(Number(ngNilai_(r, 'kode'))) : '', batas: ngDetik_(ngNilai_(r, 'batas')) ? ngWaktu_(ngNilai_(r, 'batas')) : '', email: String(ngNilai_(r, 'email')), rincian: String(ngNilai_(r, 'rincian'))}; } function ngEmailQr_(ref, png) { var n = ngEmailNilai_(ref); n.kontak = n.kontak ? ' atau hubungi ' + n.kontak : ''; var lines = NG_TEKS.qr.map(function(t) { return t === '{gambar}' ? t : ngIsiTeks_(t, n); }); var rincian; try { rincian = JSON.parse(ngNilai_(ngBaris_(ref, null).nilai, 'rincianEmail') || 'null'); } catch (e) { rincian = null; } if (!Array.isArray(rincian)) { rincian = n.rincian.split('; ').filter(function(t) { return !!t; }); } lines = lines.slice(0, 2).concat(rincian, lines.slice(2)); var blob; try { blob = Utilities.newBlob(Utilities.base64Decode(png), 'image/png', 'qris-' + ref + '.png'); } catch (e) { return 'Gagal: gambar QR tidak terbaca'; } return ngKirimEmail_(n.email, ngIsiTeks_(NG_TEKS.subjekQr, n), lines, blob); } function ngEmailLunas_(ref) { var n = ngEmailNilai_(ref); return ngKirimEmail_(n.email, ngIsiTeks_(NG_TEKS.subjekLunas, n), NG_TEKS.lunas.map(function(t) { return ngIsiTeks_(t, n); }), null); } function ngEmailGagal_(ref, kode) { var klaim = false; ngDenganKunci_(function() { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'emailGagal') || ngNilai_(b.nilai, 'qrPertama') || // Baris dari versi sebelum kolom "QR terkirim pertama" ada: bukti lamanya adalah kolom Email QR. String(ngNilai_(b.nilai, 'emailQr') || '').indexOf('Terkirim') === 0 || [NG_STATUS.LUNAS, NG_STATUS.SELISIH, NG_STATUS.MENUNGGU].includes(ngNilai_(b.nilai, 'status'))) { return; } ngTulis_(ref, NG_KOLOM.emailGagal, 'Mengirim…'); ngTulis_(ref, NG_KOLOM.emailQr, 'Mengirim…'); ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); klaim = true; }); if (!klaim) { return; } var n = ngEmailNilai_(ref); var alasan = NG_TEKS.alasanPembeli[kode] || NG_TEKS.alasanPembeli.lain; var lines = NG_TEKS.gagal.map(function(t) { return ngIsiTeks_(t, {nama: n.nama, toko: n.toko, alasanPembeli: alasan[0], lanjut: alasan[1]}); }); var hasil = ngKirimEmail_(n.email, ngIsiTeks_(NG_TEKS.subjekGagal, n), lines, null); ngDenganKunci_(function() { var b = ngBaris_(ref, null); if (b && ngNilai_(b.nilai, 'emailGagal') === 'Mengirim…') { ngTulis_(ref, NG_KOLOM.emailGagal, hasil); } }); ngCatatEmailQr_(ref, hasil, true); } function ngPeringatanPenjual_(kode) { kode = String(kode).replace(/[^a-z0-9_]/g, '').slice(0, 40) || 'kendala'; var key = 'NG_PERINGATAN_' + kode; var terakhir = Number(ngDok_().getProperty(key) || 0); if (ngSekarang_() - terakhir < 21600 || MailApp.getRemainingDailyQuota() < 6) { return; } ngDok_().setProperty(key, String(ngSekarang_())); ngKirimEmail_(Session.getEffectiveUser().getEmail(), NG_TEKS.peringatan, [ngAlasanAwam_(kode), ngBerkas_().getUrl()], null); } function ngTerapan_(ref, r, terpilih, olehPenjual) { var lock = LockService.getScriptLock(); if (!ngAdaWaktu_() || !lock.tryLock(ngAttente_(5000))) { return 0; } var hasil = 0; var berakhir = false; try { var sebelum = ngBaris_(ref, null); hasil = ngTerapanTerkunci_(ref, r, terpilih); var sesudah = ngBaris_(ref, null); berakhir = !olehPenjual && sebelum && sesudah && ngNilai_(sebelum.nilai, 'status') !== ngNilai_(sesudah.nilai, 'status') && [NG_STATUS.KEDALUWARSA, NG_STATUS.BATAL].includes(ngNilai_(sesudah.nilai, 'status')); } finally { ngLepasKunci_(lock); } if (berakhir) { ngEmailGagal_(ref, 'jaringan'); } if (hasil === 1) { ngKirimLunasTerklaim_(ref, false, true); } return hasil; } function ngTerapanTerkunci_(ref, r, terpilih) { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') === NG_STATUS.LUNAS || (!terpilih && ![NG_STATUS.MENUNGGU, NG_STATUS.TINJAU, NG_STATUS.KEDALUWARSA].includes(ngNilai_(b.nilai, 'status')))) { return 0; } if (!r.ok) { if (r.kode === 'not_found') { ngTulis_(ref, NG_KOLOM.status, NG_STATUS.BATAL); ngTulis_(ref, NG_KOLOM.catatan, 'Pesanan ini tidak ditemukan di akun NotivGo (akun atau kunci API berganti?).'); } return 0; } var d = r.data; if (terpilih && !ngNilai_(b.nilai, 'total')) { var kode = Number(d.amount) - Number(ngNilai_(b.nilai, 'subtotal')); if (d.order_ref !== ref || !ngNumerik_(d.amount) || !Number.isSafeInteger(Number(d.amount)) || !Number.isSafeInteger(kode) || kode < 0 || kode > 999 || Number(d.amount) > NG_MAKS_NOMINAL) { ngTulis_(ref, NG_KOLOM.catatan, ngAlasanAwam_('order_ref_conflict')); return 0; } ngTulisBanyak_(ref, NG_KOLOM.kode, [kode, Number(d.amount)]); b = ngBaris_(ref, null); } if (d.order_ref === ref && !ngNilai_(b.nilai, 'batas') && typeof d.expires_at === 'number' && Number.isFinite(d.expires_at) && d.expires_at > 0) { b.nilai[NG_KOLOM.batas - 1] = new Date(d.expires_at * 1000); ngTulis_(ref, NG_KOLOM.batas, b.nilai[NG_KOLOM.batas - 1]); } var hasil = d.order_ref === ref ? ngStatusDariJawaban_(d, ngNilai_(b.nilai, 'total')) : 'tidak_dikenal'; var total = ngRupiah_(Number(ngNilai_(b.nilai, 'total'))); if (hasil === 'lunas') { var rowLunas = b.nilai.slice(); rowLunas[NG_KOLOM.status - 1] = NG_STATUS.LUNAS; rowLunas[NG_KOLOM.dibayar - 1] = new Date((typeof d.paid_at === 'number' && Number.isFinite(d.paid_at) ? d.paid_at : ngSekarang_()) * 1000); rowLunas[NG_KOLOM.catatan - 1] = ''; var antreLunas = ngSetelan_().emailLunas && !ngNilai_(b.nilai, 'emailLunas'); if (antreLunas) { // Penghitung QR tidak mengurangi jatah pengulangan email lunas. rowLunas[NG_KOLOM.ulangEmail - 1] = 0; rowLunas[NG_KOLOM.emailLunas - 1] = 'Gagal: email lunas belum dikirim'; rowLunas[NG_KOLOM.emailPada - 1] = ngSekarang_(); rowLunas[NG_KOLOM.catatan - 1] = NG_EMAIL_LUNAS_GAGAL; } // Status, waktu lunas, dan pekerjaan email dipersistensikan sekaligus. ngTulisBanyak_(ref, NG_KOLOM.status, rowLunas.slice(NG_KOLOM.status - 1, NG_KOLOM.emailPada)); if (antreLunas) { ngBangunkanLunas_(); } return 1; } if (hasil === 'selisih') { ngTulis_(ref, NG_KOLOM.status, NG_STATUS.SELISIH); var nominalSah = ngNumerik_(d.amount) && Number.isSafeInteger(Number(d.amount)) && Number(d.amount) >= 0; ngTulis_(ref, NG_KOLOM.catatan, nominalSah ? 'Uang masuk ' + ngRupiah_(Number(d.amount)) + ' tercatat untuk pesanan ini, tagihannya ' + total + '. Periksa di portal NotivGo, menu Transaksi.' : 'Pesanan ditandai lunas dengan nominal yang berbeda dari tagihan ' + total + '. Periksa di portal NotivGo, menu Transaksi.'); } else if (hasil === 'tinjau') { ngTulis_(ref, NG_KOLOM.status, NG_STATUS.TINJAU); ngTulis_(ref, NG_KOLOM.catatan, 'Ada uang masuk yang perlu ditinjau. Buka portal NotivGo, menu Transaksi.'); } else if (hasil === 'menunggu' && (terpilih || ngNilai_(b.nilai, 'status') === NG_STATUS.TINJAU)) { ngTulis_(ref, NG_KOLOM.status, NG_STATUS.MENUNGGU); if (typeof d.expires_at === 'number' && Number.isFinite(d.expires_at)) { ngTulis_(ref, NG_KOLOM.batas, new Date(d.expires_at * 1000)); } ngTulis_(ref, NG_KOLOM.catatan, ''); if (!ngNilai_(b.nilai, 'emailQr')) { ngTulis_(ref, NG_KOLOM.emailQr, 'Gagal: QR belum terkirim'); ngTulis_(ref, NG_KOLOM.catatan, NG_EMAIL_GAGAL); ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); } } else if (hasil === 'kedaluwarsa') { ngTulis_(ref, NG_KOLOM.status, NG_STATUS.KEDALUWARSA); ngTulis_(ref, NG_KOLOM.catatan, 'Batas bayar terlewat.'); } else if (hasil === 'batal') { ngTulis_(ref, NG_KOLOM.status, NG_STATUS.BATAL); } else if (hasil === 'tidak_dikenal') { ngTulis_(ref, NG_KOLOM.catatan, 'Jawaban NotivGo tidak dikenali. Dicoba lagi otomatis.'); } return 0; } function ngPeriksaBerkala(e) { ngPeriksa_(false); } function ngPeriksa_(paksa) { var n = ngSekarang_(); var awal = ngJam_(); var hasil = {diperiksa: 0, lunas: 0}; if (!ngTerpasang_()) { return hasil; } var doc = ngDok_(); var generasi = doc.getProperty('NG_GEN') || '0'; var pulih = n - Number(doc.getProperty('NG_PULIH') || 0) >= 900; if (!paksa && !pulih && (doc.getProperty('NG_PANTAU') !== '1' || n < Number(doc.getProperty('NG_BERIKUT') || 0))) { return hasil; } var tokenPutaran = ''; if (!paksa) { var lockPutaran = LockService.getScriptLock(); if (!lockPutaran.tryLock(5000)) { return hasil; } try { // Bentuk angka dari versi lama tetap merupakan sewa yang sah. var sewa = doc.getProperty('NG_PUTARAN_SAMPAI') || '0'; if (n < Number(sewa.split(':')[0])) { return hasil; } tokenPutaran = String(n + 45) + ':' + Utilities.getUuid(); doc.setProperty('NG_PUTARAN_SAMPAI', tokenPutaran); } finally { ngLepasKunci_(lockPutaran); } } var sebelumnya = NG_SNAPSHOT; var tenggatLama = NG_TENGGAT; try { if (pulih || paksa) { doc.setProperty('NG_PULIH', String(n)); } doc.setProperty('NG_DETAK', String(n)); NG_TENGGAT = awal + 40000; var snapshot = ngPesanan_().getDataRange().getValues(); NG_SNAPSHOT = {rows: snapshot, indeks: {}, relu: false}; snapshot.forEach(function(r, i) { NG_SNAPSHOT.indeks[ngNilai_(r, 'ref')] = i + 1; }); ngPulihkanKlaimEmail_(); if (pulih || paksa) { ngPulihkanJawaban_(awal); } var rows = snapshot.slice(1); var membuat = rows.filter(function(r) { return ngNilai_(r, 'status') === NG_STATUS.MEMBUAT && n - ngDetik_(ngNilai_(r, 'dicek')) >= ngJedaMembuat_(r, n); }) .sort(function(a, b) { return ngDetik_(ngNilai_(a, 'dicek')) - ngDetik_(ngNilai_(b, 'dicek')); }).slice(0, 5); var cek = rows.filter(function(r) { return ngPerluDicek_(ngNilai_(r, 'status'), ngDetik_(ngNilai_(r, 'batas')), ngDetik_(ngNilai_(r, 'dicek')), n); }).sort(function(a, b) { return ngDetik_(ngNilai_(a, 'dicek')) - ngDetik_(ngNilai_(b, 'dicek')); }).slice(0, 20); if (!ngAdaWaktu_()) { return hasil; } var batch = [], dikunci = false; ngDenganKunci_(function() { dikunci = true; cek.forEach(function(r) { var nomor = snapshot.indexOf(r) + 1; if (!nomor || ngPesanan_().getRange(nomor, NG_KOLOM.ref).getValue() !== ngNilai_(r, 'ref')) { return; } ngPesanan_().getRange(nomor, NG_KOLOM.dicek).setValue(new Date(n * 1000)); batch.push(r); }); }); if (!dikunci) { return hasil; } cek = batch; var balasan = ngApiBanyak_(cek.map(function(r) { return {metode: 'GET', jalur: '/v1/orders/' + encodeURIComponent(ngNilai_(r, 'ref')), isi: null}; })); var tolak = false; for (var i = 0; i < balasan.length; i++) { if (ngJam_() - awal > 40000) { break; } if (balasan[i].kode === 'unauthorized') { ngPeringatanPenjual_('unauthorized'); tolak = true; break; } hasil.diperiksa++; hasil.lunas += ngTerapan_(ngNilai_(cek[i], 'ref'), balasan[i]); } if (!tolak) { for (var r of membuat) { if (ngJam_() - awal > 40000) { break; } ngLanjutkanBaris_(ngNilai_(r, 'ref')); } } if (!tolak) { var sheetEmail = ngPesanan_(); var rowsEmail = sheetEmail.getLastRow() > 1 ? sheetEmail.getRange(2, 1, sheetEmail.getLastRow() - 1, NG_KEPALA.length).getValues() : []; var lunasAktif = ngSetelan_().emailLunas; var ulang = rowsEmail.filter(function(r) { return ngEmailPerluUlang_(r, n) || (lunasAktif && ngLunasPerluUlang_(r, n)); }).slice(0, 3); for (var emailRow of ulang) { if (ngJam_() - awal > 40000 || MailApp.getRemainingDailyQuota() < 1) { break; } if (ngNilai_(emailRow, 'status') === NG_STATUS.LUNAS) { ngKirimLunasTerklaim_(ngNilai_(emailRow, 'ref'), false, false); } else { ngUlangEmailOtomatis_(ngNilai_(emailRow, 'ref')); } } } var berikut = 0; // NG_GEN tetap berpijak pada awal putaran: handler baru tidak boleh tertimpa. // Email lunas memiliki generasi sendiri dan sudah tercakup dalam pembacaan akhir. var generasiLunas = doc.getProperty('NG_LUNAS_GEN') || '0'; var sheetAkhir = ngPesanan_(); var rowsAkhir = sheetAkhir.getLastRow() > 1 ? sheetAkhir.getRange(2, 1, sheetAkhir.getLastRow() - 1, NG_KEPALA.length).getValues() : []; var pantauLunas = ngSetelan_().emailLunas; rowsAkhir.forEach(function(r) { var status = ngNilai_(r, 'status'); var waktu = 0; if (status === NG_STATUS.MEMBUAT) { waktu = ngDetik_(ngNilai_(r, 'dicek')) + ngJedaMembuat_(r, n); } else if ([NG_STATUS.MENUNGGU, NG_STATUS.TINJAU].includes(status)) { waktu = n; } else if (status === NG_STATUS.KEDALUWARSA && n <= ngDetik_(ngNilai_(r, 'batas')) + NG_PANTAU_KEDALUWARSA_DETIK) { waktu = ngDetik_(ngNilai_(r, 'dicek')) + NG_JEDA_KEDALUWARSA_DETIK; } if (waktu && (!berikut || waktu < berikut)) { berikut = waktu; } if (pantauLunas && ngLunasKosongBaru_(r, n)) { // Sisa rekonsiliasi mengikuti jeda email lunas, bukan pembacaan tiap menit. var rekonBerikut = n + 600; if (!berikut || rekonBerikut < berikut) { berikut = rekonBerikut; } } if (pantauLunas && ngLunasTertunda_(r)) { // Pertahankan tenggat sejak percobaan terakhir meski diperiksa lebih awal. var emailBerikut = Number(ngNilai_(r, 'emailPada') || 0) + 600; // Bila sudah jatuh tempo tetapi masih tertunda (misalnya kuota habis), // tunggu 10 menit lagi agar tidak membaca seluruh Pesanan setiap menit. if (emailBerikut <= n) { emailBerikut = n + 600; } if (!berikut || emailBerikut < berikut) { berikut = emailBerikut; } } }); ngDenganKunci_(function() { if ((doc.getProperty('NG_GEN') || '0') !== generasi || (doc.getProperty('NG_LUNAS_GEN') || '0') !== generasiLunas) { berikut = ngSekarang_(); } doc.setProperty('NG_PANTAU', berikut ? '1' : '0').setProperty('NG_BERIKUT', berikut ? String(berikut) : ''); }); return hasil; } finally { NG_SNAPSHOT = sebelumnya; NG_TENGGAT = tenggatLama; // Sewa hanya melindungi putaran yang masih berjalan, bukan menunda jadwal berikut. // Jangan menghapus sewa putaran lain yang telah mengambil alih. if (tokenPutaran) { var lockLepasPutaran = LockService.getScriptLock(); if (lockLepasPutaran.tryLock(5000)) { try { if (doc.getProperty('NG_PUTARAN_SAMPAI') === tokenPutaran) { doc.deleteProperty('NG_PUTARAN_SAMPAI'); } } finally { ngLepasKunci_(lockLepasPutaran); } } } } } function ngMenuPeriksa() { if (!ngWajibPasang_()) { return; } var ss = ngBerkas_(); var range = ss.getActiveRange(); var ref = ''; if (ss.getActiveSheet().getName() === 'Pesanan' && range && range.getRow() >= 2) { ref = String(ngPesanan_().getRange(range.getRow(), NG_KOLOM.ref).getValue() || ''); } var h = ngPeriksa_(true); var b = ref ? ngBaris_(ref, null) : null; if (b && ngNilai_(b.nilai, 'status') !== NG_STATUS.LUNAS) { var pesanan = ngApiPesanan_(ref); if (!ngNilai_(b.nilai, 'total') && !pesanan.ok && pesanan.kode === 'not_found') { ngBerkas_().toast('Pesanan ' + ref + ': belum ada di NotivGo'); return; } ngTerapan_(ref, pesanan, true); b = ngBaris_(ref, null); } ngBerkas_().toast(b ? 'Pesanan ' + ref + ': ' + ngNilai_(b.nilai, 'status') : 'Diperiksa ' + h.diperiksa + ' pesanan, ' + h.lunas + ' lunas.'); } function ngTerpilih_() { var ss = ngBerkas_(); var range = ss.getActiveRange(); if (ss.getActiveSheet().getName() === 'Pesanan' && range && range.getRow() >= 2) { var ref = String(ngPesanan_().getRange(range.getRow(), NG_KOLOM.ref).getValue() || ''); if (ref) { return ngBaris_(ref, null); } } SpreadsheetApp.getUi().alert('Pilih dulu satu baris pesanan di sheet Pesanan.'); return null; } function ngMenuKirimUlang() { if (!ngWajibPasang_()) { return; } var pilihan = ngTerpilih_(); if (!pilihan) { return; } var ref = ngNilai_(pilihan.nilai, 'ref'); if (ngNilai_(pilihan.nilai, 'status') === NG_STATUS.LUNAS) { ngKirimLunasTerklaim_(ref, true, false); return; } var b = ngMenuBacaUlang_(ref); if (!b) { return; } var status = ngNilai_(b.nilai, 'status'); if (status === NG_STATUS.MEMBUAT) { ngLanjutkanBaris_(ref); return; } if (status === NG_STATUS.GAGAL) { var pesanan = ngNilai_(b.nilai, 'total') ? ngApiPesanan_(ref) : null; var lanjut = false, terapkan = false; ngDenganKunci_(function() { b = ngBaris_(ref, null); if (!ngMenuBoleh_(b)) { return; } if (![NG_STATUS.MEMBUAT, NG_STATUS.GAGAL].includes(ngNilai_(b.nilai, 'status'))) { return; } if (pesanan) { if (pesanan.ok) { if (pesanan.data.order_ref !== ref) { SpreadsheetApp.getUi().alert(ngAlasanAwam_('invalid_response')); return; } if (pesanan.data.status === 'open' && Number(pesanan.data.amount) !== Number(ngNilai_(b.nilai, 'total'))) { SpreadsheetApp.getUi().alert(ngAlasanAwam_('order_ref_conflict')); return; } if (pesanan.data.status !== 'open') { terapkan = true; return; } } else if (pesanan.kode === 'not_found') { ngTulis_(ref, NG_KOLOM.kode, ''); ngTulis_(ref, NG_KOLOM.total, ''); ngTulis_(ref, NG_KOLOM.durasi, ''); } else { SpreadsheetApp.getUi().alert(ngAlasanAwam_(pesanan.kode, pesanan.ulang)); return; } } ngTulis_(ref, NG_KOLOM.status, NG_STATUS.MEMBUAT); ngTulis_(ref, NG_KOLOM.percobaan, 0); ngTulis_(ref, NG_KOLOM.emailQr, ''); ngDok_().setProperty('NG_PANTAU', '1').setProperty('NG_BERIKUT', String(ngSekarang_())); ngPekerjaanBaru_(); lanjut = true; }); if (terapkan) { ngTerapan_(ref, pesanan, true); } if (lanjut) { ngLanjutkanBaris_(ref); } } else if (status === NG_STATUS.MENUNGGU && ngDetik_(ngNilai_(b.nilai, 'batas')) > ngSekarang_()) { var r = ngApiBuatQr_(ref, Number(ngNilai_(b.nilai, 'total')), ngDurasi_(ref)); if (!ngEmailPesananTerbuka_(ref)) { return; } var kirim = false; ngDenganKunci_(function() { b = ngBaris_(ref, null); if (!ngMenuBoleh_(b) || ngNilai_(b.nilai, 'status') !== NG_STATUS.MENUNGGU) { return; } if (ngKlaimQrAktif_(b.nilai)) { SpreadsheetApp.getUi().alert('Pesanan ini sedang diproses. Coba lagi sebentar.'); return; } if (r.ok && r.data.order_ref === ref && typeof r.data.qris_png_base64 === 'string' && r.data.qris_png_base64.trim()) { if (ngDetik_(ngNilai_(b.nilai, 'batas')) <= ngSekarang_()) { return; } ngTulis_(ref, NG_KOLOM.emailQr, 'Mengirim…'); ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); ngPekerjaanBaru_(); kirim = true; } else { SpreadsheetApp.getUi().alert(ngAlasanAwam_(r.kode || 'invalid_response', r.ulang)); } }); if (kirim) { ngCatatEmailQr_(ref, ngEmailQr_(ref, r.data.qris_png_base64)); } } else { SpreadsheetApp.getUi().alert('Pesanan berstatus "' + status + '" tidak bisa dikirimi QR lagi.'); } } function ngDenganKunci_(aksi) { var lock = LockService.getScriptLock(); if (!ngAdaWaktu_() || !lock.tryLock(ngAttente_(5000))) { return; } var potret = NG_SNAPSHOT; NG_SNAPSHOT = null; try { aksi(); } finally { NG_SNAPSHOT = potret; ngLepasKunci_(lock); } } function ngMenuBoleh_(b) { if (!b) { return false; } if (ngNilai_(b.nilai, 'status') === NG_STATUS.LUNAS) { SpreadsheetApp.getUi().alert('Pesanan berstatus "Lunas" tidak bisa dikirimi QR lagi.'); return false; } if (Number(ngNilai_(b.nilai, 'sibuk') || 0) > ngSekarang_()) { SpreadsheetApp.getUi().alert('Pesanan ini sedang diproses. Coba lagi sebentar.'); return false; } return true; } function ngMenuBacaUlang_(ref) { var b = null; ngDenganKunci_(function() { var baru = ngBaris_(ref, null); if (ngMenuBoleh_(baru)) { b = baru; } }); return b; } function ngMenuBatalkan() { if (!ngWajibPasang_()) { return; } var b = ngTerpilih_(); if (!b) { return; } var ref = ngNilai_(b.nilai, 'ref'); var status = ngNilai_(b.nilai, 'status'); var ui = SpreadsheetApp.getUi(); if (![NG_STATUS.MENUNGGU, NG_STATUS.TINJAU].includes(status)) { ui.alert('Pesanan berstatus "' + status + '" tidak bisa dibatalkan.'); return; } if (ui.alert('Batalkan pesanan?', ref, ui.ButtonSet.YES_NO) !== ui.Button.YES) { return; } var r = ngApiBatal_(ref); if (r.ok) { ngTerapan_(ref, {ok: true, data: {order_ref: ref, status: 'canceled'}}, false, true); } else if (r.kode === 'not_cancelable') { ngTerapan_(ref, ngApiPesanan_(ref), false, true); } else { ui.alert(ngAlasanAwam_(r.kode, r.ulang)); } } function ngApiAkunPesanan_(kunci, ref) { try { var opsi = ngPermintaan_('GET', '/v1/orders/' + encodeURIComponent(ref), null); opsi.headers = {Authorization: 'Bearer ' + kunci, Accept: 'application/json'}; var r = ngBacaBalasan_(UrlFetchApp.fetch(opsi.url, opsi), kunci); return {ok: r.ok, kode: String(r.kode).split(kunci).join('').replace(/[^a-z0-9_]/g, '').slice(0, 40)}; } catch (e) { return {ok: false, kode: 'jaringan'}; } } function ngPulihkanJawaban_(awal) { var sejak = Number(ngDok_().getProperty('NG_REKON') || 0); var responses; if (!sejak || !ngAdaWaktu_()) { return; } try { responses = ngForm_().getResponses(new Date((sejak - 120) * 1000)); } catch (e) { return; } responses.sort(function(a, b) { return a.getTimestamp().getTime() - b.getTimestamp().getTime(); }); var terakhir = sejak; var diproses = 0; for (var response of responses) { if (ngJam_() - awal > 40000 || diproses >= 30) { break; } var id = String(response.getId()); if (ngDetik_(response.getTimestamp()) < sejak || ngSudahDiproses_(id)) { continue; } if (!ngBaris_(null, id)) { ngProsesJawaban_(response, true); diproses++; } if (!ngBaris_(null, id)) { break; } terakhir = Math.max(terakhir, ngDetik_(response.getTimestamp())); } if (terakhir) { ngDok_().setProperty('NG_REKON', String(terakhir)); } } function ngCatatEmailQr_(ref, hasil, bukanQr) { ngDenganKunci_(function() { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'emailQr') !== 'Mengirim…') { return; } ngTulis_(ref, NG_KOLOM.emailQr, hasil); if (!bukanQr && hasil.indexOf('Terkirim') === 0 && !ngNilai_(b.nilai, 'qrPertama')) { ngTulis_(ref, NG_KOLOM.qrPertama, new Date(ngSekarang_() * 1000)); } ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); if (ngNilai_(b.nilai, 'status') !== NG_STATUS.MENUNGGU) { return; } if (hasil.indexOf('Gagal') === 0) { ngTulis_(ref, NG_KOLOM.catatan, NG_EMAIL_GAGAL); } else if (ngNilai_(b.nilai, 'catatan') === NG_EMAIL_GAGAL) { ngTulis_(ref, NG_KOLOM.catatan, ''); } }); } function ngLunasTertunda_(r) { var hasil = String(ngNilai_(r, 'emailLunas') || ''); return ngNilai_(r, 'status') === NG_STATUS.LUNAS && (hasil === 'Mengirim…' || hasil.indexOf('Gagal') === 0) && hasil !== 'Gagal: email pembeli kosong atau tidak sah' && Number(ngNilai_(r, 'ulangEmail') || 0) < 3; } function ngLunasPerluUlang_(r, n) { return ngLunasTertunda_(r) && String(ngNilai_(r, 'emailLunas')).indexOf('Gagal') === 0 && n - Number(ngNilai_(r, 'emailPada') || 0) >= 600; } /** * Klaim, pengiriman, dan pencatatan hasil berada dalam satu kunci. * Pemulihan tidak bisa mengambil klaim ketika MailApp masih berjalan. * @param {string} ref @param {boolean} manual @param {boolean} pertama */ function ngKirimLunasTerklaim_(ref, manual, pertama) { ngDenganKunci_(function() { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') !== NG_STATUS.LUNAS || !ngSetelan_().emailLunas) { return; } var hasilLama = String(ngNilai_(b.nilai, 'emailLunas') || ''); // Menu pun tidak mengulang email yang sudah berhasil. if (hasilLama.indexOf('Terkirim') === 0) { return; } if (hasilLama === 'Mengirim…' && ngSekarang_() - Number(ngNilai_(b.nilai, 'emailPada') || 0) <= 300) { return; } if (!manual && !pertama && !ngLunasPerluUlang_(b.nilai, ngSekarang_())) { return; } if (pertama && hasilLama !== 'Gagal: email lunas belum dikirim') { return; } if (MailApp.getRemainingDailyQuota() < 1) { ngTulis_(ref, NG_KOLOM.emailLunas, 'Gagal: jatah email harian Google habis'); ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); ngTulis_(ref, NG_KOLOM.catatan, NG_EMAIL_LUNAS_GAGAL); ngBangunkanLunas_(); return; } if (!manual && !pertama) { ngTulis_(ref, NG_KOLOM.ulangEmail, Number(ngNilai_(b.nilai, 'ulangEmail') || 0) + 1); } ngTulis_(ref, NG_KOLOM.emailLunas, 'Mengirim…'); ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); ngTulis_(ref, NG_KOLOM.catatan, NG_EMAIL_LUNAS_GAGAL); ngBangunkanLunas_(); // Flush la klaim sebelum memanggil layanan email. SpreadsheetApp.flush(); var hasil; try { hasil = ngEmailLunas_(ref); } catch (e) { hasil = 'Gagal: ' + ngAmanGalat_(e.message || 'email tidak terkirim'); } ngTulis_(ref, NG_KOLOM.emailLunas, hasil); ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); if (hasil.indexOf('Terkirim') === 0) { ngTulis_(ref, NG_KOLOM.catatan, ngNilai_(b.nilai, 'catatanAwal') || ''); } else { ngTulis_(ref, NG_KOLOM.catatan, NG_EMAIL_LUNAS_GAGAL); } }); } function ngKlaimQrAktif_(r) { return ngNilai_(r, 'emailQr') === 'Mengirim…' && ngSekarang_() - Number(ngNilai_(r, 'emailPada') || 0) <= 300; } function ngEmailPerluUlang_(r, n) { var hasil = String(ngNilai_(r, 'emailQr') || ''); return ngNilai_(r, 'status') === NG_STATUS.MENUNGGU && ngDetik_(ngNilai_(r, 'batas')) > n && hasil.indexOf('Gagal') === 0 && hasil !== 'Gagal: email pembeli kosong atau tidak sah' && Number(ngNilai_(r, 'ulangEmail') || 0) < 3 && n - Number(ngNilai_(r, 'emailPada') || 0) >= 600; } function ngUlangEmailOtomatis_(ref) { var awal = ngMenuBacaUlang_(ref); if (!awal || !ngEmailPerluUlang_(awal.nilai, ngSekarang_()) || MailApp.getRemainingDailyQuota() < 1) { return; } var r = ngApiBuatQr_(ref, Number(ngNilai_(awal.nilai, 'total')), ngDurasi_(ref)); if (!ngEmailPesananTerbuka_(ref)) { return; } var lock = LockService.getScriptLock(); if (!ngAdaWaktu_() || !lock.tryLock(ngAttente_(5000))) { return; } var b; try { b = ngBaris_(ref, null); if (!b || !ngEmailPerluUlang_(b.nilai, ngSekarang_()) || MailApp.getRemainingDailyQuota() < 1) { return; } ngTulis_(ref, NG_KOLOM.ulangEmail, Number(ngNilai_(b.nilai, 'ulangEmail') || 0) + 1); ngTulis_(ref, NG_KOLOM.emailPada, ngSekarang_()); ngTulis_(ref, NG_KOLOM.emailQr, 'Mengirim…'); } finally { ngLepasKunci_(lock); } var hasil = 'Gagal: ' + ngAlasanAwam_(r.kode || 'invalid_response', r.ulang); b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') !== NG_STATUS.MENUNGGU) { return; } if (r.ok && r.data.order_ref === ref && typeof r.data.qris_png_base64 === 'string' && r.data.qris_png_base64.trim()) { hasil = ngEmailQr_(ref, r.data.qris_png_base64); } ngCatatEmailQr_(ref, hasil); } function ngKesehatanApi_(ok, kode) { if (ok) { ngDok_().setProperty('NG_SUKSES', String(ngSekarang_())); } else { kode = ngAmanGalat_(kode).replace(/[^a-z0-9_]/g, '').slice(0, 40); ngDok_().setProperty('NG_GALAT', kode + '|' + ngSekarang_()); } } function ngTanggalSehat_(detik) { return Number(detik) > 0 ? Utilities.formatDate(new Date(Number(detik) * 1000), ngBerkas_().getSpreadsheetTimeZone(), 'dd/MM HH.mm') : 'belum pernah'; } function ngKesehatanMenu_(menerima) { var doc = ngDok_(); var galat = (doc.getProperty('NG_GALAT') || '').split('|'); var rows = ngPesanan_().getDataRange().getValues().slice(1); var lines = ['Pemicu terakhir berjalan: ' + ngTanggalSehat_(doc.getProperty('NG_DETAK')), 'Terakhir tersambung ke NotivGo: ' + ngTanggalSehat_(doc.getProperty('NG_SUKSES'))]; if (Number(galat[1]) > Number(doc.getProperty('NG_SUKSES') || 0)) { lines.push('Galat terakhir: ' + ngAlasanAwam_(galat[0]) + ' (' + ngTanggalSehat_(galat[1]) + ')'); } return lines.concat(['Formulir menerima jawaban: ' + (menerima ? 'ya' : 'tidak'), 'Pesanan menunggu QR: ' + rows.filter(function(r) { return ngNilai_(r, 'status') === NG_STATUS.MEMBUAT; }).length, 'Pesanan menunggu pembayaran: ' + rows.filter(function(r) { return ngNilai_(r, 'status') === NG_STATUS.MENUNGGU; }).length]); } function ngSidikJawaban_(id) { return Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, id).map(function(b) { return ('0' + ((b + 256) % 256).toString(16)).slice(-2); }).join('').slice(0, 12); } function ngSudahDiproses_(id) { return (ngDok_().getProperty('NG_DIPROSES') || '').split(',').includes(ngSidikJawaban_(id)); } function ngCatatDiproses_(id) { var sidik = ngSidikJawaban_(id); var daftar = (ngDok_().getProperty('NG_DIPROSES') || '').split(',').filter(function(s) { return s && s !== sidik; }); ngDok_().setProperty('NG_DIPROSES', daftar.concat([sidik]).slice(-300).join(',')); } function ngEmailPesananTerbuka_(ref) { var r = ngApiPesanan_(ref); if (!r.ok) { return false; } if (r.data.status !== 'open') { ngTerapan_(ref, r, true); return false; } var boleh = false; ngDenganKunci_(function() { var b = ngBaris_(ref, null); if (!b || ngNilai_(b.nilai, 'status') !== NG_STATUS.MENUNGGU) { return; } if (r.data.order_ref !== ref || Number(r.data.amount) !== Number(ngNilai_(b.nilai, 'total'))) { ngTulis_(ref, NG_KOLOM.catatan, ngAlasanAwam_('order_ref_conflict')); return; } boleh = Number(r.data.expires_at) > ngSekarang_() && ngDetik_(ngNilai_(b.nilai, 'batas')) > ngSekarang_(); }); return boleh; } function ngLunasKosongBaru_(r, n) { var dibayar = ngDetik_(ngNilai_(r, 'dibayar')); var umur = n - dibayar; return ngNilai_(r, 'status') === NG_STATUS.LUNAS && !ngNilai_(r, 'emailLunas') && dibayar > 0 && umur >= 0 && umur <= 86400; } function ngPulihkanKlaimEmail_() { ngDenganKunci_(function() { var sheet = ngPesanan_(); var rows = sheet.getLastRow() > 1 ? sheet.getRange(2, 1, sheet.getLastRow() - 1, NG_KEPALA.length).getValues() : []; var lunasAktif = ngSetelan_().emailLunas; var dipulihkan = 0; for (var i = 0; i < rows.length; i++) { if (!ngAdaWaktu_()) { break; } var r = rows[i]; if (lunasAktif && ngLunasKosongBaru_(r, ngSekarang_())) { if (dipulihkan >= 5) { continue; } // Potret dibaca di bawah kunci yang sama; nomor baris tetap diverifikasi. var pulihLunas = r.slice(); pulihLunas[NG_KOLOM.emailLunas - 1] = 'Gagal: email lunas belum dikirim'; pulihLunas[NG_KOLOM.ulangEmail - 1] = 0; pulihLunas[NG_KOLOM.emailPada - 1] = ngSekarang_() - 600; pulihLunas[NG_KOLOM.catatan - 1] = NG_EMAIL_LUNAS_GAGAL; ngTulisBanyak_(ngNilai_(r, 'ref'), NG_KOLOM.catatan, pulihLunas.slice(NG_KOLOM.catatan - 1, NG_KOLOM.emailPada), i + 2); dipulihkan++; ngBangunkanLunas_(); continue; } if (ngSekarang_() - Number(ngNilai_(r, 'emailPada') || 0) <= 300) { continue; } ['emailQr', 'emailLunas'].forEach(function(k) { if (ngNilai_(r, k) === 'Mengirim…') { ngTulis_(ngNilai_(r, 'ref'), NG_KOLOM[k], 'Gagal: hasil kirim tidak diketahui'); if (k === 'emailLunas' && ngNilai_(r, 'status') === NG_STATUS.LUNAS) { ngTulis_(ngNilai_(r, 'ref'), NG_KOLOM.catatan, NG_EMAIL_LUNAS_GAGAL); } } }); } }); } var NG_TENGGAT = 0; function ngAdaWaktu_() { return !NG_TENGGAT || ngJam_() < NG_TENGGAT; } function ngAttente_(bawaan) { return NG_TENGGAT ? Math.max(0, Math.min(5000, NG_TENGGAT - ngJam_())) : bawaan; } function ngBangunkanLunas_() { var doc = ngDok_(); doc.setProperty('NG_LUNAS_GEN', String(Number(doc.getProperty('NG_LUNAS_GEN') || 0) + 1)) .setProperty('NG_PANTAU', '1').setProperty('NG_BERIKUT', String(ngSekarang_())); } function ngPekerjaanBaru_() { ngDok_().setProperty('NG_GEN', String(Number(ngDok_().getProperty('NG_GEN') || 0) + 1)) .setProperty('NG_PANTAU', '1').setProperty('NG_BERIKUT', String(ngSekarang_())); } function ngLepasKunci_(lock) { try { SpreadsheetApp.flush(); } finally { lock.releaseLock(); } }