# Building a POS + Mobile ERP for an Indonesian Carwash SME

> How I shipped a three-surface ERP — NestJS API, Next.js POS, Flutter field app — for a cash-heavy, multi-branch carwash that loses wifi in the bays.

URL: https://www.matthewswong.com/en/blog/building-pos-mobile-indonesian-sme-carwash/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-07-14
- Updated: 2026-07-14
- Category: Product

> **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.

## One business, three surfaces, one contract

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.

- apps/api — NestJS 10 with Prisma 5 over PostgreSQL. One module per domain, one Prisma schema as the single source of truth for shape and migrations.
- apps/pos-web — Next.js 15 App Router. The cashier POS plus the owner admin console, with API access behind a real httpClient and a mock mockClient sharing one types.ts contract.
- apps/field-app — Flutter 3.22 on Dart 3.4, using Riverpod and go_router. Every feature ships an Api repository for live use and a Mock repository for offline and demo mode.

## The constraints that actually shaped the build

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.

- Offline bays. The wash area is wet, metal, and far from the router. Wifi drops for minutes at a time, so the field app had to assume no connection is the default, not the exception.
- Cash-heavy. Most customers pay cash. Every shift opens and closes a cash session, and the day's reconciliation is the report the owner cares about most.
- Kasbon. Staff regularly ask for a cash advance against their wages. That is a real payable, tracked in its own module and deducted at payroll time.
- Multi-branch. Several branches share one database and one owner, but a cashier at one branch must never see or touch another branch's orders, cash, or stock.

## Money is an integer, and the cash book is a lie

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.

> 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.

## Every query is scoped by branch (the IDOR fix)

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.

## Offline bays and the outbox

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.

> 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.

## Idempotent settlement and an append-only audit log

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.

## Stack choices and why

| 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.

## Sources

- NestJS official docs — Prisma recipe
- Modern Treasury — Floats Don't Work For Storing Cents
- How Stripe Prevents Double Payments With Idempotency Keys

## Source links

- https://docs.nestjs.com/recipes/prisma
- https://www.moderntreasury.com/journal/floats-dont-work-for-storing-cents
- https://singhajit.com/how-stripe-prevents-double-payment/

## FAQ

### Why build three separate apps instead of one responsive web app?

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.

### How is money stored to avoid rounding errors?

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.

### What is kasbon and how is it modeled in the ERP?

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.

### How does the system stop one branch from seeing another branch's data?

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.

### How does the Flutter field app work without an internet connection?

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.

---

- HTML version: https://www.matthewswong.com/en/blog/building-pos-mobile-indonesian-sme-carwash/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/building-pos-mobile-indonesian-sme-carwash/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
