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.
// 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.
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.
// 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.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.
// 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.
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.
// 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() },
});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.

