Building an Operations Platform for a Shoe Cleaning Shop

What I learned building UmimaClean - why frozen prices are the most important decision in the schema, how a conditional UPDATE stops two staff driving to the same address, why a payment webhook needs an escape hatch, and the parts I would build differently.

Most of what I have written here is about servers. This one is about the thing running on them.

UmimaClean is a shoe, bag, and helmet cleaning service in Bandung. It runs the way a lot of small services run: orders arrive over WhatsApp, prices are quoted verbally, staff assignments get agreed in a group chat, and revenue is worked out at the end of the month from a stack of receipts.

That works, right up until it doesn’t. Two staff drive to the same address. A customer is quoted one price and charged another. An order sits finished on a shelf because nobody remembers whose it is. The owner cannot answer the one question that matters — which services actually make money.

I built the platform that replaces that. This is what the interesting decisions turned out to be, and which of them I got wrong.

The Shape of the Problem

Three audiences, one record.

WhoWhat they doWhere
CustomersBook a pickup, track the order, pay, get a receiptTheir phone
Field & shop staffClaim pickups and deliveries, inspect and price items, serve walk-insVan or counter
OwnerWatch the shop, manage the catalogue, resolve payments, read reportsDesktop

The temptation with three audiences is to build three applications. I built one, with role-based routing, because they are all looking at the same objects. The customer’s “your shoes are being cleaned” and the staff member’s “this batch is in the wash” are the same row.

The whole system hangs off one order lifecycle. Every participant sees the same status at the same time:

Customer books
      │
      ▼
Penjemputan Dijadwalkan  ──► Dalam Penjemputan ──► Dalam Inspeksi
                                                          │
                                                          ▼
                                                 Menunggu Pelunasan
                                                          │
                                                          ▼
Walk-in at counter ─────────────────────────────► Dalam Pencucian
                                                     │        │
                                       nothing to deliver     ▼
                                                     ▼   Dalam Pengantaran
                                              Siap Diambil    │
                                                     └────────┴──► Selesai

Nine statuses, defined once as a const object rather than a TypeScript enum:

export const OrderStatus = {
	PICKUP_SCHEDULED: 'pickup_scheduled',
	IN_PICKUP: 'in_pickup',
	IN_INSPECTION: 'in_inspection',
	AWAITING_PAYMENT: 'awaiting_payment',
	IN_CLEANING: 'in_cleaning',
	CLEANING_DONE: 'cleaning_done',
	IN_DELIVERY: 'in_delivery',
	COMPLETED: 'completed',
	CANCELLED: 'cancelled'
} as const

export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus]

Each enum file also exports a label map holding the Indonesian UI string. Filter dropdowns and chart legends are built by mapping Object.values(), so adding a status makes it appear in the admin filter without anyone remembering to go and add it.

That Siap Diambil status — “ready for collection” — is the one I did not anticipate. A walk-in order that has been washed is done, paid for, and still in the shop. Moving it straight to “completed” would have lost the only fact anybody needed: whose shoes are on the shelf.

The Most Important Decision in the Schema

If I could keep one design decision from this project and throw away the rest, it would be this one.

The price catalogue is editable. The owner raises the price of “Deep Clean Sepatu” and every future order costs more. Obvious, and correct.

What is not obvious is what happens to orders that already exist. If an order line points at a service row, then raising that price silently rewrites what last month’s customers were charged, breaks every receipt already printed, and changes every historical report. The books move retroactively because somebody edited a price list.

So order lines carry a frozen copy of the name and price, written at inspection time:

name:     `${service.name} - ${item.brand} ${item.model}`,
price:    Number(service.price),
subtotal: price,

service_id is still there, but only so reports can join on it. Its foreign key is RESTRICT, which means a service that has ever priced an order can never be deleted — the receipt for that order still names it.

The copy is the contract. Everything downstream — receipts, revenue reports, the reconciliation backlog — is only trustworthy because of that one denormalisation.

The general form of this rule: anything a customer was told is a historical fact, not a lookup. Copy it at the moment you tell them. The same logic is why customer_name and customer_phone are copied onto the order at creation, and why they come from the address rather than the account — a customer may well be booking on behalf of somebody else, and it is whoever is at that door that staff need to ask for.

Two Staff, One Address

This is the failure the shop actually described to me, and it is a concurrency problem wearing an apron.

Available pickups sit on a shared task board. Two staff open the app, see the same card, and both tap it. Both drive across Bandung. One of them wasted their morning.

The naive fix is a read followed by a write:

const order = await Order.findBy('order_number', orderNumber)

if (order.lockedById) {
	throw new Error('Already claimed')
}

await order.merge({ lockedById: staff.id }).save() // ← race lives here

Two requests can both pass the check before either performs the write. The window is small and it is real — two people tapping the same card in the same second is exactly the scenario, not a hypothetical one.

The fix is to let Postgres decide, with a conditional UPDATE that carries its own precondition:

const claimed = await db.transaction(async (trx) => {
	const won = affectedRows(
		await this.whereUnlocked(Order.query({ client: trx }).where('id', order.id)).update({
			locked_by_id: staff.id,
			locked_task: type,
			locked_until: now.plus({ hours: CLAIM_DURATION_HOURS }).toSQL(),
			...(type === ActionName.PICKUP ? { status: OrderStatus.IN_PICKUP } : {})
		})
	)

	if (won === 0) {
		return false
	}

	// ... record the attempt in the audit trail
	return true
})

Where whereUnlocked is the precondition:

query.where((free) => {
	free.whereNull('locked_until').orWhere('locked_until', '<=', now)
})

Postgres answers an UPDATE with the number of rows it touched. Zero means the row no longer matched — somebody else’s claim landed first. There is no window between checking and writing, because the check is the write.

The loser does not get an error. They get the current lock and a “somebody else has this” view, because from their point of view nothing went wrong; they just tapped second.

The Lock Has to Expire

A lock with no expiry is a support ticket waiting to happen. Flat battery, end of shift, somebody simply forgot — and now a pickup is held by a person who is not doing it, until an admin goes and edits a row.

Three hours:

const CLAIM_DURATION_HOURS = 3

That number is about the city, not the task. Crossing Bandung can take an hour in traffic and a run of stops is several of those, so three hours leaves somebody who is genuinely still driving well clear of losing a stop out from under them — while making sure an abandoned claim is back in the queue the same day.

There is no cleanup job. An expired claim is not a lock, and whereUnlocked already says so: locked_until <= now is indistinguishable from never locked. Expiry that requires a sweeper is expiry that can fail.

What This Replaced

The first version derived the lock in JavaScript by replaying each order’s action log — every “attempted pickup” and “released pickup” row, in order, to work out who currently held it.

It worked. It was also the wrong shape: every queue had to preload the actions and then filter rows it had already fetched, doing work no index could help with, growing with every action ever recorded.

Three columns on the order (locked_by_id, locked_task, locked_until) turned “is this free?” into a question the database can answer in the WHERE clause. The action log still records everything — it just stopped being load-bearing for a decision made on every page render.

Order Numbers People Can Say Out Loud

Orders are identified by ORDYYMMDD-NNN. ORD260728-001 is the first order on 28 July 2026. The sequence restarts daily.

This is not the primary key. It is the identity that gets read down a phone, written on a paper tag hanging off a shoe, and typed into a search box. A UUID would be correct and unusable.

Generating it is the obvious thing:

async generateOrderNumber(): Promise<string> {
  const prefix = `ORD${DateTime.now().toFormat('yyLLdd')}`

  const lastOrder = await Order.query()
    .where('order_number', 'like', `${prefix}-%`)
    .orderBy('order_number', 'desc')
    .first()

  const lastSequence = lastOrder ? Number.parseInt(lastOrder.orderNumber.split('-')[1], 10) : 0

  return `${prefix}-${String(lastSequence + 1).padStart(3, '0')}`
}

Which has the same race as the naive lock: two orders created in the same instant both read -004 and both try to write -005.

I did not solve it with a lock. There is a unique constraint on the column, so the database will refuse the second one — and the caller retries with a freshly generated number:

for (let attempt = 0; attempt < ORDER_NUMBER_ATTEMPTS; attempt++) {
	try {
		return await create(await this.generateOrderNumber())
	} catch (error) {
		if (!isDuplicateOrderNumber(error)) {
			throw error
		}
		lastError = error
	}
}

throw lastError

Three attempts. Both creation paths — online booking and walk-in intake — go through it, so a collision is never something a customer or a staff member sees. They get an order, not an error.

The difference in approach is deliberate. The claim lock needs to be correct under contention, because contention is the normal case: a shared board is designed to be tapped by several people. Order numbers collide rarely, and the retry is cheap. Optimistic where collisions are rare, pessimistic where they are the point.

Payments, and the Webhook That Never Arrived

QRIS payment goes through Midtrans. The customer pays, Midtrans calls a webhook, the order moves on.

The endpoint is public, because Midtrans calls it directly. Authenticity is proven by the payload signature, not a session:

async update({ request, response }: HttpContext) {
  const payload = request.body() as MidtransNotification

  if (!verifyNotificationSignature(payload)) {
    return response.forbidden({ message: 'Invalid signature' })
  }

  await this.transactionService.handleNotification(payload)

  return response.ok({ message: 'OK' })
}

That part is standard. The part that took me longer to accept is that the webhook will sometimes not arrive. Network blips, a deploy at the wrong second, a retry policy that gave up. It is rare. It is not zero.

When it happens, the order sits in Menunggu Pelunasan forever. The customer has paid and cannot prove it to the system. Staff have no tool for it. Nothing in the normal flow can rescue that order.

So there is a deliberate escape hatch — an admin-only reconciliation screen listing every order stuck awaiting payment, oldest first, on the reasoning that the longer one has sat there the more likely it is a lost callback rather than a customer who simply has not paid yet.

The constraints on it matter more than the feature:

  • Admin only. Not staff.
  • Cash or card only. A QRIS payment is always confirmed by Midtrans, because Midtrans is the only party that can know whether the money moved. The override exists for money that arrived in person and was witnessed by a human.
  • A reason is required, and the override is permanently recorded in that administrator’s name.

An override that is easy, unattributed, and available to everyone is not a safety valve — it is a hole in the accounting. The whole design is “possible, but never casual”.

There is a matching constraint one level down, in the schema. A partial unique index allows at most one pending transaction per order:

CREATE UNIQUE INDEX transactions_order_id_pending_unique
  ON transactions (order_id) WHERE status = 'pending'

An order can accumulate several transaction rows over time — QR expires, customer retries — but only ever one live one. Which is also why revenue is summed from orders.total_price and never from transaction rows.

A Deliberately Boring Stack

LayerChoice
HTTP frameworkAdonisJS 7 (Node 24, TypeScript, ESM)
ORMLucid 22 over PostgreSQL
UIReact 19 + Inertia.js 2
StylingTailwind 4
RealtimeServer-sent events via @adonisjs/transmit
PaymentsMidtrans Core API
WhatsAppFonnte HTTP API
SpreadsheetsExcelJS

One process, one database. No queue, no worker, no cache server. Anything that looks like a background job is done inline inside the request. The only thing that runs on a schedule is a housekeeping command, pointed at by cron or a systemd timer:

node ace prune:records    # delete expired proof photos and orphaned addresses

Two choices in there are worth defending.

Inertia instead of a REST API. Controllers render page components with props. There is no API layer, no client-side data fetching, no duplicated request/response types. For an application with exactly one consumer — its own frontend — an API is a boundary you maintain for the benefit of nobody. If a mobile app ever needs one, adding it later is a known amount of work; carrying it from day one is a permanent tax.

Server-sent events instead of WebSockets. There are two channels — orders/:orderNumber, which a customer’s open order page listens on, and admin/orders, the shop-wide feed. Both are entirely one-directional: the server tells a screen something changed and the screen never answers back. SSE is a plain HTTP response that reconnects on its own; WebSockets would have meant a second protocol and a second set of failure modes to solve a problem I did not have.

Layer discipline is the rule the codebase actually holds to, and it is what makes the thing navigable months later:

LayerMay doMay not do
ValidatorShape, type, format, uniqueness of a payloadKnow about order status or roles
ControllerRead the request, call services, choose the pageContain a business rule
ServiceEnforce every business rule, own transactionsTouch request / response
TransformerTurn a model into the plain object a page receivesQuery, decide, or compute
ModelRelations and column mappingBusiness rules

The payoff: every business rule is in app/services/, and there is nowhere else it could be hiding.

One convention that turned out to matter more than expected — services throw validation errors, not exceptions:

throw new vineErrors.E_VALIDATION_ERROR([
	{ field: 'pickupDate', message: 'Batas penjemputan per hari sudah penuh untuk tanggal ini.' }
])

A broken business rule renders as an inline error under the right form field, not a 500 page. Which means the rule can live entirely in the service — the one place it is guaranteed to be enforced — and still produce a good UI. No duplicating rules into the frontend to get decent error messages.

History You Cannot Quietly Erase

The system assumes money and other people’s property need a paper trail. That assumption produces a set of rules that are all variations of the same idea.

Every action is attributed. Collections, inspections, cleaning, deliveries, and payment overrides are recorded against the staff member who performed them, with a timestamp.

Accounts are switched off, not deleted. A customer who has ordered or a staff member who has worked cannot be deleted — they appear in the order record. Deactivation stops working on their very next request and takes none of their work with it. The alternative is an order history with holes in it where people used to be.

Administrators cannot lock the business out. An admin cannot delete or demote their own account. It is a small check guarding a genuinely one-way door.

Capacity is enforced, not advisory. The daily pickup limit is checked at booking time, not discovered when the van is already full:

export const DAILY_PICKUP_LIMIT = 10

A password reset ends every other session. A reset exists to lock out whoever knew the old password. Leaving their sessions signed in makes it ceremonial.

Sensitive routes are rate-limited — signup, login, password reset, and payment requests. The payment limiter is keyed on order id rather than IP, and applied inside the service rather than on the route, because most “pay” requests just reuse an existing pending QR and never reach Midtrans at all. Only a genuinely new charge should be metered.

The Part I Would Change

Proof photos go to a private local disk, and the stored reference is a signed URL valid for 90 days:

const key = `${folder}/${randomUUID()}.${photo.extname}`
await drive.use().putStream(key, createReadStream(photo.tmpPath!))

return drive.use().getSignedUrl(key, { expiresIn: PHOTO_RETENTION }) // '90d'

order_actions.photo_path therefore holds a signed URL, not a storage key.

Ninety days is the right retention. A dispute about a collection or a hand-over stays live for about that long; past it, the file is not evidence, it is a picture of a stranger’s front door taking up disk. The audit trail is never touched — who did what and when stays exactly where it is, only the image goes.

But storing the URL instead of the key was a mistake, and it is the kind that only shows up later. The row cannot regenerate its own link, because the key is only recoverable by parsing the URL back apart. It works perfectly as long as the retention policy never changes — and the moment it does, that is a migration over historical rows rather than a constant edit.

Store the key. Derive the URL. The signed URL is a view of the file, and I persisted the view.

What Is Unfinished

Being honest about the edges, because every project has them and pretending otherwise is how you end up rediscovering them at 2 AM.

Route planning is straight-line distance. RouteService sorts stops nearest-first using the Haversine formula from the shop’s coordinates. It is honest about what it is:

/**
 * The route is currently distance-based and does not consider
 * traffic, delivery priority, or route optimization algorithms.
 */

Two kilometres across a river with one bridge is not two kilometres. For a single van in one city it is a useful ordering rather than a correct one, and a routing API would fix it — at the cost of a dependency and a per-call bill.

There is a notifications table nothing writes to. In-app notifications were scoped and not built; WhatsApp covers the messaging that shipped. It is dead weight in the schema, and it is documented as such so nobody goes looking for the missing half.

There is a placeholder page still sitting in the tree. inertia/pages/staff/task/edit.tsx returns <div>Edit</div> and nothing routes to it — the real correction screen lives elsewhere. Harmless, and exactly the sort of thing that sends the next person on a ten-minute detour.

One validator field is validated and never read. The walk-in form sends a totalItems count that the service ignores, because the real count is derived from the items themselves. Two sources for one number, one of which cannot be wrong — so the other should not exist.

What Building It Taught Me

The technical decisions I am happiest with are not the clever ones.

Frozen prices are a denormalisation that any normalisation-minded review would flag, and they are the single reason the financial history is trustworthy. The claim lock is one WHERE clause. The order number retry is a for loop around a unique constraint. None of that is sophisticated.

What they have in common is that each one came from asking what happens when the real world misbehaves — when two people tap at once, when a webhook is dropped, when somebody edits a price list, when a staff member’s phone dies mid-route. Those are not edge cases in an operations system. They are the system. The happy path is the easy part.

The other thing I would keep: writing down why. Not what the code does — you can read that — but what would go wrong if it did the obvious thing instead. The three-hour claim looks arbitrary until you know it is about traffic in Bandung. The frozen price copy looks redundant until you picture last month’s receipts changing.

Six months from now, the reasoning is the part I will not be able to reconstruct.


The full source is at github.com/r3p-dev/skripsi. It is a private, unlicensed codebase built for one business, so it is not something to fork — but the decisions are portable, and those are what this post is really about.

If you want the infrastructure side of how something like this gets deployed, that starts at Setting Up a VPS, Part 1.

© 2026 r3p.dev. All rights reserved.