Building a POS + Mobile ERP for an Indonesian Carwash SME

Photo by Kim Siever via Openverse (Public Domain 1.0)
Each surface has a genuinely different job. The cashier needs a fast keyboard-driven POS on a desktop, the owner needs a report-heavy admin console, and the wash crew needs an app that keeps working when the bay's wifi drops. A single web app cannot reliably queue writes offline on a cheap Android tablet, so the field surface had to be a native Flutter app with its own outbox.
All money is stored as integer Indonesian rupiah, never as a float. The rupiah has no sub-unit in daily use, so a price is simply a whole number and there is no reason to introduce floating point at all. This eliminates the classic 0.1 plus 0.2 rounding bug from every total, tax, and payroll calculation in the system.
Kasbon is an Indonesian term for an employee cash advance taken against future wages. In the system it is a real payable tracked in its own module, not an informal note. When payroll runs, outstanding kasbon is deducted from the employee's pay, and the advance also shows up as a cash-out event in the derived cash book.
Every list and detail query is scoped by the branchId taken from the authenticated session, never from a value the client can send. This came directly from fixing an insecure direct object reference where an order endpoint returned any order by id with no branch filter. After the fix, a row that does not belong to your branch effectively does not exist for your session.
The field app is offline-first. Every write goes into a local outbox and returns immediately, and a connectivity listener drains the queue in order once the link returns. Because every feature has both a live Api repository and a Mock repository behind the same interface, offline mode and demo mode share one code path and are easy to test.

Photo by Kim Siever via Openverse (Public Domain 1.0)
Key Takeaway
JID Carwash ERP is a three-surface system for an Indonesian carwash SME: a NestJS plus Prisma API, a Next.js POS and admin console, and a Flutter field app. It runs offline in wet bays, treats cash as first-class, models employee advances called kasbon, and isolates every branch's data. Money is stored as integer rupiah, never float.
The client runs a small chain of carwash branches around the city. Before this project they lived on paper receipts, a busy WhatsApp group, and one shared spreadsheet that nobody trusted by the end of the month. Cash went missing, stock counts never matched, and the owner could not tell which branch was actually profitable. The brief was simple to say and hard to build: one system, three very different places people would use it.
I built it as a monorepo with three deployable surfaces that share one idea about the domain. The cashier gets a fast desktop POS. The owner gets an admin console with reports. The wash crew gets a phone or tablet app that keeps working when the wifi in the bay drops, which it does often. This post is the product-and-engineering overview: the surfaces, the constraints that shaped them, and the handful of invariants I refused to bend.
The three apps look nothing alike, but they all speak to the same domain model. On the API side, each business area gets its own module — orders, payments, cash sessions, inventory, payroll, customers, vehicles, branches, and more. On the client side, both the web POS and the Flutter app hide the network behind a live client and a mock client that share one typed contract, so I can develop and demo either surface without a running server.
Almost every non-obvious decision traces back to a real-world constraint, not a technical preference. This is an Indonesian SME, not a SaaS startup, and the physical shop dictated the software far more than any framework did.
Two data rules do more work than anything else in the system. First, money is always integer rupiah — never a float. The rupiah has no sub-unit in daily use, so a price is just a whole number, and floating point arithmetic has no place anywhere near it. Second, the cash book that the owner reads every night is not a stored table at all. It is a derived union view computed on demand from the events that actually moved cash: payments in, kasbon out, purchases out. Nobody writes a row to the ledger, because the ledger is a projection, not a source.
// 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;The same pattern applies to stock. Product.stockQty is a cache for fast reads; the source of truth is the sum of every StockMovement row. When a cached quantity disagrees with the movement sum, the movement sum wins and the cache is rebuilt — never the other way around.
Early on, an order detail endpoint took an id and returned the order with no branch filter. In testing I opened another branch's order just by guessing a sequential id — a textbook insecure direct object reference. The fix became a hard rule for the whole codebase: every list and detail query scopes by the branchId taken from the authenticated session, never from anything the client can send. If the row does not belong to your branch, it simply does not exist as far as your session is concerned.
// 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.The field app never blocks the crew on the network. Every write — start a wash, mark it done, take a cash payment — goes into a local outbox first and returns immediately. A connectivity listener watches the link, and when it comes back the outbox drains in order against the live API. Because every feature has both an Api repository and a Mock repository behind the same interface, offline mode and demo mode are the same code path, which made the whole thing testable without ever touching a 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
}
});The live-versus-mock repository pair paid for itself twice. It let me build the entire field app before the API was finished, and it gave the client a fully clickable demo on a plane with airplane mode on. Design your data layer so the network is one swappable implementation, not a hardcoded assumption.
Payments are where offline retries get dangerous. A cashier on flaky wifi taps settle, the request times out, they tap again — and without protection that is a double charge. Settlement therefore requires an idempotency key: the client generates one per settlement attempt, and the server returns the prior result if it has seen that key before. Retrying is safe by construction. Alongside it, the audit log is append-only and redacts personal data on write, so there is always an honest record of who did what, without leaking a customer's phone number into logs.
// 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() },
});| Surface | Stack | Why it won |
|---|---|---|
| API | NestJS 10, Prisma 5, PostgreSQL | One module per domain keeps boundaries clean; Prisma gives a typed schema plus real migrations; Postgres handles the derived views and sums. |
| POS and admin | Next.js 15 App Router | A fast keyboard-driven cashier UI and a report-heavy admin in one codebase, with a shared TypeScript contract to the API. |
| Field app | Flutter 3.22, Riverpod, go_router | One codebase for the cheap Android tablets in the bays, with offline-first outbox sync via connectivity_plus. |
None of these choices are exotic, and that is the point. For a small business the risk is not the framework — it is the domain modelling. Get integer money, derived ledgers, branch scoping, and idempotent writes right, and the rest is ordinary CRUD you can ship fast. Get them wrong and no amount of framework polish will make the owner trust the numbers.