Reverse Engineering Document

EcoBank
Bank Sampah Digital

Arsitektur lengkap sistem EcoBank v2 — mencakup application logic, API layer, database schema, dan Entity Relationship Diagram.

Tech Stack
HTML · CSS · JavaScript · Google Apps Script
Database
Google Sheets — 9 sheets
Deployment
Static HTML + GAS Web App (serverless)
Versi
v2 — KursNasabah · KursPelapak · Admin via role
01

System Overview

EcoBank adalah platform bank sampah digital yang berjalan sepenuhnya sebagai file HTML statis di browser, dibackup oleh Google Apps Script (GAS) web app yang menggunakan Google Sheets sebagai database relasional. Tidak ada server tradisional, tidak ada biaya hosting selain akun Google.

Arsitektur Tiga Lapisan

LayerTeknologiPeran
FrontendSingle HTML file (vanilla JS)Seluruh UI, state management, optimistic updates
APIGoogle Apps Script Web AppHTTP endpoints: doGet (baca publik) + doPost (tulis + autentikasi)
DatabaseGoogle Sheets — 9 sheetPenyimpanan data tabular persisten; tanpa SQL engine

User Roles

RoleDashboardAkses
Nasabahdash-nasabahSaldo · Buku Tabungan · Riwayat Timbangan · Tarik Saldo · Harga
Admindash-adminTimbangan · Jual ke Pelapak · Kelola Member · Kurs · Laporan · Kas · Error Log
Pelapakdash-pelapakRiwayat pembelian · Harga per-pelapak
02

Authentication & Security

v2 Security Model: Semua backdoor PIN script-level (token "1234" untuk admin) telah dihapus. Akses admin hanya melalui akun Nasabah dengan kolom role = 'admin' di sheet.

Login Flow

1 Pilih role card
2 Input identifier + token
3 POST login ke GAS
4 GAS verifikasi sheet
5 Frontend baca res.user.role
6 POST getAll (re-validasi token)

Aturan Keamanan

  • Routing dashboard ditentukan oleh res.user.role (server-authoritative) — bukan oleh kartu login yang diklik user.
  • Admin-role Nasabah diblokir dari path login nasabah (dan sebaliknya) — GAS menolak di dua jalur.
  • Token tidak pernah disimpan di object D setelah login — hanya ada dalam scope dua-call handshake.
  • getAll memvalidasi ulang userId + token + role terhadap sheet di setiap panggilan.
  • Semua percobaan login admin yang gagal ditulis ke sheet ErrorLog.
  • ADMIN_TOKEN Script Property dihapus — tidak ada fallback PIN yang tersisa.

Token Lifecycle

// 1. Login call POST {action:'login', role, identifier, token} → GAS verifikasi sheet → return {user: {id, nama, role, ...}} ← token TIDAK dikembalikan // 2. getAll call (token re-validated server-side) POST {action:'getAll', role, userId, token} → GAS: cari row dimana id===userId AND token===token AND role===admin → return seluruh dataset // 3. Token dibuang — tidak pernah masuk D cache D.user = res.user ← hanya data publik, tanpa token
03

Data Flow & State Model

Setelah login, seluruh dataset (6 tabel data, token di-strip) diunduh ke satu JavaScript object D di memori browser. Semua operasi baca selama sesi membaca dari D — tidak ada network call tambahan. Semua operasi tulis menggunakan pola optimistic update tiga fase.

Object D — Single Source of Truth

FieldTipeIsi
rolestring|null'nasabah' | 'admin' | 'pelapak' | null
userobject|nullObjek user aktif (tanpa token)
nextIdnumberCounter untuk temporary ID (tmp-N)
jenisarraySemua JenisSampah: id, nama, warna
kursNasabaharraySemua KursNasabah: versioned rate per jenis
kursPelapakarraySemua KursPelapak: rate per pelapak per jenis
nasabaharraySemua Nasabah: id, nama, email, phone, saldo, role
pelapakarraySemua Pelapak: id, nama, email, phone
timbangarrayTimbangan: nsId, tgl, jId, kg, kredit, kursSnapshot
jualarrayPenjualanPelapak: plId, tgl, jId, kg, total, kursSnapshot
tarikarrayPenarikan: nsId, tgl, jml, metode, status

Optimistic Update Pattern

  1. Mutasi lokal langsung — record baru di-push ke D dengan temporary ID tmp-N. UI re-render instan, zero perceived latency.
  2. Background POST — data dikirim ke GAS secara asynchronous. User bisa terus bekerja.
  3. Rekonsiliasi server — jika sukses: ID nyata menggantikan tmp-N. Jika gagal: mutasi lokal di-rollback penuh, error toast ditampilkan.
04

Business Logic Rules

Timbangan (Penimbangan Sampah)

  • Admin memilih Nasabah + tanggal, memasukkan berat per jenis sampah.
  • kredit = Math.round(beratKg × kursNasabah_aktif) — rate diambil dari KursNasabah berdasarkan tanggal transaksi.
  • kursSnapshot disimpan di baris Timbangan — perubahan harga tidak mempengaruhi catatan historis.
  • Nasabah.saldo bertambah secara atomic melalui _updateSaldo().

PenjualanPelapak

  • Admin memilih Pelapak + tanggal, memasukkan berat per jenis sampah.
  • total = Math.round(beratKg × kursPelapak_aktif) — rate per-pelapak diambil dari KursPelapak.
  • Setiap pelapak dapat memiliki harga berbeda untuk jenis sampah yang sama — selisih adalah margin bank.

Penarikan (Withdrawal)

  • Nasabah memasukkan jumlah (min Rp 10.000) dan metode pencairan.
  • GAS memvalidasi saldo >= jumlah sebelum menulis. Gagal → rollback client-side.
  • v2: Status awal adalah 'Menunggu' (bukan langsung 'Selesai') — admin perlu mengkonfirmasi pencairan.

Kurs Versioning

  • Update harga = tutup baris aktif (berlakuSampai = kemarin) + insert baris baru.
  • Riwayat lengkap tersimpan selamanya — audit trail setiap perubahan harga.
  • Lookup: berlakuDari <= tglTx AND (berlakuSampai kosong OR berlakuSampai >= tglTx).

Cascading Deletes

  • Hapus Nasabah → hapus semua Timbangan + Penarikan milik nasabah tersebut.
  • Hapus Pelapak → hapus semua PenjualanPelapak + KursPelapak milik pelapak tersebut.
  • Hapus JenisSampah → hapus semua Timbangan, PenjualanPelapak, KursNasabah, KursPelapak untuk jenis tersebut.
  • Semua cascade mengiterasi sheet dari baris terakhir ke atas untuk menjaga indeks baris.
05

API Layer — Google Apps Script

GAS mengekspos satu URL deployment. HTTP method menentukan tipe action: GET untuk query read-only publik, POST untuk semua write dan data-fetch yang memerlukan autentikasi. Setiap response adalah JSON dengan field status: 'ok' | 'error'.

GET Actions (doGet)

ActionAuthReturns
getStatsPubliknasabahCount, pelapakCount, totalKg — untuk landing page hero

POST Actions (doPost)

ActionAuthDeskripsi
logintokenVerifikasi identifier + token. Return user object (tanpa token). Admin harus punya role='admin' di sheet Nasabah.
getAlluserId + tokenReturn semua 6 tabel data. Re-validasi token sebelum respond.
saveTimbangansessionTulis N baris Timbangan. Update Nasabah.saldo. Return {ids, totalKredit, saldoBaru}.
saveJualsessionTulis N baris PenjualanPelapak. Ambil kurs dari KursPelapak. Return {ids}.
tarikSaldosessionValidasi saldo, tulis Penarikan (status:'Menunggu'), kurangi saldo. Return {id, saldoBaru}.
saveKursNasabahadminTutup baris kursNasabah aktif untuk jenis ini, insert baris baru berlaku hari ini.
saveKursPelapakadminTutup baris kursPelapak aktif untuk pelapak+jenis ini, insert baris baru.
addNasabahadminAppend ke sheet Nasabah. Default token '1234'. Kolom role opsional.
deleteNasabahadminCascade: hapus Nasabah + Timbangan + Penarikan.
addPelapakadminAppend ke sheet Pelapak. Auto-buat baris KursPelapak kosong untuk semua jenis.
deletePelapakadminCascade: hapus Pelapak + PenjualanPelapak + KursPelapak.
addJenisadminAppend ke JenisSampah. Auto-buat KursNasabah awal + KursPelapak untuk semua pelapak.
deleteJenisadminCascade: hapus JenisSampah + Timbangan + PenjualanPelapak + KursNasabah + KursPelapak.
getErrorsadminReturn 100 baris ErrorLog terbaru, terbaru pertama.
logErrornoneTulis satu baris ke ErrorLog. Dipanggil dari window.onerror client-side.

Response Envelope

// Sukses { "status": "ok", ...payload } // Gagal { "status": "error", "message": "pesan error" }
06

Database Schema

Storage model: Google Sheets digunakan sebagai flat-file relational store. Baris 1 = header. Data mulai baris 2. Tidak ada query engine — GAS membaca seluruh sheet ke 2D array lalu filter di JavaScript. Semua ID bertipe string dengan prefix E untuk mencegah Sheets meng-konversi angka panjang ke float64.

Sheet: Nasabah

KolomTipeKeterangan
🔑 idstringPK. Format: 'E' + Date.now() + random 4 digit
namastringNama lengkap
emailstringLogin identifier (case-insensitive)
phonestringLogin identifier alternatif
🔒 tokenstringPIN. Tidak pernah dikembalikan ke client.
saldonumberRunning balance Rp. Di-update in-place.
rolestring'' = nasabah biasa. 'admin' = dapat login sebagai admin.

Sheet: Pelapak

KolomTipeKeterangan
🔑 idstringPK
namastringNama perusahaan / individu
emailstringLogin identifier
phonestringLogin identifier alternatif
🔒 tokenstringPIN. Tidak pernah dikembalikan ke client.

Sheet: JenisSampah

KolomTipeKeterangan
🔑 idstringPK
namastringNama jenis (misal: Plastik, Kertas)
warnastringCSS hex color untuk visualisasi chart
v2 change: Kolom kursNasabah dan kursPelapak dipindah ke sheet terpisah (KursNasabah & KursPelapak) untuk mendukung versioning dan per-pelapak pricing.

Sheet: KursNasabah (baru di v2)

KolomTipeKeterangan
🔑 idstringPK
🔗 jenisIdstringFK → JenisSampah.id
kursNasabahnumberRp per kg dibayarkan KE nasabah
berlakuDaristringTanggal mulai berlaku (yyyy-MM-dd)
berlakuSampaistringTanggal berakhir. Kosong = masih aktif.
catatanstringAlasan perubahan harga (audit trail)

Sheet: KursPelapak (baru di v2)

KolomTipeKeterangan
🔑 idstringPK
🔗 pelapakIdstringFK → Pelapak.id. Harga bersifat per-pelapak.
🔗 jenisIdstringFK → JenisSampah.id
kursPelapaknumberRp per kg diterima DARI pelapak. Harus > kursNasabah (margin bank).
berlakuDaristringTanggal mulai berlaku
berlakuSampaistringKosong = masih aktif
catatanstringAlasan perubahan / catatan negosiasi

Sheet: Timbangan

KolomTipeKeterangan
🔑 idstringPK
🔗 nasabahIdstringFK → Nasabah.id. Cascade-delete.
tanggalstringISO date (yyyy-MM-dd)
🔗 jenisIdstringFK → JenisSampah.id. Cascade-delete.
beratnumberBerat dalam kg saat penimbangan
kreditnumberround(berat × kursSnapshot). Dihitung saat tulis, immutable.
kursSnapshotnumberKurs aktif saat transaksi. Disimpan agar historis tidak berubah jika harga diupdate.

Sheet: PenjualanPelapak

KolomTipeKeterangan
🔑 idstringPK
🔗 pelapakIdstringFK → Pelapak.id. Cascade-delete.
tanggalstringISO date
🔗 jenisIdstringFK → JenisSampah.id. Cascade-delete.
beratnumberBerat dijual dalam kg
totalnumberround(berat × kursSnapshot). Immutable.
kursSnapshotnumberKurs pelapak aktif saat transaksi.

Sheet: Penarikan

KolomTipeKeterangan
🔑 idstringPK
🔗 nasabahIdstringFK → Nasabah.id. Cascade-delete.
tanggalstringISO date
jumlahnumberJumlah ditarik dalam Rp. Minimum 10.000.
metodestringMetode pencairan dipilih nasabah
statusstring'Menunggu' saat dibuat. Admin update ke 'Selesai' setelah transfer.

Sheet: ErrorLog

KolomTipeKeterangan
timestampstringServer: formatted via Utilities.formatDate. Client: ISO 8601.
actionstringAction yang sedang dieksekusi saat error
errorstringError message + 3 baris stack trace
rolestringRole user saat error
userIdstringUser ID saat error. Kosong = unauthenticated.
detailsstring500 karakter pertama request body
Ring buffer: Baris terlama otomatis dihapus ketika total melebihi 1.000 baris.
07

Entity Relationship Diagram

👤 Nasabah
🔑 idstringPK
namastring
emailstringlogin
phonestringlogin alt
🔒 tokenstringPIN
saldonumberRp
rolestring''|'admin'
🏪 Pelapak
🔑 idstringPK
namastring
emailstringlogin
phonestringlogin alt
🔒 tokenstringPIN
JenisSampah
🔑 idstringPK
namastringPlastik, dll
warnastringCSS hex
💱 KursNasabah
🔑 idstringPK
🔗 jenisIdstringFK → Jenis
kursNasabahnumberRp/kg
berlakuDaristringyyyy-MM-dd
berlakuSampaistring'' = aktif
catatanstringalasan
🤝 KursPelapak
🔑 idstringPK
🔗 pelapakIdstringFK → Pelapak
🔗 jenisIdstringFK → Jenis
kursPelapaknumberRp/kg
berlakuDaristringyyyy-MM-dd
berlakuSampaistring'' = aktif
catatanstringnegosiasi
Timbangan
🔑 idstringPK
🔗 nasabahIdstringFK → Nasabah
tanggalstringyyyy-MM-dd
🔗 jenisIdstringFK → Jenis
beratnumberkg
kreditnumberRp, immutable
kursSnapshotnumberrate frozen
🛒 PenjualanPelapak
🔑 idstringPK
🔗 pelapakIdstringFK → Pelapak
tanggalstringyyyy-MM-dd
🔗 jenisIdstringFK → Jenis
beratnumberkg
totalnumberRp, immutable
kursSnapshotnumberrate frozen
💸 Penarikan
🔑 idstringPK
🔗 nasabahIdstringFK → Nasabah
tanggalstringyyyy-MM-dd
jumlahnumberRp, min 10k
metodestringtransfer, tunai
statusstringMenunggu→Selesai
🪲 ErrorLog
timestampstringISO / formatted
actionstringGAS action
errorstringmsg + stack
rolestringuser role
userIdstringuser id
detailsstring500 chars

Relationship Map

Nasabah JenisSampah Pelapak Timbangan KursNasabah Penarikan PenjualanPelapak KursPelapak 1 ──< N 1 ──< N 1 ──< N 🟢 Nasabah-side FK 🟤 JenisSampah FK 🔵 Pelapak-side FK --- = 1-to-many relationship

Relasi Ringkas

-- One-to-many relationships Nasabah ──< Timbangan (Nasabah.id = Timbangan.nasabahId) Nasabah ──< Penarikan (Nasabah.id = Penarikan.nasabahId) Pelapak ──< PenjualanPelapak (Pelapak.id = PenjualanPelapak.pelapakId) Pelapak ──< KursPelapak (Pelapak.id = KursPelapak.pelapakId) JenisSampah ──< Timbangan (JenisSampah.id = Timbangan.jenisId) JenisSampah ──< PenjualanPelapak (JenisSampah.id = PenjualanPelapak.jenisId) JenisSampah ──< KursNasabah (JenisSampah.id = KursNasabah.jenisId) JenisSampah ──< KursPelapak (JenisSampah.id = KursPelapak.jenisId) -- Composite unique constraint (conceptual, not enforced by Sheets) KursPelapak UNIQUE (pelapakId, jenisId) WHERE berlakuSampai = '' KursNasabah UNIQUE (jenisId) WHERE berlakuSampai = ''
08

ID Generation Strategy

// GAS — server-side ID generator function _id() { return 'E' + String(Date.now()) + String(Math.floor(Math.random() * 9000 + 1000)); } // Contoh: 'E17234561234' + '5478' = 'E172345612345478' // HTML — client-side temporary ID (optimistic update) const tmpId = 'tmp-' + D.nextId++; // Diganti ID nyata setelah server confirm
  • Prefix 'E' memaksa Google Sheets menyimpan nilai sebagai teks. Tanpa prefix, ID 17-digit numerik akan di-konversi ke float64 dan kehilangan 2 digit terakhir.
  • Date.now() memberikan timestamp milidetik (13 digit). Dikombinasikan dengan 4 digit random, probabilitas collision per milidetik ≈ 0,01%.
  • Semua FK comparison menggunakan String(a) === String(b) untuk menghindari type mismatch antara ID numerik (demo data) dan string (GAS data).
09

Frontend Architecture

Screen Management

Seluruh aplikasi ada dalam satu file HTML. Multiple <div class="screen"> hadir di DOM bersamaan; hanya satu yang aktif dengan toggle CSS class active. Tidak ada routing URL.

Screen IDUntukDiaktifkan oleh
landingUnauthenticatedLoad awal, doLogout()
auth-screenForm logingoAuth(role)
dash-nasabahSesi NasabahdoLogin() → serverRole='nasabah'
dash-adminSesi AdmindoLogin() → serverRole='admin'
dash-pelapakSesi PelapakdoLogin() → serverRole='pelapak'

GAS ↔ Frontend Field Mapping

GAS menggunakan nama field deskriptif panjang. Frontend memetakan ulang ke alias pendek di _loadAll() untuk efisiensi di render functions.

SheetGAS fieldD cache field
TimbangannasabahIdnsId
Timbangantanggaltgl
TimbanganjenisIdjId
Timbanganberatkg
PenjualanPelapakpelapakIdplId
Penarikanjumlahjml
JenisSampahkursNasabah (via lookup)getKursNasabah(id)
JenisSampahkursPelapak (via lookup)getKursPelapak(plId, id)
10

Batasan & Catatan Operasional

BatasanDampakBatas Aman
Full table scan per requestGAS membaca seluruh sheet setiap panggilan< 10.000 baris per sheet
GAS cold startRequest pertama setelah idle bisa 3–8 detikTidak ada workaround — by design
Tidak ada session persistenceTutup tab = logout. Tidak ada remember-me.Acceptable untuk skala komunitas
Tidak ada row-level lockingDua admin simultan menulis saldo bisa race conditionAman untuk < 500 user, 2 tx/bulan
GAS execution limit6 menit per call — cascade delete dataset besar bisa timeoutAman untuk volume yang ada
getAll return all dataNasabah bisa melihat daftar member di network tabAcceptable untuk bank sampah komunitas
Kapasitas yang direncanakan: Maksimum 500 nasabah, rata-rata 2 transaksi per bulan per nasabah = ~12.000 baris Timbangan per tahun. Google Sheets cukup untuk skala ini dengan performa yang baik selama beberapa tahun operasi.