Membangun POS + ERP Mobile untuk UKM Cuci Mobil Indonesia

Foto oleh Kim Siever via Openverse (Public Domain 1.0)
Setiap permukaan punya tugas yang benar-benar berbeda. Kasir butuh POS cepat berbasis keyboard di desktop, pemilik butuh konsol admin padat laporan, dan kru cuci butuh aplikasi yang tetap berjalan saat wifi di area cuci putus. Satu aplikasi web tidak bisa mengantre penulisan offline secara andal di tablet Android murah, jadi permukaan lapangan harus berupa aplikasi Flutter native dengan outbox-nya sendiri.
Semua uang disimpan sebagai bilangan bulat rupiah Indonesia, tidak pernah sebagai float. Rupiah tidak punya sub-unit dalam pemakaian sehari-hari, jadi harga hanyalah bilangan bulat dan tidak ada alasan memakai floating point sama sekali. Ini menghilangkan bug pembulatan klasik 0,1 tambah 0,2 dari setiap perhitungan total, pajak, dan penggajian di sistem.
Kasbon adalah istilah Indonesia untuk uang muka karyawan yang diambil atas gaji masa depan. Dalam sistem ia adalah hutang nyata yang dilacak dalam modulnya sendiri, bukan catatan informal. Saat penggajian berjalan, kasbon yang belum lunas dipotong dari gaji karyawan, dan uang muka itu juga muncul sebagai peristiwa kas keluar di buku kas turunan.
Setiap kueri daftar dan detail dibatasi oleh branchId yang diambil dari sesi terautentikasi, tidak pernah dari nilai yang bisa dikirim klien. Ini datang langsung dari perbaikan insecure direct object reference di mana sebuah endpoint order mengembalikan order apa pun berdasarkan id tanpa filter cabang. Setelah perbaikan, baris yang bukan milik cabang Anda praktis tidak ada bagi sesi Anda.
Aplikasi lapangan bersifat offline-first. Setiap penulisan masuk ke outbox lokal dan langsung kembali, dan sebuah listener konektivitas menguras antrean secara berurutan begitu tautan kembali. Karena setiap fitur punya repository Api live dan repository Mock di balik antarmuka yang sama, mode offline dan mode demo berbagi satu jalur kode dan mudah diuji.

Foto oleh Kim Siever via Openverse (Public Domain 1.0)
Ringkasan Utama
JID Carwash ERP adalah sistem tiga permukaan untuk UKM cuci mobil Indonesia: API NestJS plus Prisma, POS dan konsol admin Next.js, serta aplikasi lapangan Flutter. Sistem berjalan offline di area cuci basah, memperlakukan kas sebagai kelas utama, memodelkan kasbon karyawan, dan mengisolasi data tiap cabang. Uang disimpan sebagai bilangan bulat rupiah, bukan float.
Klien menjalankan jaringan kecil cabang cuci mobil di seputar kota. Sebelum proyek ini mereka bergantung pada struk kertas, grup WhatsApp yang ramai, dan satu spreadsheet bersama yang tidak dipercaya siapa pun di akhir bulan. Kas hilang, hitungan stok tidak pernah cocok, dan pemilik tidak bisa tahu cabang mana yang benar-benar untung. Ringkasannya mudah diucapkan tapi sulit dibangun: satu sistem, tiga tempat pemakaian yang sangat berbeda.
Saya membangunnya sebagai monorepo dengan tiga permukaan yang dapat dideploy dan berbagi satu pemahaman tentang domain. Kasir mendapat POS desktop yang cepat. Pemilik mendapat konsol admin dengan laporan. Kru cuci mendapat aplikasi ponsel atau tablet yang tetap berjalan saat wifi di area cuci putus, yang sering terjadi. Tulisan ini adalah gambaran produk dan rekayasa: permukaannya, kendala yang membentuknya, dan sejumlah kecil invarian yang saya tolak untuk dilanggar.
Ketiga aplikasi terlihat sangat berbeda, tetapi semuanya berbicara ke model domain yang sama. Di sisi API, setiap area bisnis mendapat modulnya sendiri — order, pembayaran, sesi kas, inventori, penggajian, pelanggan, kendaraan, cabang, dan lainnya. Di sisi klien, baik POS web maupun aplikasi Flutter menyembunyikan jaringan di balik klien live dan klien mock yang berbagi satu kontrak bertipe, sehingga saya bisa mengembangkan dan mendemokan permukaan mana pun tanpa server berjalan.
Hampir setiap keputusan yang tidak jelas berakar pada kendala dunia nyata, bukan preferensi teknis. Ini UKM Indonesia, bukan startup SaaS, dan toko fisiknya jauh lebih menentukan perangkat lunak daripada framework mana pun.
Dua aturan data bekerja lebih keras daripada apa pun di sistem ini. Pertama, uang selalu bilangan bulat rupiah — tidak pernah float. Rupiah tidak punya sub-unit dalam pemakaian sehari-hari, jadi harga hanyalah bilangan bulat, dan aritmetika floating point tidak punya tempat di dekatnya. Kedua, buku kas yang dibaca pemilik tiap malam sama sekali bukan tabel tersimpan. Itu adalah tampilan gabungan turunan yang dihitung sesuai permintaan dari peristiwa yang benar-benar menggerakkan kas: pembayaran masuk, kasbon keluar, pembelian keluar. Tidak ada yang menulis baris ke buku besar, karena buku besar adalah proyeksi, bukan sumber.
// CashLedgerEntry is NEVER a table. It is a DERIVED union view.
// The source of truth is the domain events that actually moved cash.
const ledger = [
...payments.map(p => ({ ts: p.paidAt, type: 'IN', amountIdr: p.amountIdr })),
...kasbons.map(k => ({ ts: k.issuedAt, type: 'OUT', amountIdr: k.amountIdr })),
...purchases.map(x => ({ ts: x.paidAt, type: 'OUT', amountIdr: x.totalIdr })),
].sort((a, b) => a.ts.getTime() - b.ts.getTime());
// Product.stockQty is only a cache. The truth is the movement sum:
// SELECT SUM(qty_delta) FROM stock_movement WHERE product_id = $1;Pola yang sama berlaku untuk stok. Product.stockQty adalah cache untuk pembacaan cepat; sumber kebenaran adalah jumlah dari setiap baris StockMovement. Ketika kuantitas cache berbeda dengan jumlah pergerakan, jumlah pergerakanlah yang menang dan cache dibangun ulang — tidak pernah sebaliknya.
Di awal, sebuah endpoint detail order menerima id dan mengembalikan order tanpa filter cabang. Saat pengujian saya membuka order cabang lain hanya dengan menebak id berurutan — kasus klasik insecure direct object reference. Perbaikannya menjadi aturan keras untuk seluruh basis kode: setiap kueri daftar dan detail dibatasi oleh branchId yang diambil dari sesi terautentikasi, tidak pernah dari apa pun yang bisa dikirim klien. Jika baris itu bukan milik cabang Anda, baris itu sama saja tidak ada bagi sesi Anda.
// Every list/detail query is scoped by branchId from the SESSION,
// never from the request body or params.
async findOrder(orderId: string, session: Session) {
return this.prisma.order.findFirst({
where: { id: orderId, branchId: session.branchId },
});
}
// The IDOR that forced the rule: GET /orders/:id with no branch filter
// let a cashier at branch A open branch B's order by guessing the id.Aplikasi lapangan tidak pernah menghambat kru karena jaringan. Setiap penulisan — memulai cuci, menandai selesai, menerima pembayaran tunai — masuk ke outbox lokal terlebih dahulu dan langsung kembali. Sebuah listener konektivitas memantau tautan, dan saat kembali, outbox dikuras berurutan ke API live. Karena setiap fitur punya repository Api dan repository Mock di balik antarmuka yang sama, mode offline dan mode demo adalah jalur kode yang sama, yang membuat semuanya dapat diuji tanpa pernah menyentuh router.
// field-app: every write hits a local outbox first, then drains when online.
final repo = online ? ApiOrderRepository(dio) : MockOrderRepository();
await outbox.enqueue(CreateOrderOp(payload));
Connectivity().onConnectivityChanged.listen((status) {
if (status != ConnectivityResult.none) {
outbox.drain(); // replay queued ops in order against the live API
}
});Pasangan repository live-versus-mock membayar dirinya dua kali. Ia membuat saya bisa membangun seluruh aplikasi lapangan sebelum API selesai, dan memberi klien demo yang sepenuhnya dapat diklik di pesawat dengan mode pesawat menyala. Rancang lapisan data Anda agar jaringan menjadi satu implementasi yang dapat ditukar, bukan asumsi yang ditanam keras.
Pembayaran adalah tempat percobaan ulang offline menjadi berbahaya. Kasir di wifi tidak stabil menekan selesaikan, permintaan timeout, mereka menekan lagi — dan tanpa proteksi itu adalah pembebanan ganda. Karena itu penyelesaian membutuhkan kunci idempotensi: klien menghasilkan satu per percobaan penyelesaian, dan server mengembalikan hasil sebelumnya jika sudah pernah melihat kunci itu. Percobaan ulang aman secara desain. Di sampingnya, log audit bersifat hanya-tambah dan menyunting data pribadi saat penulisan, sehingga selalu ada catatan jujur tentang siapa melakukan apa, tanpa membocorkan nomor telepon pelanggan ke log.
// settlement.service.ts — client sends an Idempotency-Key on settle.
const existing = await this.prisma.settlement.findUnique({
where: { idempotencyKey: key },
});
if (existing) return existing; // safe retry: return the prior result
return this.prisma.settlement.create({
data: { paymentId, idempotencyKey: key, amountIdr, settledAt: new Date() },
});| Permukaan | Tumpukan | Mengapa dipilih |
|---|---|---|
| API | NestJS 10, Prisma 5, PostgreSQL | Satu modul per domain menjaga batas tetap bersih; Prisma memberi skema bertipe plus migrasi nyata; Postgres menangani tampilan turunan dan penjumlahan. |
| POS dan admin | Next.js 15 App Router | UI kasir cepat berbasis keyboard dan admin padat laporan dalam satu basis kode, dengan kontrak TypeScript bersama ke API. |
| Aplikasi lapangan | Flutter 3.22, Riverpod, go_router | Satu basis kode untuk tablet Android murah di area cuci, dengan sinkronisasi outbox offline-first via connectivity_plus. |
Tidak satu pun pilihan ini eksotis, dan itulah intinya. Untuk bisnis kecil, risikonya bukan framework — melainkan pemodelan domain. Benarkan uang bilangan bulat, buku besar turunan, pembatasan cabang, dan penulisan idempoten, dan sisanya adalah CRUD biasa yang bisa dirilis cepat. Salah menanganinya dan sebanyak apa pun poles framework tidak akan membuat pemilik mempercayai angkanya.