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.
Penyimpanan data tabular persisten; tanpa SQL engine
User Roles
Role
Dashboard
Akses
Nasabah
dash-nasabah
Saldo · Buku Tabungan · Riwayat Timbangan · Tarik Saldo · Harga
Admin
dash-admin
Timbangan · Jual ke Pelapak · Kelola Member · Kurs · Laporan · Kas · Error Log
Pelapak
dash-pelapak
Riwayat 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
Field
Tipe
Isi
role
string|null
'nasabah' | 'admin' | 'pelapak' | null
user
object|null
Objek user aktif (tanpa token)
nextId
number
Counter untuk temporary ID (tmp-N)
jenis
array
Semua JenisSampah: id, nama, warna
kursNasabah
array
Semua KursNasabah: versioned rate per jenis
kursPelapak
array
Semua KursPelapak: rate per pelapak per jenis
nasabah
array
Semua Nasabah: id, nama, email, phone, saldo, role
Mutasi lokal langsung — record baru di-push ke D dengan temporary ID tmp-N. UI re-render instan, zero perceived latency.
Background POST — data dikirim ke GAS secara asynchronous. User bisa terus bekerja.
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)
Action
Auth
Returns
getStats
Publik
nasabahCount, pelapakCount, totalKg — untuk landing page hero
POST Actions (doPost)
Action
Auth
Deskripsi
login
token
Verifikasi identifier + token. Return user object (tanpa token). Admin harus punya role='admin' di sheet Nasabah.
getAll
userId + token
Return semua 6 tabel data. Re-validasi token sebelum respond.
saveTimbangan
session
Tulis N baris Timbangan. Update Nasabah.saldo. Return {ids, totalKredit, saldoBaru}.
saveJual
session
Tulis N baris PenjualanPelapak. Ambil kurs dari KursPelapak. Return {ids}.
Return 100 baris ErrorLog terbaru, terbaru pertama.
logError
none
Tulis 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
Kolom
Tipe
Keterangan
🔑 id
string
PK. Format: 'E' + Date.now() + random 4 digit
nama
string
Nama lengkap
email
string
Login identifier (case-insensitive)
phone
string
Login identifier alternatif
🔒 token
string
PIN. Tidak pernah dikembalikan ke client.
saldo
number
Running balance Rp. Di-update in-place.
role
string
'' = nasabah biasa. 'admin' = dapat login sebagai admin.
Sheet: Pelapak
Kolom
Tipe
Keterangan
🔑 id
string
PK
nama
string
Nama perusahaan / individu
email
string
Login identifier
phone
string
Login identifier alternatif
🔒 token
string
PIN. Tidak pernah dikembalikan ke client.
Sheet: JenisSampah
Kolom
Tipe
Keterangan
🔑 id
string
PK
nama
string
Nama jenis (misal: Plastik, Kertas)
warna
string
CSS 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)
Kolom
Tipe
Keterangan
🔑 id
string
PK
🔗 jenisId
string
FK → JenisSampah.id
kursNasabah
number
Rp per kg dibayarkan KE nasabah
berlakuDari
string
Tanggal mulai berlaku (yyyy-MM-dd)
berlakuSampai
string
Tanggal berakhir. Kosong = masih aktif.
catatan
string
Alasan perubahan harga (audit trail)
Sheet: KursPelapak (baru di v2)
Kolom
Tipe
Keterangan
🔑 id
string
PK
🔗 pelapakId
string
FK → Pelapak.id. Harga bersifat per-pelapak.
🔗 jenisId
string
FK → JenisSampah.id
kursPelapak
number
Rp per kg diterima DARI pelapak. Harus > kursNasabah (margin bank).
berlakuDari
string
Tanggal mulai berlaku
berlakuSampai
string
Kosong = masih aktif
catatan
string
Alasan perubahan / catatan negosiasi
Sheet: Timbangan
Kolom
Tipe
Keterangan
🔑 id
string
PK
🔗 nasabahId
string
FK → Nasabah.id. Cascade-delete.
tanggal
string
ISO date (yyyy-MM-dd)
🔗 jenisId
string
FK → JenisSampah.id. Cascade-delete.
berat
number
Berat dalam kg saat penimbangan
kredit
number
round(berat × kursSnapshot). Dihitung saat tulis, immutable.
kursSnapshot
number
Kurs aktif saat transaksi. Disimpan agar historis tidak berubah jika harga diupdate.
Sheet: PenjualanPelapak
Kolom
Tipe
Keterangan
🔑 id
string
PK
🔗 pelapakId
string
FK → Pelapak.id. Cascade-delete.
tanggal
string
ISO date
🔗 jenisId
string
FK → JenisSampah.id. Cascade-delete.
berat
number
Berat dijual dalam kg
total
number
round(berat × kursSnapshot). Immutable.
kursSnapshot
number
Kurs pelapak aktif saat transaksi.
Sheet: Penarikan
Kolom
Tipe
Keterangan
🔑 id
string
PK
🔗 nasabahId
string
FK → Nasabah.id. Cascade-delete.
tanggal
string
ISO date
jumlah
number
Jumlah ditarik dalam Rp. Minimum 10.000.
metode
string
Metode pencairan dipilih nasabah
status
string
'Menunggu' saat dibuat. Admin update ke 'Selesai' setelah transfer.
Sheet: ErrorLog
Kolom
Tipe
Keterangan
timestamp
string
Server: formatted via Utilities.formatDate. Client: ISO 8601.
action
string
Action yang sedang dieksekusi saat error
error
string
Error message + 3 baris stack trace
role
string
Role user saat error
userId
string
User ID saat error. Kosong = unauthenticated.
details
string
500 karakter pertama request body
Ring buffer: Baris terlama otomatis dihapus ketika total melebihi 1.000 baris.
// 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 ID
Untuk
Diaktifkan oleh
landing
Unauthenticated
Load awal, doLogout()
auth-screen
Form login
goAuth(role)
dash-nasabah
Sesi Nasabah
doLogin() → serverRole='nasabah'
dash-admin
Sesi Admin
doLogin() → serverRole='admin'
dash-pelapak
Sesi Pelapak
doLogin() → 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.
Sheet
GAS field
D cache field
Timbangan
nasabahId
nsId
Timbangan
tanggal
tgl
Timbangan
jenisId
jId
Timbangan
berat
kg
PenjualanPelapak
pelapakId
plId
Penarikan
jumlah
jml
JenisSampah
kursNasabah (via lookup)
getKursNasabah(id)
JenisSampah
kursPelapak (via lookup)
getKursPelapak(plId, id)
10
Batasan & Catatan Operasional
Batasan
Dampak
Batas Aman
Full table scan per request
GAS membaca seluruh sheet setiap panggilan
< 10.000 baris per sheet
GAS cold start
Request pertama setelah idle bisa 3–8 detik
Tidak ada workaround — by design
Tidak ada session persistence
Tutup tab = logout. Tidak ada remember-me.
Acceptable untuk skala komunitas
Tidak ada row-level locking
Dua admin simultan menulis saldo bisa race condition
Aman untuk < 500 user, 2 tx/bulan
GAS execution limit
6 menit per call — cascade delete dataset besar bisa timeout
Aman untuk volume yang ada
getAll return all data
Nasabah bisa melihat daftar member di network tab
Acceptable 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.